IOS Performance tips you probably didn’t know iOS Performance tips you probably didn’t know (from an ex-Apple Engineer)

As developers, good performance is invaluable to surprise and delight our users. IOS users have high standards, and if your app is slow to react or crashes under memory pressure, they will stop using it, or worse, your reviews will suck.

For the past six years, I have worked at Apple developing Cocoa frameworks and first-party applications. I’ve worked on Spotlight, iCloud, app extensions, and most recently Files.

I’ve noticed that there’s an easy target where you can get an 80% performance improvement in 20% of the time.

Here’s a list of performance tips that hopefully will give you the most bang for your buck:

1. The cost of UILabel is beyond your imagination

We tend to think of Lables as lightweight in terms of memory usage. In the end, they just display text. UILabel is actually stored as bitmaps, which can easily consume megabytes of memory.

Thankfully, UILabel’s implementation is clever and uses only what it needs:

  • If the label is monochrome, UILabel will choose kCAContentsFormatGray8Uint calayercontents format (1 byte) per pixel, rather than a single color label (for example, to show “🥳 is party time”, Or multicolor NSAttributedString) will need to use kCAContentsFormatRGBA8Uint (per pixel 4 bytes).

Monochrome labels consume up to width * height * contentsScale ^ 2 * (1 byte per pixel) bytes, while non-monochrome labels consume up to 4 times as much: Width * height * contentsScale ^ 2 * (4 bytes per pixel).

For example, on the iPhone 11 Pro Max, a maximum of 414 * 100 points of lable can be consumed:

  • 414 * 100 * 3 ^ 2 * 1 = 372.6KB
  • 414 * 100 * 3 ^ 2 * 4 = ~ 1.49MB

When these cells are queued for reuse, a common anti-pattern is to populate the text content with UITableView/UICollectionView Cell Labels. Once cells are recycled, the text value of the label is likely to be different, so storing them is wasteful.

To free up potential megabytes of memory:

  • If the text of the label is set to hidden, the text of the label is set to nil, showing them only occasionally.
  • If the text of the label is displayed in the UITableView/UICollectionView cell, set the text of the label to nil, in:
tableView(_:didEndDisplaying:forRowAt:)
collectionView(_:didEndDisplaying:forItemAt:)

Copy the code

2. Always start with the serial queue and only use the concurrent queue as the last choice

Such as:

A common anti-pattern is to allocate blocks that do not affect the UI from the main queue to a global concurrent queue.

func textDidChange(_ notification: Notification) {
    let text = myTextView.text
    myLabel.text = text
    DispatchQueue.global(qos: .utility).async {
        self.processText(text)
    }
}

Copy the code

If we suspend application:

When you dispatch_async a block to a concurrent queue, the GCD will try to find a free thread in its thread pool to run the block. If no idle thread is found, a new thread must be created for the work item. Rapid allocation of blocks to concurrent queues can result in the rapid creation of new threads.

Remember these:

  • Thread creation is not free. If the amount of work you commit is small (<1 millisecond), creating new threads can be wasteful in terms of switching execution context, CPU cycles, and dirty memory.
  • The GCD will happily continue to create threads for you, possibly causing them to explode.

In general, you should always start with a limited number of serial queues, each representing the child components of your application (database queues, text processing queues, and so on). For smaller objects with their own serial scheduling queue, use dispatch_set_target_queue to locate one of the subcomponent queues.

Use your own concurrency queue (not dispatch_get_global_queue) and consider dispatch_apply only when there is a bottleneck that can be resolved by additional concurrency.

Dispatch_get_global_queue

Concurrent queues obtained from dispatch_get_global_queue are not conducive to forwarding QoS information to the system and should be avoided.

For more detailed suggestions on libDispatch efficiency, check out this excellent collection.

3. It may not be as bad as it looks

So, you try to optimize memory usage as much as possible, but even then, after using the application for a while, memory usage is still high.

Don’t worry, some system components will only release memory if they receive a memory warning.

For example, UICollectionView reacts to -DidReceivememoryWarning (starting with iOS 13) to clear its reuse queue from memory if it is out of memory.

Analog memory warning:

  • In the iOS emulator, use the “Simulate Memory Warning” menu item.
  • On the test device, call the private API (do not submit to the App Store with this) :
[[UIApplication sharedApplication] performSelector:@selector(_performMemoryWarning)];

Copy the code

4. Do not wait for asynchronous operations using dispatch_semaphore_t

