Reusable Image Cache in Swift


October 24, 2019

#swift #ui #combine

Almost every application contains some kind of graphics. That is the reason why downloading and displaying images in a mobile application is one of the most common tasks for app developers. Eventually, it could be a source of unnecessary work when application reloads the same images multiple times.

In this article, I’ll show how to improve it by creating an Image Cache and integrate it with Image Loader using Combine framework.

Using NSCache as a storage

While building a caching mechanism in iOS project most of the times you’d consider using NSCache class. There are quite some pros of this class such as it being thread-safe and removing items from cache when memory is needed by other applications, but also some cons about it like having unclear eviction process.

In any case it is a better option for caching comparing to collection classes from the Swift standard library or Foundation framework. In this article we’ll be using NSCache as internal image cache storage. As an alternative, you can replace it with any other solution that follows one of the cache replacement policies.

Image rendering pipeline

If your app downloads images from the web, a common challenge is application responsiveness and performance. For example you might have a stutter while scrolling a table view with images. The issue is that image rendering doesn’t happen at once when assigning one to be displayed by an image view. The image rendering pipeline consists of several steps:

  • loading - loads compressed image into memory;
  • decoding - converts encoded image data into per pixel image information;
  • rendering - copies and scales the image data from the image buffer into the frame buffer.

It can add up to a significant amount of work on the main thread, making your app unresponsive. You can probably think of some potential improvements like decoding & rendering an image before one is assigned to the UIImageView.

This function consumes a regular UIImage and returns a decompressed and rendered version. It makes sense to have a cache of decompressed images. This should improve drawing performance, but with the cost of extra storage.

extension UIImage {

    func decodedImage() -> UIImage {
        guard let cgImage = cgImage else { return self }
        let size = CGSize(width: cgImage.width, height: cgImage.height)
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let context = CGContext(data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: cgImage.bytesPerRow, space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue)
        context?.draw(cgImage, in: CGRect(origin: .zero, size: size))
        guard let decodedImage = context?.makeImage() else { return self }
        return UIImage(cgImage: decodedImage)
    }
}

If you’d like to dive deeply into this topic I’d suggest watching the WWDC talk iOS Memory Deep Dive and Image and Graphics Best Practices.

In-memory Image Cache

It might be a good idea to start with defining the Image Cache requirements. The cache should implement CRUD functions (create, read, update, and delete). It would be nice to have a subscript and make our code more readable. Most of the times we’ll be caching an image loaded from the network, that’s why it makes perfect sense using URL as a key. Eventually we can declare the ImageCacheType as:

// Declares in-memory image cache
protocol ImageCacheType: class {
    // Returns the image associated with a given url
    func image(for url: URL) -> UIImage?
    // Inserts the image of the specified url in the cache
    func insertImage(_ image: UIImage?, for url: URL)
    // Removes the image of the specified url in the cache
    func removeImage(for url: URL)
    // Removes all images from the cache
    func removeAllImages()
    // Accesses the value associated with the given key for reading and writing
    subscript(_ url: URL) -> UIImage? { get set }
}

Image Cache implementation

Taking all of this into account we can declare the ImageCache class. Internally it has two NSCache fields to store compressed images and decompressed ones. We limit the cache size with the maximum number of objects and the total cost, such as the size in bytes of all images. The NSLock instance is used to provide mutually exclusive access and make the cache thread-safe.

final class ImageCache {

    // 1st level cache, that contains encoded images
    private lazy var imageCache: NSCache<AnyObject, AnyObject> = {
        let cache = NSCache<AnyObject, AnyObject>()
        cache.countLimit = config.countLimit
        return cache
    }()
    // 2nd level cache, that contains decoded images
    private lazy var decodedImageCache: NSCache<AnyObject, AnyObject> = {
        let cache = NSCache<AnyObject, AnyObject>()
        cache.totalCostLimit = config.memoryLimit
        return cache
    }()
    private let lock = NSLock()
    private let config: Config

    struct Config {
        let countLimit: Int
        let memoryLimit: Int

        static let defaultConfig = Config(countLimit: 100, memoryLimit: 1024 * 1024 * 100) // 100 MB
    }

    init(config: Config = Config.defaultConfig) {
        self.config = config
    }
}

Now we should implement several functions to satisfy the ImageCacheType requirements defined above. Here is the way we can insert and remove images from cache:

