One of the first lessons we learn in iOS development is that UIKit classes (UILabel, UIImageView, …etc) shouldn’t be touched outside the main thread. Sometimes we learn it the hard way. However, this doesn’t mean we cannot do any form of rendering off the main thread.

Classes like NSAttributedString and UIImage come with methods for drawing to a given graphics context; an image context for our use case. This doesn’t mandate being done in a particular thread. Not only this, but UIKit enables us to export what’s drawn to the current context to a bitmap image using UIGraphicsGetImageFromCurrentImageContext. This means we can do any complex drawing like we do with CoreGraphics in drawRect:, and then export this to a bitmap.

All what we have to do next is to display the resulting image in our view. This is easily achieved by setting the views layer.contents property to a CGImage representation of our image. And that’s it.

Cool, but why?

UIKit performance is great 99% of the time. However, it’s not the best we can acheive. UIKit performance degrades noticebly when rendering large scrolling amounts of text and images with varying sizes, in addition to relying on AutoLayout for sizing. AutoLayout came along way in iOS 12, but earlier iOS versions are still in support, and no matter how fast AutoLayout becomes, it still works on the main thread.

AutoLayout is not only the slowing factor. Actual rendering and intrinsic content size calculation also happens on the main thread. I’ve profiled stuttering scrolling performances and the culprit was none other than regular text drawing invoked from UILabel’s drawing.

Rendering and Sizing

If you notice, we’re dealing with two types of problems: (1) Rendering, i.e. the graphical content we see, and (2) Sizing, i.e. what space our rendered content will consume.

We talked about rendering methods above. If you notice, those rendering methods rely on a CGRect input, that is the bounding box of the graphical content. So, this implies a prior sizing step. Sizing images is usually easy; as we know beforehand where it would appear, and at what size. Text may be a bit trickier; as we usually fix a dimension (width or height) then let the text flow with respect to the desired alignment, consuming space depending on the font and other text attributes. Fortunately, there are more than one way to calculate bounding rectangles for attributed strings. The simplest method is NSAttributedString’s boundingRect. Other ways involve utilizing NSLayoutManager, NSTextContainer, and NSTextStorage trio for advanced text layout.

A Simple Demo

I made a very simplistic demo that showcases the gains in a scrolling use case. Notice the regular implementation (left) stutters on fast scrolling, while the prerendered implementation (right) scrolls like the wind. One cost is to engineer when to pre-load the pre-rendered conent. I went with an inefficient way for the sake of simplicity (i.e. loading all beforehand). This is a real cost for such approach.

Where to go from here?

I’m just exploring this technique myself. It’s not something new. There are already amazing libraries which adopt this approach; namely Ryan Nystrom’s StyledTextKit, and Texture (AsyncDisplayKit).

Notes

  • When using UIGraphicsBeginImageContextWithOptions, take some notes:
    • The third parameter is the scale at which the bitmap is generated. A zero values picks the device’s scale (i.e. 2x, 3x). This helpful in emulating vector drawing behavior in zoomable views by redrawing content at a higher scale (e.g. device scale * zoom scale).
    • Don’t forget to call UIGraphicsEndImageContext after done dealing with the image context. This is to clean up memory. This approach can use memory extensively, it’s a really important step.
    • The second parameter is whether to draw opaquely or transparent. If you know that your view is going to be not transparent, it’s good to set this to true, as transparency is a computationally expensive task. See this WWDC session at 29:28 mark.
  • Use a serial DispatchQueue instead of a gloabl queue if you’re going to render multiple views in series, or group them in a single block to be executed in a global queue. Anyways don’t execute each block individually in a gobal queue. This to avoid creating more threads than CPU can handle; what’s called “Thread Explosion”. See this WWDC talk at 16:42 mark.

  • When using NSAttributedString’s boundingRect:
    • You have to use an NSAttributedString instance with at least .font and .foregroundColor attributes set, or else it won’t give correct results.
    • The first CGSize parameter it takes marks what constrained dimension it expects, and what other dimension to compute. For example, to emulate the behaviour of a UILabel with numberOfLines set to 0, you provide a fixed width, and a height value of .greatestFiniteMagnitude. Example.

Update (06-09-2019)

This made its way to a talk at a SwiftCairo meet-up. You can find the slides and a sample code here.