Asynchronous Image Downloading with Caching in Swift
Downloading images asynchronously is hard due to complexities involving concurrency, speed, and user experience. In this post, I will implement a simple yet effective image downloader with memory and disk caches.
Structure of the Class
The downloader consists of a class named ImageDownloader
. It provides a method that asynchronously downloads an image with a given URL and calls a handler when the task is completed. Under the hood, this class is also responsible for scheduling incoming download tasks and caching downloaded images.
The rest of this post will be organised into three parts, each dealing with one important feature of the downloader.
Memory and Disk Caching
For simplicity, the downloader uses the NSURLCache
class provided by Apple. During initialisation, we create the cache with the specified memory and disk capacities (in bytes). We then assign it to an NSURLSessionConfiguration
object with which we create a custom NSURLSession
object.
Note that we set the requestCachePolicy
of the configuration object to .ReturnCacheDataElseLoad
. This enables more aggressive caching and is optional.
From now on, whenever you start a data task using this custom NSURLSession
object, the response will be cached if it meets the conditions outlined in the documentation.
LIFO Task Scheduling
By default, network tasks are treated in a FIFO fashion—earlier requests get processed first. LIFO scheduling, however, gives the latest request the highest priority. Why do we need such a behaviour?
Imaging an app that displays rows of images downloaded from the Internet. As the user scrolls down, more and more images are fetched from the server to populate the rows. Assume that the app initially displays rows 1 to 5, and the user quickly scrolls to rows 15 to 20. With FIFO task scheduling, the app has to finish downloading the first five rows of images before it can move on to what the user most urgently wants, causing high latency. LIFO task scheduling solves this problem: the images that are immediately visible to the user are always downloaded first, delivering a responsive experience.
Of course, the scheduler itself needs to be efficient, and the key to this is the data structure we choose to store the tasks. Referring our needs, we know that it should have the following characteristics:
- Ordered
- Fast insertion
- Fast removal
- Fast lookup
Dictionaries and sets are ruled out instantly, because they are unordered collections. Neither can we use arrays, because arbitrary lookup and removals run at O(n)
. The obscure NSMutableOrderedSet
seems like a good candidate—it is a hybrid between a set and an array, providing the best of both worlds. Of course, these benefits come at the cost of higher memory usage, but it should rarely be a concern nowadays.
Let’s create an NSMutableOrderedSet
instance and add it as a property.
We also need a counter to keep track of the number of active tasks.
Since these variables will be mutated on multiple threads, a lock is used for protection.
Here comes the complicated part. To ensure that the scheduler works correctly, we have to state the invariants in our data structure:
dataTasks[0..<activeDataTaskCount]
are tasks that are actively running. The rest of the tasks are in a suspended state.activeDataTaskCount
falls in the range0...min(dataTasks.count, maxActiveDataTaskCount)
and should be kept as large as possible within this range.
To achieve LIFO behaviour, new data tasks should be inserted at index 0. After insertion, we also have to increment activeDataTaskCount
.
Then we run a loop to trim down the number of active tasks so that the invariants are kept.
Removing a task is a bit trickier. Since we do not know whether the task we are about to remove is active or not, a check must be performed. If the task is active, we have to decrement activeDataTaskCount
before removing it.
Then we run a loop to ensure that activeDataTaskCount
is as large as possible.
Voila! This is all you need to implement a LIFO task scheduler.
Duplicate Requests Coalescing
Returning to our hypothetical app, an astute reader may discover that if the user scrolls back up while the images there are still loading, duplicate network requests for the same URL will be issued, generating unnecessary traffic. Hence we need a way to detect such requests and combine them with the original one.
But this coalescing behaviour violates our LIFO rule: if a new, duplicate request is combined into an old one, it will not gain the highest priority! A direct way to solve this is to “promote” the data task to the front of the queue.
In order to reduce overhead, however, the above implementation deviates from the prescribed behaviour in that it does nothing if the task is already actively running.
Back to the issue. When talking about combining network requests, what we really mean is the ability to modify the completion handler of a data task on the fly. We can achieve this by adding another level of indirection—a helper struct ResponseHandler
that stores a data task and all the completion handlers it is going to execute upon completion.
We also need another property in ImageDownloader
that maps a URL to a ResponseHandler
.
Upon receiving a new request, check the responseHandlers
dictionary first to find out whether this is a duplicate request. If yes, simply extract the ResponseHandler
instance, promote the data task, and append the completion handler.
Otherwise, we need to create a new data task. In its completion handler, extract the dictionary entry for the URL, remove the task from the queue, and execute all the completion handlers.
Lastly, do not forget to enqueue the data task and set the dictionary entry for the URL.
And here ends the entire implementation of ImageDownloader
.
Conclusion
Admittedly, ImageDownloader
has several limitations such as the inability to cancel a request. Nevertheless, we can still see that implementing asynchronous image downloading is, at least on a conceptual level, a fun exercise.
All code above is available in this gist.