Dealing with memory limits in iOS app extensions

In the iOS app I currently work on there is a Notification Service Extension and a Share Extension. Both extensions have been implemented quite some time age and have been working fine.

Recently I got some bug reports that led to discovering some interesting limits about both of those extension types.

Notification Service Extension

The Notification Service Extension is executed when the iOS app receives a push notification and has a chance to modify the payload before iOS displays the push notification.

I use it to change the push notification sound to the sound the user chose in the app, for better personalization.

Another feature is adding a big red warning image as an attachment to the push notification if the push notification is of an alert type.

I already use the image in the main app so I implemented it quite simply, loading it from the asset catalog, saving it into a file and adding that file as an attachment

let image = #imageLiteral(resourceName: "NotificationAlert")
guard let data = image.jpegData(compressionQuality: 0.8) else {
    return failEarly()
}

try data.write(to: tmp.appendingPathComponent("image.png"), options: [])
let imageAttachment = try UNNotificationAttachment(identifier: "image.png", url: fileURL, options: nil)
content.attachments = [imageAttachment]
contentHandler(content.copy() as! UNNotificationContent)

This worked fine on smaller phones but when users started using bigger phone, like iPhone 11, they started complaining that the image is not shown when they receive an alert push notification.

I was able to reproduce the problem and found out the extension crashed exceeding the 24 MB memory limit. But only on bigger phones.

The problem is that manipulating an UIImage instance does not consume the same amount of memory on every device, it depends on the device screen scaling factor.

On smaller devices with smaller scaling factor the image operations take up less memory, below the extension limit, but on bigger devices the memory limit is exceeded.

I solved this problem by just adding the image to the app bundle as a file and using the file directly, without the additional step of using an UIImage.

[Read More]
iOS  Xcode  UIImage 

Animating annotations position change in MKMapView

Annotations are mainly used to displays static “pins” in MKMapView but sometimes you might need to make them move and animate their position changes so it looks better to the users.

There are a few things you need to do to achieve this.

Coordinate property specifics

The MKAnnotation protocol has a coordinate property that is used by the MKMapView to position the annotation to its corresponding location on the map.

If you just update the property you will quickly see that nothing happens, the annotation does not move on the map.

MKMapView uses KVO to know when the coordinate property changes so in Swift you need to mark the coordinate property in your MKAnnotation asĀ @objc dynamic to make it work

final class LocationViewModel: NSObject, MKAnnotation {
    @objc dynamic coordinate: CLLocationCoordinate2D

    ...
}

With this change you will notice that the annotation now moves on the map, but it is not smooth, it basically jumps from the old position to the new one.

[Read More]
iOS  Xcode  MapKit 

Clustering annotations in MKMapView

If you need to display many annotations in your MKMapView it is recommended to cluster them for better performance.

Map clustering

This means instead of showing all the visible annotations you group annotations that are close together into one single annotation cluster representing them instead.

This cluster annotation usually shows the number of annotations it represents. As you then zoom in to get finer detail the clusters break up and show the actual annotations.

Clustering is supported in MKMapView on iOS 11 and newer, no need to use any custom library. If you need to support older versions of iOS, there are libraries like Cluster that you can use.

Custom cluster view implementation

Let’s say you use a custom annotation view to show your annotations and you want to add support for clustering. You first create a custom MKAnnotationView in the same way

final class LocationDataMapClusterView: MKAnnotationView {

    // MARK: Initialization
    private let countLabel = UILabel()

    override var annotation: MKAnnotation? {
    	didSet {
			 guard let annotation = annotation as? MKClusterAnnotation else {
            	assertionFailure("Using LocationDataMapClusterView with wrong annotation type")
            	return
        	}

    		countLabel.text = annotation.memberAnnotations.count < 100 ? "\(annotation.memberAnnotations.count)" : "99+"
    	}
    }

    override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
        super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)

		displayPriority = .defaultHigh
        collisionMode = .circle

        frame = CGRect(x: 0, y: 0, width: 40, height: 50)
        centerOffset = CGPoint(x: 0, y: -frame.size.height / 2)

       
        setupUI()
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: Setup
    private func setupUI() {
        ...
    }
}

