iOS Image Caching for UIKit Apps

The most common approach to displaying images in an iOS application is to include images in asset catalogs compiled into and shipped with the app.  For images that are provided at design time and never change, this is nearly always the best approach.

There are some scenarios where bundling images isn't possible or practical, and an approach to fetch and cache remote images using NSURLSession is needed. These scenarios may include:

  1. Images that are expected change over time.
  2. Images may be dynamic content served by content management systems.
  3. Images may be very large and/or infrequently used, so fetching them at runtime may make more sense than shipping them with the app's bundle.

In this article I'll cover a simple and straightforward approach to fetching images from remote web servers and efficiently caching them so they aren't retrieved more frequently than necessary.  The scenario in this approach is to display remote images in a UITableView's cells. Since UITableView cells will be destroyed and recreated frequently, fetching remote images with every layout is inefficient and results in a "janky" user experience.

Solution Approach

The example code in this tutorial leverages NSCache within a UIImageView extension. This provide a simple in-memory cache solution for remote images that's highly intuitive and simple to use within UI code.

NSCache object provided as part of the standard Apple runtime class libraries, and this approach is straightforward, doesn't require much code, and doesn't add  3rd-party dependencies to your codebase.  There are third party libraries available that implement this basic approach and add additional features as well. I'll mention a couple of them briefly at the end of this article. These libraries expand on the fundamental designs in this article.

The fundamental approach is straightforward:

  1. Fetch an image the first time it's requested by the UI, and add it to a memory cache.
  2. On subsequent requests for the same image, return the cached object, avoiding the additional network round-trip.
  3. While the user waits for the remote image, display a placeholder so they know something is in process.
  4. If we can't fetch the specified remote image for some reason, display an error image instead.

Step one: create the caching extension for UIImageView

In the demo solution source code, I first created the source file ImageCache.swift. At the top of this file, I created a global imageCache global variable to serve as the cache. Note this is a simple global variable. For a more complex solution, this could be wrapped in a Singleton class that adds additional features, but in this solution the cache is just a simple global object, which keeps the code easier to understand.

var imageCache = NSCache<NSString, UIImage>()

Within the same source file, I created an extension on UIImageView, with a loadImage function.  This function is given the following three parameters:

  • urlString - the remote url for the image
  • placeholderImage - a UIImage to display when a remote server fetch of the image at the URL is in process
  • errorImage - an image to display if the image cannot be fetched from the remote server.

placeholderImage and errorImage are stored in the app's asset catalog – since they need to be guaranteed to be available (even offline).  If the image at urlString is received successfully, it is placed in the UIImageView.  If there's an error retrieving the image, then errorImage is placed instead.  While waiting for the image at urlString, the user will see placeholderImage. Note that the placeholder and error images are optional. If omitted, the extension makes no changes to the UIImageView if and only if the image fetch succeeds.

The code for the extension is below.

    extension UIImageView {
        func loadImage(urlString: String, 
                       placeholderImage: UIImage?, 
                       errorImage: UIImage?) {
            
            // 1
            if let cachedImage = imageCache.object(forKey: urlString as NSString) {
                image = cachedImage
                return
            }
    
            // 2
            guard let url = URL(string: urlString) else {
                return
            }
            
            // 3
            if let placeholder = placeholderImage {
                DispatchQueue.main.async {
                    self.image = placeholder
                }
            }
            
            self.fetchImageFromNetwork(url: url, 
                                       placeholderImage: placeholderImage, 
                                       errorImage: errorImage)
        }
        
        private func fetchImageFromNetwork(url: URL, 
                                           placeholderImage: UIImage?, 
                                           errorImage: UIImage?) {
    
            // 4
            URLSession.shared.dataTask(with: url) { [weak self] (data, response, error ) in
                
                // 5
                if let err = error {
                    if let errImage = errorImage {
                        DispatchQueue.main.async {
                            self?.image = errImage
                        }
                    }
                    return
                }
                
                // 6
                let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
                if !(200...299).contains(statusCode) {
                    if let errImage = errorImage {
                        DispatchQueue.main.async {
                            self?.image = errImage
                        }
                    }
                    return
                }
                
                // 7
                if let self = self, let data = data, let image = UIImage(data: data) {
                    imageCache.setObject(image, forKey: url.absoluteString as NSString)
                    DispatchQueue.main.async {
                        self.image = image
                    }
                }
            }.resume()
        }
    }

To summarize how the extension works:

  1. At position (1), the memory cache is checked for the requested url. If found (i.e. the image has already been fetched during the current app's run), it's returned immediately. Each step below is processed if the image isn't already in the cache.
  2. The url passed is a String, and needs to be validated as a workable URL object. If a URL object can't be made with the given url string, the routine returns immediately.
  3. If a placeholder image was provided, it's set to the UIImageView, so the user has some indication that there is more content coming.
  4. A URLSession task is created to fetch the remote image.  the .resume() call starts the fetch.
  5. If the URLSession fetch fails with an NSError, the errorImage is set to the UIImageView content.
  6. If the server http response code doesn't fall within the expected success range of 200...299, the errorImage is displayed. This probably indicates a 404/not found, 401/unauthorized, etc.
  7. If everything completes as expected, the fetched image is saved to the cache using the setObject method, and then set to the UIImageView content using the image = property assignment.

Step Two: Calling loadImage(...) from the UI View Controller

Because the remote fetch and load is implemented as an extension, any UIIMageView instance can call it as a method.  The code snippet below is extracted from the sample application's UITableViewController class MainTableViewController.swift.   Click on that source file link to view the full source for the controller where the extension is used.

    // imageView is an object of type UIImageView. 
    imageView.loadImage(urlString: obj.imageUrl,
                        placeholderImage: obj.placeholderImage,
                        errorImage: obj.errorImage)

In the sample application, the snippet above is called each time a UITableViewCell is laid out. Caching makes a lot of sense in a TableView, since images will be fetched Cells laid out frequently. We wouldn't want to fetch an image from a remote server dozens of times as users scroll through the TableView.  The cache avoids this by keeping cached versions on hand for subsequent layout requests.

Here's how the final application looks.  Note the grey hourglass image while images are fetched--and the red error image in the 3rd row. The url specified for the third row is invalid (a 404), so after each fetch fails, that row will change from the gray to red image.  The other three cells display the remote image after it's received.

Project Source Code

The full project implementing this technique is available via GitHub here. This approach could be extended to other types of containers or as a singleton to cache other types of content--feel free to use as a base for your own solutions!

Third-Party Libraries

If your needs are straightforward and/or if you'd rather avoid adding third-party libraries to your codebase, this example code may cover your needs completely. That said, there are are 3rd-party, open-source libraries that provide additional features and design scenarios. Examples to look at:

  • SDWebImage provides many interesting features, such as disk-based caches and content types in addition to images.
  • AlamoFire is a comprehensive networking library, and includes image fetch and cache features as a part of its overall feature set. If you're already using AlamoFire instead of NSURLSession, then for sure look into its caching capabilities.