Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 37

Optimizing Image Loading for Smooth Scrolling

Recommended resources

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:

  1. Thread-Safety: You can read from and write to NSCache from any thread without needing to manage locks yourself. This is perfect for our background-processing needs.
  2. 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:

  1. On a background thread, after you receive Data from URLSession, create an initial UIImage.
  2. 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 targetSize should be the size of your UIImageView.
    • Decodes the image into a bitmap.
  3. In the completion handler of prepareThumbnail, you now have a fully decoded, ready-to-display image.
  4. Store this prepared UIImage in your NSCache.
  5. 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 UITableViewDataSourcePrefetching and set tableView.prefetchDataSource = self.
  • Create a shared NSCache<NSURL, UIImage> instance to hold your prepared images.

In tableView(_:cellForRowAt:):

  1. Get the URL for the image at the given indexPath.
  2. Check the NSCache for a prepared image using the URL as the key.
  3. If an image is found: Set it on the cell's imageView.
  4. If no image is found: Set a placeholder image. The prefetching mechanism will handle loading it.

In tableView(_:prefetchRowsAt:):

  1. Loop through the array of indexPaths provided by the delegate method.
  2. For each indexPath, get the corresponding image URL.
  3. Check your NSCache to see if an image for this URL already exists or if a download is already in progress.
  4. If not, trigger your background loading pipeline:
    • Use URLSession to fetch the data.
    • Use prepareThumbnail(of:) to decode it into a bitmap.
    • Store the prepared UIImage in NSCache.
    • Crucially: After caching, check if the cell for this indexPath is now visible (tableView.indexPathsForVisibleRows?.contains(indexPath)). If it is, get the cell using tableView.cellForRow(at:) and update its imageView on the main thread.

In tableView(_:cancelPrefetchingForRowsAt:):

  1. If the user scrolls quickly past a set of cells, this method will be called.
  2. Loop through the indexPaths and cancel any ongoing URLSessionDataTasks 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 NSCache to store UIImage objects that have already been decoded in the background via prepareThumbnail(of:).
  • Prefetch proactively: Use UITableViewDataSourcePrefetching to initiate your fetch-decode-cache pipeline for cells before they become visible.
  • Cancel eagerly: Implement cancelPrefetchingForRowsAt to 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.