Architecting Asynchronous Image Download and Caching in Swift

Few months ago, I worked on a side-project that involved writing image downloader and cacher from scratch without using a third-party library. Today, in this blog post I am going to summarize how I built this framework from scratch with details technical explanation.

Before I started working on the project, I made a list of things I wanted to achieve through this framework.

  1. Given the image URL, download the image from that URL
  2. Download should happen in the background, and thus should not block the main thread
  3. It may simultaneously be used by different parts of app
  4. It should be thread safe
  5. Should be configurable from outside
  6. It should cache images

  1. Building the Singleton instance for ImageDownloader

We will begin by creating a class named ImageDownloader which will act as an object which will be used for downloading images. We will create its singleton instance and let other parts of app to use the same instance instead of creating new copy for every part which wants to perform image download operation.

Let's start with that step first - Create a class, private initializer and provide a singleton instance,

// Image downloader utility class. We are going to use the singleton instance to be able to download required images and store them into in-memory cache.

final class ImageDownloader {

    static let shared = ImageDownloader()
    
    // MARK: Private init
    private init() {
        // No op
    }
}

Every time any part of app wants to take advantage of ImageDownloader utility, they will simply access it through ImageDownloader.shared instance.

2. Defining interfaces for image download operation

Next step we will do is to define interface which will allow our client to execute the interface by passing following potential input values.

  1. imageURL - The URL of remote image path
  2. completionHandler - The completion handler with two parameters - First, the downloaded UIImage instance. Second, whether the returned image was cached or not
  3. placeholderImage - Alternatively user can also provide the placeholder image which will be used in case there is any kind of error during image download operation
 /**
Downloads and returns images through the completion closure to the caller

 - Parameter imageUrlString: The remote URL to download images from
 - Parameter completionHandler: A completion handler which returns two parameters. First one is an image which may or may
 not be cached and second one is a bool to indicate whether we returned the cached version or not
 - Parameter placeholderImage: Placeholder image to display as we're downloading them from the server
 */
 
func downloadImage(with imageUrlString: String?,
                   completionHandler: @escaping (UIImage?, Bool) -> Void,
                   placeholderImage: UIImage?) {
                       
                       // No op
                       
}

3. Implementing downloadImage interface

In the previous step, we defined an interface. Now we will start adding actual implementation details to it. However, before directly jumping to perform download operation, we are going to do extra checks as follows.

The list of checks and corresponding results is listed below

A. Return the result through completion handler with placeholder image as an output image, indicating this is a cached image

   a. If input imageUrlString is nil

   b. Or for some reason we cannot construct the URL object through input string value

   c. Or the task was completed with non-nil error object in the completion handler

B. Return the result through completion handler with cached image as an output image, indicating this is a cached image

   a. If we found that image was already cached

C. Return the control back to the calling function

   a. If before request executes, if we find that the task to download image from given URL already exists and is in progress

D. If all the preceding conditions fail, we will continue and create a task to download image by passing the input URL string and waiting for that download to finish.

4. Defining Containers

As we saw in the list of requirements in point #3, we will need two containers,

  1. First, to hold the list of downloaded images - This will essentially be the dictionary structure with image url string as a key and downloaded image object in the form of UIImage as a value
  2. Second, to hold the list of ongoing download tasks - Every time our imageDownload interface is called, we will check if the corresponding task already exists. If it does, we won't create a duplicate task. At the end of each task we will remove it from the container. This will also be the dictionary structure with image url string as a key and task in the form of URLSessionDataTask as a value

With these updated requirements, let's slightly modify our class,

final class ImageDownloader {

    static let shared = ImageDownloader()

    private var cachedImages: [String: UIImage]
    private var imagesDownloadTasks: [String: URLSessionDataTask]

    // MARK: Private init
    private init() {
        cachedImages = [:]
        imagesDownloadTasks = [:]
    }

}

5. Finishing the implementation part

Now that we're equipped with the theoretical knowledge of this library, it's time for us to continue with implementing downloadImage method.

func downloadImage(with imageUrlString: String?,
                   completionHandler: @escaping (UIImage?, Bool) -> Void,
                   placeholderImage: UIImage?) {

    // Case A.a
    guard let imageUrlString = imageUrlString else {
        completionHandler(placeholderImage, true)
        return
    }

    // Case B.a
    if let image = getCachedImageFrom(urlString: imageUrlString) {
        completionHandler(image, true)
    } else {
        // Case A.b
        guard let url = URL(string: imageUrlString) else {
            completionHandler(placeholderImage, true)
            return
        }

        // Case C.a
        if let _ = getDataTaskFrom(urlString: imageUrlString) {
            return
        }

        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in

            guard let data = data else {
                return
            }

            // Case A.c
            if let _ = error {
                DispatchQueue.main.async {
                    completionHandler(placeholderImage, true)
                }
                return
            }

            let image = UIImage(data: data)
            
            self.cachedImages[imageUrlString] = image

            self.imagesDownloadTasks.removeValue(forKey: imageUrlString)

            DispatchQueue.main.async {
                // Case D
                completionHandler(image, false)
            }
        }
        
        imagesDownloadTasks[imageUrlString] = task

        task.resume()
    }
}


// MARK: Helper methods
private func getCachedImageFrom(urlString: String) -> UIImage? {
    return cachedImages[urlString]
}

