Swift Image Cache

A lot of people are scratching their heads now we’re all using Swift, wondering ‘which image caching library shall I use?’. Hopefully I can throw an option in the ring.

If your image-loading requirements are fairly basic: you want reasonable speed, but you don’t want to worry about the potential delays involved in saving/loading to disk, you may want to consider simply using NSCache - Foundation’s built-in caching class.

One thing this solution brings to the table is that it is easy to determine if a given image is already cached or not. This is often very useful eg when scrolling table views: if an image is cached, you simply set the image with no animations. However if the image isn’t cached, you must display a placeholder, and crossfade the image once loaded - two distinct code paths. One of my biggest gripes with SDWebImage, for instance, is that this is difficult (although not impossible). I feel this use-case should be emphasised by the image cache.

The code

class MyImageCache {
    
    static let sharedCache: NSCache = {
        let cache = NSCache()
        cache.name = "MyImageCache"
        cache.countLimit = 20 // Max 20 images in memory.
        cache.totalCostLimit = 10*1024*1024 // Max 10MB used.
        return cache
    }()
    
}

extension NSURL {
    
    typealias ImageCacheCompletion = UIImage -> Void
    
    /// Retrieves a pre-cached image, or nil if it isn't cached.
    /// You should call this before calling fetchImage.
    var cachedImage: UIImage? {
        return MyImageCache.sharedCache.objectForKey(
            absoluteString) as? UIImage
    }
    
    /// Fetches the image from the network.
    /// Stores it in the cache if successful.
    /// Only calls completion on successful image download.
    /// Completion is called on the main thread.
    func fetchImage(completion: ImageCacheCompletion) {
        let task = NSURLSession.sharedSession().dataTaskWithURL(self) {
            data, response, error in
            if error == nil {
                if let  data = data,
                        image = UIImage(data: data) {
                    MyImageCache.sharedCache.setObject(
                        image, 
                        forKey: self.absoluteString, 
                        cost: data.length)
                    dispatch_async(dispatch_get_main_queue()) {
                        completion(image)
                    }
                }
            }
        }
        task.resume()
    }
    
}

How to use it

Say you’ve got a UITableView with cells that need images. Firstly, i’d have two UIImageViews, one under the other. The underneath view would always contain a placeholder image, eg a watermark of a camera in dark grey on a light grey background. And the upper image would contain the actual image.

In your cellForRowAtIndexPath method, you firstly will try to grab the image from the cache. If it is available, you set the upper image immediately, and set its alpha to 1. If it is not available, you set the upper image’s alpha to 0, load the image, then once loaded you set the image and animate the alpha to 1. It’ll look like the below:

class MyCell: UITableViewCell {
    var placeholderImageView: UIImageView!
    var myImageView: UIImageView!
    var imageUrl: NSURL!
    // ...
}

struct MyModel {
    let text: String
    let imageUrl: NSURL
    // ...
}

class MyViewController: UITableViewController {
    var models: [MyModel]!
    
    override func tableView(tableView: UITableView,
	        cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let data = models[indexPath.row]
        let cell = tableView.dequeueReusableCellWithIdentifier("cell",
            forIndexPath: indexPath) as! MyCell
        cell.textLabel?.text = data.text
        
        // Image loading.
        cell.imageUrl = data.imageUrl // For recycled cells' late image loads.
        if let image = data.imageUrl.cachedImage {
            // Cached: set immediately.
            cell.myImageView.image = image
            cell.myImageView.alpha = 1
        } else {
            // Not cached, so load then fade it in.
            cell.myImageView.alpha = 0
            data.imageUrl.fetchImage { image in
                // Check the cell hasn't recycled while loading.
                if cell.imageUrl == data.imageUrl {
                    cell.myImageView.image = image
                    UIView.animateWithDuration(0.3) {
                        cell.myImageView.alpha = 1
                    }
                }
            }
        }
        
        return cell
   }

}

To be honest, it may make more sense to make a custom view that contains these two image views, and let it handle these responsibilities. I’ll leave that as an exercise to the reader.

Cell recycling

Any discussion about async image loading isn’t complete without talking about cell recycling in table views. A common race condition occurs when scrolling quickly: by the time an image is loaded, that cell has been recycled and it’ll be wrong to set the image on that cell.

To deal with this, I recommend adding an extension on the UIImageView class which stores an image URL as an associated object. Read here for how to do this. Alternatively you can add an imageUrl variable to custom cell subclasses as I did in the above example.

When it comes time to configure a cell, do the following:

  • Set the text label as normal
  • Set the image view’s URL immediately
  • If the image is cached, set the imageView.image immediately
  • If it isn’t cached, load the image:
    • When the image returns, set the imageView.image only if the imageView’s URL == the model object’s image URL

One less Cocoapod

I’m a big proponent of cutting down the number of third-party dependencies your app has. I have no ideology here, simply pragmatism: I’ve simply been burned way too many times by poorly-engineered cocoapods. I’m much more of a fan of blogging a suggestion for a simple solution, and letting you copy and paste it and customise it further if you need it.

In fact, if you combine this image cache with my wrapper for NSURLSession, you can obviate the requirement for the two biggest reasons I have for using Cocoapods at all. In fact, in my current project, by doing this, I do not need to use cocoapods/carthage, and life is good. You may wish to consider the same.

Other options

If you’re the next Flickr or Instagram and this doesn’t look suitable for you, there’s always Haneke or good old SDWebImage.

Thanks for reading!

Thanks for reading! And if you want to get in touch, I'd love to hear from you: chris.hulbert at gmail.

Chris Hulbert

(Comp Sci, Hons - UTS)

iOS Developer (Freelancer / Contractor) in Australia.

I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Coles, Woolworths, Trust Bank, and Westpac, among others. If you're looking for help developing an iOS app, drop me a line!

Get in touch:
[email protected]
github.com/chrishulbert
linkedin



 Subscribe via RSS