extension ImageCache: ImageCacheType {
    func insertImage(_ image: UIImage?, for url: URL) {
        guard let image = image else { return removeImage(for: url) }
        let decodedImage = image.decodedImage()

        lock.lock(); defer { lock.unlock() }
        imageCache.setObject(decodedImage, forKey: url as AnyObject)
        decodedImageCache.setObject(image as AnyObject, forKey: url as AnyObject, cost: decodedImage.diskSize)
    }

    func removeImage(for url: URL) {
        lock.lock(); defer { lock.unlock() }
        imageCache.removeObject(forKey: url as AnyObject)
        decodedImageCache.removeObject(forKey: url as AnyObject)
    }
}

You might notice that we are setting the cost for the decoded image. Right, the decodedImageCache is configured with totalCostLimit. It should remove some elements when the total cost exceeds the maximum allowed one.

To get an image from cache first we should check for the decoded one as the best-case scenario. Next search for an image in the imageCache or return nil as a fallback.

extension ImageCache {
    func image(for url: URL) -> UIImage? {
        lock.lock(); defer { lock.unlock() }
        // the best case scenario -> there is a decoded image
        if let decodedImage = decodedImageCache.object(forKey: url as AnyObject) as? UIImage {
            return decodedImage
        }
        // search for image data
        if let image = imageCache.object(forKey: url as AnyObject) as? UIImage {
            let decodedImage = image.decodedImage()
            decodedImageCache.setObject(image as AnyObject, forKey: url as AnyObject, cost: decodedImage.diskSize)
            return decodedImage
        }
        return nil
    }
}

We can use the functions above to define a subscript for the ImageCache:

extension ImageCache {
    subscript(_ key: URL) -> UIImage? {
        get {
            return image(for: key)
        }
        set {
            return insertImage(newValue, for: key)
        }
    }
}

Just like that, we’ve built the image cache that could be reused within your projects and make them faster and more responsive.

Integration with Image Loader

Let’s have a look at how to integrate ImageCache into your project. Let’s assume that you have Image Loader defined already. If not, it could be done as following using Combine framework:

final class ImageLoader {

    func loadImage(from url: URL) -> AnyPublisher<UIImage?, Never> {
        return URLSession.shared.dataTaskPublisher(for: url) ➊
            .map { (data, _) -> UIImage? in return UIImage(data: data) } ➋
            .catch { error in return Just(nil) } ➌
            .subscribe(on: backgroundQueue) ➍
            .receive(on: RunLoop.main) ➎
            .eraseToAnyPublisher() ➏
    }
}

dataTaskPublisher creates a publisher that delivers the results of performing URL session data tasks. It returns down the pipeline a tuple (data: Data, response: URLResponse).
➋ The map operator is used to create an optional UIImage object.
➌ We are using catch operator for error handling. It replaces the upstream publisher with Just(nil) publisher.
➍ Performs the work on the background queue.
➎ Switches to receive the image on the main queue.
eraseToAnyPublisher does type erasure on the chain of operators so the loadImage(from:) function returns an object of type AnyPublisher<UIImage?, Never>.

Next we should make some adjustments in the ImageLoader to return an image immediately if we have one and to cache one when date loading is finished. Eventually, the ImageLoader can look like:

final class ImageLoader {

    private let cache = ImageCache()

    func loadImage(from url: URL) -> AnyPublisher<UIImage?, Never> {
        if let image = cache[url] {
            return Just(image).eraseToAnyPublisher() ➊
        }
        return URLSession.shared.dataTaskPublisher(for: url)
            .map { (data, response) -> UIImage? in return UIImage(data: data) }
            .catch { error in return Just(nil) }
            .handleEvents(receiveOutput: {[unowned self] image in ➋
                guard let image = image else { return }
                self.cache[url] = image
            })
            .subscribe(on: backgroundQueue)
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }
}

➊ Returns Just publisher with the cached image if any.
➋ The data is passed into receiveOutput as the publisher makes it available. Here we cache an image as soon as data loading is done and dataTaskPublisher emits a new value.

Conclusion

With ImageCache, you can optimize the image loading within an app and enhance user experience. After all, loading images from cache should be always faster than getting ones from the network.

It should be mentioned that you can introduce some improvements to the solution mentioned above:

  • using LRU cache instead of NSCache;
  • adding persistence;
  • using read-write lock for better performance.

You can find the source code of everything described in this blog post on Github. Feel free to play around and reach me out on Twitter if you have any questions, suggestions or feedback.

Thanks for reading!