Loading Images on iOS 15
Images are used ubiquitously in apps — whether as icon indictors for buttons, in listing views as part of each item in a list, or to render a page of a document like what’s done by the PSPDFKit PDF SDK for iOS. Some of these images already exist in the local storage and are loaded directly from the disk, while some use cases require loading an image from a remote URL.
Images hold a significant amount of information, and decoding large images can often lead to performance issues, scrolling stutters, or problems with animation. In this blog post, we’ll discuss the APIs provided by UIKit for decoding images asynchronously. We’ll also cover the available APIs for loading remote images with SwiftUI. These APIs have been available since iOS 15.
Decoding Images Asynchronously
When it comes to displaying images in a UICollectionView
or a UITableView
, most of us have observed animation hitches while scrolling items with images in a list.
There could be numerous reasons behind this, such as an extravagant setup of views, animations, etc. However, more often than not, it’s the images themselves and the way they’re displayed — and not the other components of the view — that are behind these issues.
This is because an image needs to be decoded before it’s displayed onscreen. The process of decoding an image uses a sizable amount of memory, as it involves turning compressed data from a backing file to bitmap data for the screen.
Decoding images is a process that occurs whenever an image is loaded in the form of a UIImage
and assigned to a UIImageView.image
. This process occurs on the main thread, since UIKit APIs should be exclusively manipulated on the main thread itself.
The way image decoding by UIImage
works is that, by default, it’ll lazily decode an image the first time the image is displayed onscreen.
The problem that arises is that the decoding of the image also occurs on the main thread, potentially while scrolling, thereby blocking it from context switching. As a result, this leads to the animation hitches in question, which goes to show how achieving optimum performance and minimum memory consumption while dealing with images can be tricky.
Traditionally, as a workaround for avoiding the overhead of decoding, one would perform decoding on demand by manually drawing an image in the background, with that image being discarded afterward.
Alternatively, Image I/O, which is a set of APIs for reading and writing different image formats, allows you to decode images on demand. However, these are lower-level APIs, and they require manual memory management of Image I/O’s resources.
The good news is that, since iOS 15, there have been a couple of new UIImage
APIs that allow us to offload the image decoding process to a background thread, in turn freeing up the main thread to continue with its tasks.
On-Demand Decoding
The first API is preparingForDisplay()
, which is a synchronous call on the UIImage
instance that returns a decoded image that can be displayed by a UIImageView
.
The interesting bit here is that this call can be made on a background thread. Once the image is decoded and returned, it can be set to the relevant UIImageView.image
. This needs to take place on the main thread.
However, Apple recommends using a serial queue for queueing the decoding tasks rather than spawning multiple concurrent queues, as requesting the system for multiple queues can degrade system performance.
Consider using this API when, for example, configuring your UICollectionViewCell
/UITableViewCell
subclass instance for being displayed in the corresponding list views:
// Create your serial queue for lining up the decoding tasks. let serialQueue = ... // In the view configuration method: // Access the corresponding `UICollectionViewCell` / `UITableViewCell`. let itemCell = ... let image: UIImage = getImageForItem(item) serialQueue.async { // Call `preparingForDisplay`, which runs synchronously. let decodedImage = image.preparingForDisplay()! DispatchQueue.main.async { // Fetch the relevant cell here instead of capturing it to ensure that the fetched cell belongs to the correct index path. let itemCell = ... // Set the decoded image as the image for the image view of the cell now that you have the decoded image. itemCell.imageView.image = decodedImage } }
The above API returns an optional (UIImage?
); that’s because a UIImage
created using a CIImage
can’t be decoded and presented in an efficient manner by the UIImageView
.
Using this API means you have to manually take care of preparing new images. This includes the images from the asset catalog having multiple variants for dark/light appearance and updating them whenever the environment traits change. In other words, you’d update an image by observing the trait collection changes in traitCollectionDidChange(_:)
.
The second API is an asynchronous version of the above API — prepareForDisplay(completionHandler:)
. This API works in exactly the same manner and has the same attributes as its synchronous counterpart. It takes a completion handler, which has one parameter, and that’s the prepared image. This image is also an optional.
Using this API eliminates the need to create and manage your own serial queue:
// In the view configuration method: // Access the corresponding `UICollectionViewCell` / `UITableViewCell`. let itemCell = ... let image: UIImage = getImageForItem(item) image.prepareForDisplay { decodedImage in // Fetch the relevant cell here instead of capturing it to ensure that the fetched cell belongs to the correct index path. let itemCell = ... // Set the decoded image as the image for the image view of the cell now that you have the decoded image. itemCell?.imageView.image = decodedImage }
The asynchronous counterpart of the API can also be called using the async/await
paradigm, like so:
// In the view configuration method: // Access the corresponding `UICollectionViewCell` / `UITableViewCell`. let itemCell = ... let image: UIImage = getImageForItem(item) async { // Start preparing the image the asynchronously. let decodedImage = await image.byPreparingForDisplay() // Set the decoded image to the cell. itemCell.imageView.image = decodedImage }
iOS 15 introduces similar APIs for loading image thumbnails that work almost in the same way: Their synchronous/asynchronous counterparts allow you to specify the size of the image to be returned. prepareThumbnail(of:completionHandler:)
is the asynchronous API, and preparingThumbnail(of:)
is its synchronous counterpart.
The asynchronous version of the API also supports async/await
in the form of byPreparingThumbnail(ofSize:)
. This API is a good candidate for displaying images in lists, as, in many instances, we only want to display a small image in cells so that loading a thumbnail of a large image reduces the app’s memory use significantly.
Loading Remote Images
We’ve always wanted a convenient way to load remote images into our apps without having to worry about the overhead of managing the network calls, things being asynchronous, and the complexity that comes with working with UIKit. This is one of the many cases where SwiftUI manages to get the job done much more gracefully.
Before I go into the details of this, first the bad news: There’s no UIKit counterpart for what we’re about to dive into.
SwiftUI supports loading images from a remote URL using the AsyncImage
view. AsyncImage
also allows you to specify a placeholder while it’s loading an image from a remote URL. If it isn’t specified, it falls back to using the default placeholder.
AsyncImage
also allows applying image modifiers. However, to do that, the Image
returned in the content
closure that takes an image needs to be used. Additionally, the modifier needs to be applied to the image instance:
// In your custom view hierarchy. ... // Add the `AsyncImage` view. AsyncImage(url: URL(string: "your-image-url-here")) { image in image.resizable() } placeholder: { ProgressView() } .frame(width: 200, height: 200) ...
AsyncImage
allows you to update the view based on its loading state — empty, success, or a failure.
This can be done like so:
// Add the `AsyncImage` view. AsyncImage(url: URL(string: "your-image-url-here")) { phase in if let image = phase.image { // Check if the image is available and then display the image after adding insets. image.resizable(capInsets: .init(top: 4, leading: 4, bottom: 4, trailing: 4), resizingMode: SwiftUI.Image.ResizingMode.stretch) } else if phase.error != nil { // Display your custom error view. MyErrorImageView() } else { // Show the placeholder because the image hasn't been loaded yet. MyPlaceholderView() } }
As we can see above, displaying an image from a remote URL using AsyncImage
requires only a few lines of code — most of which sets up the view for the image, thereby eliminating the need for boilerplate code to create and manage network requests for downloading images. This ensures AsyncImage
feels right at home with the declarative nature of the SwiftUI framework.
Conclusion
In this blog post, we discussed the new APIs available in iOS 15 for loading images from the disk asynchronously and loading an image from a remote URL. They’re easy to use, and I’d suggest evaluating your codebase, as these APIs address some of the pain points we’ve had for some time, and they may do the same for you.
The asynchronous image loading API can bring down the animation hitches and improve perceived performance. AsyncImage
may be understated, but it’s a powerful tool if you’re using SwiftUI, and it makes image loading a breeze.