This is a common anti-pattern:

let sem = DispatchSemaphore(value: 0)
makeAsyncCall {
	sem.signal()
}
sem.wait()
Copy the code

The problem is that priority information is not propagated to other threads/processes where work initiated by makeAsyncCall will be completed, and priority inversion may result:

  • Suppose you call from the main queuemakeAsyncCallThe workload is dispatched to QoSQOS_CLASS_UTILITYIn the database queue.
  • Due to themakeAsyncCallFrom the main linedispatch_async, the QoS of the database queue will be increased toQOS_CLASS_USER_INITIATED.
  • Blocking the main queue with a semaphore means it is stuck waitingQOS_CLASS_USER_INITIATEDThe work running below the main queueQOS_CLASS_USER_INTERACTIVE), therefore priority inversion.

Notes with XPC:

If you are already using XPC (on macOS, or you are using NSFileProviderService) and want to make synchronous calls, avoid using semaphores and instead send messages to the synchronization broker as follows:

-[NSXPCConnection synchronousRemoteObjectProxyWithErrorHandler:].
Copy the code

Don’t use UIView Tags

This is bad practice and indicates code smell. This is also bad for performance.

I recently wrote code that, once you click on a view, changes the color of its subviews based on their label value.

UIKit implements labels using objc_get/setAssociatedObject(), which means that every time you set or get a label, you’re doing a dictionary lookup, which will be displayed in Instruments:

-[UIView Tag] consumes precious milliseconds processing touch events.

Articles and interesting discussions on Twitter

Articles and interesting discussions on Twitter, and I’ve picked a few here that might help

# # # # 1

Steven Fisher: I still haven’t found a good way to replace 4. I reduced my use of this pattern to the point that it was only used in my testing tools, but it still bothered me.

Xaxxus: PromiseKit is your answer.

Rony Fadel: Ask the API provider for the synchronization API, using the synchronization API is your best choice, it will ensure QoS propagation.

Daniel Pourhadi: What if the API provider is Apple and you have to wait for the AVAsset property to fill in? Is the semaphore in the background thread thread (as opposed to the main thread) harmful?

Rony Fadel: What are the benefits of semaphores on background threads? If you really think there is a benefit to using the synchronization API, submit an error report. This is harmful because every time you block waiting for a signal to work in the background, the system loses QoS propagation information. Then imagine that the main queue performs dispatch_sync on that background queue. Boost does not propagate all the way to the thread performing AVAsset work, so the main queue is affected.

2

Tyler: Very interesting, thank you. Repopulating cells – my understanding is that collection/table view entry into the reuse pool triggers on a boundary larger than the visible region – is an optimization to prevent reuse pool jitter. If we clear/load cell visibility, do we not do this optimization? I understand that your suggestion is to fix the memory problem, but what does that do to improve performance? Unfortunately, there doesn’t seem to be a way to know when a unit is actually back in the reuse pool.

Rony Fadel: Cells enters the reuse queue when it is not in the view (usually when scrolling). It has to do with memory (part of performance, at least the way we classify it on Apple), but not scrolling performance.

Tyler: I think what you’re describing is that returning the contents of the reuse pool on didDisappear is consistent with the behavior prior to iOS10. They described the added scroll performance optimization from the new feature in UICollectionView in iOS 10 records – “… Now the cell will exit the viewscope of the CollectionView. So, we’re going to send it the expected didEndDisplayingCell. When Peter was talking about iOS 9, the cell was queued for reuse, and we’ll do that. To display data in this particular cell again, we must go through the beginning of the lifecycle and call cellForItemAtIndexPath. But in iOS 10, we’re going to keep that cell for a little bit longer.” Note that I just remembered this because I was just working in this area, trying to figure out how to avoid running out of memory without doing this optimization. Thanks again for your post.

3

John Siracusa: What do you recommend instead of DispatchSemaphore when you are waiting for asynchronous off-main thread users to start work due to timeout?

Yaron Inger: You can use dispatch group and dispatch_group_wait.

Rafael Cerioli: Like Semaphores, there is no way to convert async to sync.

J Matusevich: The Dispatch Group is the answer.

NieR: Autoconf: Dispatch Group performs the same as Semaphore. The API is great but behaves The same.

Bob Godwin: DispatchWorkItem👍🏽 They handle those cases where I have to use Semafores. It is just that dispatchWorkItem is not yet widely known by developers.

Pkamb: DispatchGroup! Waiting for multiple blocks to finish