Hello! Welcome to our next lesson.
In the last two lessons, we've been deep in Instruments, profiling and fixing performance issues that lead to dropped frames. We first tackled GPU-bound problems like off-screen rendering, and then moved to CPU-bound issues caused by complex Auto Layout hierarchies and constraint churn.
Today, we address another of the most common and critical causes of poor scrolling performance: loading and displaying images from a network. A lazy approach can easily block the main thread, leading to the stutters and hitches we've been working to eliminate.
This lesson will teach you how to implement background image fetching, decoding, and caching to ensure smooth scrolling. We'll construct a robust, three-part strategy that offloads work from the main thread and avoids doing the same work twice, resulting in a responsive and professional user experience.
1. The "Hidden Cost" of Displaying an Image
You might think that once you have the Data for an image, the hard work is over. However, there's a crucial, often overlooked step: decoding. Compressed image formats like JPEG or PNG are not raw pixel data. Before an image can be rendered on screen, it must be decompressed into a bitmap, a process that can be surprisingly CPU-intensive for large images.
By default, UIKit performs this decoding "just-in-time" on the main thread when you assign a UIImage to a UIImageView. If this decoding takes longer than a few milliseconds, it will cause a hitch.
Let's see what this looks like in practice.
Inspect & optimize Image Decoding timing in iOS
To understand the solution, we must first deeply understand the problem. This article provides an excellent explanation of the image rendering pipeline and uses the Time Profiler to visually demonstrate how decoding can block the main thread.
Please read the first section, 'Images may be decoded in the Commit phase on the main thread'. Focus on the explanation of when decoding occurs and examine the Time Profiler screenshot. You'll see the main thread occupied by CA::Layer::prepare_commit, which is doing the image decoding work, causing a hang.
As the article demonstrates, this main thread blockage is the root cause of the problem we need to solve. Our entire strategy will be built around moving this work into the background.
2. A Three-Part Strategy for Performant Images
A robust image loading pipeline consists of three key components: Caching, Background Fetching & Decoding, and Proactive Prefetching.
Part 1: Caching with NSCache
Before we even think about fetching an image from the network, we should check if we already have it. Re-downloading and re-processing the same image is wasteful. For in-memory caching, NSCache is the ideal tool.
It behaves much like a Dictionary, but with two key advantages for our use case:
- Thread-Safety: You can read from and write to
NSCachefrom any thread without needing to manage locks yourself. This is perfect for our background-processing needs. - Automatic Eviction: It automatically removes objects when the system is under memory pressure, helping your app be a good citizen and avoid being terminated for using too much memory.
This video provides a great hands-on guide to implementing a simple image provider using NSCache.
NSCache: Performant iOS App Basics (2022)
This video from iOS Academy clearly explains what NSCache is and demonstrates how to integrate it into a class that provides images.
Please watch the following segments: What is NSCache? (00:50 - 01:39): Understand its core benefits over a standard dictionary. Integrating NSCache (04:41 - 08:15): Observe how to check the cache before fetching and how to add a downloaded image to the cache. Note the thread-safe nature of the operations. Cache Keys (12:56 - 13:31): Pay attention to the best practice of using a unique identifier, like the image URL, as the cache key.
Part 2: Background Decoding
Now we know how to cache, but what should we cache? Caching the raw Data is good, but it doesn't solve the main-thread decoding problem. A better approach is to perform the expensive decoding in the background and cache the ready-to-display UIImage object itself.
Since iOS 15, UIImage has APIs specifically for this.
Inspect & optimize Image Decoding timing in iOS
The same article that illustrated the problem also shows us the modern solution. Let's look at how to use UIKit's built-in APIs to prepare images for display on a background thread.
Please read the sections 'Move the heavy lifting things off the main thread in advance' and 'Using UIImage and UIImageReader to prepare thumbnail off the main thread'. Focus on how prepareForDisplay and, more importantly for lists, prepareThumbnail(of:completionHandler:) are used to get a decoded UIImage in a background completion handler.
The workflow now looks like this:
- On a background thread, after you receive
DatafromURLSession, create an initialUIImage. - Call
image.prepareThumbnail(of: targetSize) { ... }. This asynchronous method does two things:- Downsamples the image to a smaller size, which is crucial for memory and performance in lists. The
targetSizeshould be the size of yourUIImageView. - Decodes the image into a bitmap.
- Downsamples the image to a smaller size, which is crucial for memory and performance in lists. The
- In the completion handler of
prepareThumbnail, you now have a fully decoded, ready-to-display image. - Store this prepared
UIImagein yourNSCache. - Switch back to the main thread to set this prepared image on your
UIImageView. Because the image is already a decoded bitmap, the main thread has no work to do, and the update is instantaneous.
A key consideration, as mentioned in the WWDC21 resource "Make blazing fast lists and collection views", is that prepared images use significantly more memory than their compressed counterparts. This is a trade-off: you use more RAM to save CPU cycles on the main thread. NSCache is perfect for managing this trade-off due to its automatic eviction policy.
Part 3: Proactive Prefetching
We now have a complete pipeline for fetching, decoding, and caching an image. But when should we trigger it? If we wait until tableView(_:cellForRowAt:) is called, the cell will already be on screen, and the user will see a placeholder while the image loads.
The solution is to start loading images for cells before they appear. UITableView and UICollectionView provide a powerful mechanism for this: data source prefetching.
Prefetching with TableViews (2022) – iOS
This video clearly demonstrates the 'before' and 'after' of implementing prefetching. It's a powerful technique for creating a seamless scrolling experience.
Watch the video from 01:14 to 14:38. The first part (until 09:55) sets up the problem: a table view where images appear with a noticeable delay during scrolling. The second part (from 09:55 onwards) adopts the UITableViewDataSourcePrefetching protocol and implements the tableView(_:prefetchRowsAt:) method to start image downloads early. Observe the dramatic improvement in scrolling smoothness.
The Complete Workflow
By combining these three parts, we can build a highly performant image loading system. Here is the complete logic:
Setup:
- Conform your table view's data source to
UITableViewDataSourcePrefetchingand settableView.prefetchDataSource = self. - Create a shared
NSCache<NSURL, UIImage>instance to hold your prepared images.
In tableView(_:cellForRowAt:):
- Get the
URLfor the image at the givenindexPath. - Check the
NSCachefor a prepared image using the URL as the key. - If an image is found: Set it on the cell's
imageView. - If no image is found: Set a placeholder image. The prefetching mechanism will handle loading it.
In tableView(_:prefetchRowsAt:):
- Loop through the array of
indexPathsprovided by the delegate method. - For each
indexPath, get the corresponding imageURL. - Check your
NSCacheto see if an image for thisURLalready exists or if a download is already in progress. - If not, trigger your background loading pipeline:
- Use
URLSessionto fetch the data. - Use
prepareThumbnail(of:)to decode it into a bitmap. - Store the prepared
UIImageinNSCache. - Crucially: After caching, check if the cell for this
indexPathis now visible (tableView.indexPathsForVisibleRows?.contains(indexPath)). If it is, get the cell usingtableView.cellForRow(at:)and update itsimageViewon the main thread.
- Use
In tableView(_:cancelPrefetchingForRowsAt:):
- If the user scrolls quickly past a set of cells, this method will be called.
- Loop through the
indexPathsand cancel any ongoingURLSessionDataTasks associated with them to save network bandwidth and CPU time.
Conclusion
You now have a comprehensive, professional-grade strategy for handling images in scrolling views. By thoughtfully managing when and how you fetch, decode, and cache images, you can eliminate a major source of UI hitches and provide a fluid user experience.
Key Takeaways:
- Decoding is the hidden cost: The decompression of image data into a bitmap happens on the main thread by default and can cause stutters.
- Cache prepared images: Use
NSCacheto storeUIImageobjects that have already been decoded in the background viaprepareThumbnail(of:). - Prefetch proactively: Use
UITableViewDataSourcePrefetchingto initiate your fetch-decode-cache pipeline for cells before they become visible. - Cancel eagerly: Implement
cancelPrefetchingForRowsAtto conserve resources when the user scrolls away from items that are being prefetched.
Preview of the Next Lesson:
We've covered the concept of prefetching in detail using a UITableView. In the next lesson, we will apply this exact same pattern to UICollectionView by implementing UICollectionViewDataSourcePrefetching to proactively load data for upcoming cells, solidifying your understanding of this powerful performance optimization technique.