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!