The idea is the same as when custom annotation view, but there is a difference. You need to define displayPriority to tell the map that your cluster annotation has a higher priority that normal annotation.

[Read More]
iOS  Xcode  MapKit 

Using custom annotation views in MKMapView

If you want to display completely custom views as “pins” on the map in your iOS application, you should use annotations. All your data needs to be represented as objects conforming to the MKAnnotation protocol, with title, subtitle and coordinate as the required properties.

Custom view implementation

Visually you represent an MKAnnotation with a MKAnnotationView. You can create a custom class that subclasses MKAnnotationView and implement your custom UI in that class.

Here is an sample MKAnnotationView with fixed size that displays just one custom view

final class LocationAnnotationView: MKAnnotationView, Reusable {

    // MARK: Initialization

    override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
        super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)

        frame = CGRect(x: 0, y: 0, width: 40, height: 50)
        centerOffset = CGPoint(x: 0, y: -frame.size.height / 2)

        canShowCallout = true
        setupUI()
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: Setup

    private func setupUI() {
        backgroundColor = .clear

        let view = MapPinView()
        addSubview(view)

        view.frame = bounds
    }
}

The MKAnnotationView is by default aligned to its corresponding position on map with the bottom left corner. If your MKAnnotationView looks like a pin for example, you need to align it to the position on the map with the bottom center point. To do that you use the centerOffset property as shown.

Registering the custom view with MKMapView

The next step is to tell MKMapView to user your custom class.

Using single custom MKAnnotationView

If you want to display only one type of annotation in your MKMapView, just register your custom class with the MKMapViewDefaultAnnotationViewReuseIdentifier reuse identifier

mapView.register(LocationAnnotationView.self, forAnnotationViewWithReuseIdentifier: MKMapViewDefaultAnnotationViewReuseIdentifier)

This is enough to make MKMapView to completely handle creating and recycling instances of your custom MKAnnotationViews for you. No need to implement any MKMapView delegate methods for providing annotation views.

[Read More]
iOS  Xcode  MapKit 

Logging error messages from assert and fatalerror

I often use fatalerror(message:) in my code base to deal with invalid states when the application cannot continue. A typical example can be a method that requires to be called only after the user has logged in:

guard let loggedUser = dataStore.user else {
	fatalerror("Invalid use before signup is complete")
}

The problem is that the fatalerror message does not appear in the crash log. You can of course take a look at the whole stack trace to figure out where the fatalerror originated but seeing the message in the logs yout get from your uses immediately would be much better.

I use PLCrashReporter to store crash logs locally so users can export them from the application together with all the logs.

I tried logging the message every time before calling fatalerror

guard let loggedUser = dataStore.user else {
	Log.error?.message("Invalid use before signup is complete")
	fatalerror("Invalid use before signup is complete")
}

but this is really not ideal, it is just writing boilerplate code you can easily forget.

I have not found a way to directly log the fatalerror message, so I created my own fail method

func fail(_ logMessage: String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
    let formattedMessage = formatLogMessage(logMessage, file: file, function: function, line: line)
    Log.error?.message(formattedMessage)
    fatalError(formattedMessage, file: file, line: line)
}

You can format the message you log any way you want, I just log the filename, function name and line number. Getting the filename from a StaticString is a bit tricky though

func formatLogMessage(_ logString: String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) -> String {
    let filename = (file.withUTF8Buffer {
        String(decoding: $0, as: UTF8.self)
    } as NSString).lastPathComponent
    return "[\(filename):\(line) \(function)]: \(logString)"
}

Instead of calling fatalerror(message:) I now call fail(message:) instead in all the places it is needed and the message is always logged.

As a downside if you have debugger attached it stops in the actual fatalerror call not on the fail method call, so you need to move one method up in the stack trace to see the actual place your application failed.

For me it is worth it, I am much more interested in the error messages in the logs than this.

In my code base I also define a failDebug(message:) method with the same code just replacing fatalerror(message:) with assertionFailure(message:).

[Read More]
iOS  Xcode