private func getDataTaskFrom(urlString: String) -> URLSessionTask? {
    return imagesDownloadTasks[urlString]
}
Please note how once the task returns in the completion handler, we explicitly return the completionHandler on main thread. This is because data task completion closure is returned on the background thread. However, since we're returning an image object which may potentially be applied to any UIKit elements such as UIImageView, completion handler must be executed on the main thread.

Please also note additional helper methods for getting cached images and data tasks from the dictionary. Here we assume that input URL string acts as the  key and we utilize it to uniquely identify cached image or ongoing image downloader tasks.

6. Adding thread-safety

If you use this library to download a single image, it will work perfectly fine. However, this is not always going to be the case. There may be cases when you want to download and display multiple images on UITableView and user may be scrolling really fast through them.

Having a singleton instance may have looked good in the beginning considering how much convenience it offers. But when we think about simultaneous access, this could be problem since Swift dictionary and arrays are not thread-safe.

While using ImageDownloader with UITableView or UICollectionView, there may be simultaneous image download operations going on as user rapidly scrolls through the screen. Since download task executes in the background in asynchronous manner, there is quite a possibility that multiple threads may simultaneously access cachedImages and imagesDownloadTasks containers. This will result in application crash.

To avoid these kind of scenarios, we will make use of two serial queues to be able to read and write the non-thread-safe dictionaries. One for image container, and other for task container.

Every time we want to read or write from these dictionaries, we will add a sync barrier around these operations which will essentially act as a binary semaphore so that only one thread could access them at a time.

With thread-safety on, we can refactor our code as follows,

final class ImageDownloader {

    .....
    .....
    
    // A serial queue to be able to write the non-thread-safe dictionary
    let serialQueueForImages = DispatchQueue(label: "images.queue", attributes: .concurrent)
    let serialQueueForDataTasks = DispatchQueue(label: "dataTasks.queue", attributes: .concurrent)
    
    ....


    func downloadImage(with imageUrlString: String?,
                   completionHandler: @escaping (UIImage?, Bool) -> Void,
                   placeholderImage: UIImage?) {

    guard let imageUrlString = imageUrlString else {
        completionHandler(placeholderImage, true)
        return
    }

    if let image = getCachedImageFrom(urlString: imageUrlString) {
        completionHandler(image, true)
    } else {
        guard let url = URL(string: imageUrlString) else {
            completionHandler(placeholderImage, true)
            return
        }

        if let _ = getDataTaskFrom(urlString: imageUrlString) {
            return
        }

        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in

            guard let data = data else {
                return
            }

            if let _ = error {
                DispatchQueue.main.async {
                    completionHandler(placeholderImage, true)
                }
                return
            }

            let image = UIImage(data: data)
         
            // Store the downloaded image in cache
            self.serialQueueForImages.sync(flags: .barrier) {
                self.cachedImages[imageUrlString] = image
            }

            // Clear out the finished task from download tasks container
            _ = self.serialQueueForDataTasks.sync(flags: .barrier) {
                self.imagesDownloadTasks.removeValue(forKey: imageUrlString)
            }

            // Always execute completion handler explicitly on main thread
            DispatchQueue.main.async {
                completionHandler(image, false)
            }
        }
        
        // We want to control the access to no-thread-safe dictionary in case it's being accessed by multiple threads at once
        self.serialQueueForDataTasks.sync(flags: .barrier) {
            imagesDownloadTasks[imageUrlString] = task
        }

        task.resume()
    }
    

    private func getCachedImageFrom(urlString: String) -> UIImage? {
        // Reading from the dictionary should happen in the thread-safe manner.
        serialQueueForImages.sync {
            return cachedImages[urlString]
        }
    }

    private func getDataTaskFrom(urlString: String) -> URLSessionTask? {

        // Reading from the dictionary should happen in the thread-safe manner.
        serialQueueForDataTasks.sync {
            return imagesDownloadTasks[urlString]
        }
    }
}

7. Using the framework for image download and caching operation

In the next and final step we are going to see how to use this framework to perform any arbitrary image download operation.

We can directly access the shared ImageDownloader instance through singleton with ImageDownloader.shared. Downloader also defines a clear interface which takes image URL and (optional) placeholder image as input parameters and returns the result through completion handler closure.

ImageDownloader.shared.downloadImage(with: "www.foo.com/profile_pic.png", completionHandler: { (image, cached) in

    profileImageView.image = image    

}, placeholderImage: UIImage(named: "placeholder_profile_pic"))

The full source code of this project can be viewed on this gist,

Limitations and future improvements

  1. Library only supports downloading png/jpeg images. As a future extension, we can also have this library support gif and webp image formats
  2. Only provides an in-memory cache. No matter how many images are cached, once the app terminates, the cache will get cleared out and next time we need to re-download those images. In the future, we can also provide support for disk based caching letting users choose between these two modes based on the use-case
  3. Currently, there is no cache refresh. When images are downloaded, they are cached permanently. They're neither updated nor removed in case of cache limit is reached. We can also make this library bit more intelligent and let it decide where it makes sense to update the cache or purge least used cached image by implementing suitable cache policy

This post explain the basic usage of downloader. However, off-the-shelf ImageDownloader cannot be used directly UITableViewCells. There is an additional code involved to make it work with UITableViewCells or UICollectionViewCells.

We will explore the nuances of using it with reusable cells in the next blog post.