Don't throw anything away with pausable downloads

March 5th, 2018
#networking #imgur-api

Once upon a time, an app was just a stripped-down version of a website. Those days are long gone. Our users now expect to be able to do everything through the app that they can do through the website. This change in expectation has meant that our apps have become increasingly media hungry. Despite network speeds increasing year-on-year, network requests are still the most likely source of bottlenecks, especially media requests. Every time a user has to wait for a network request to complete before they can get on with their task, we risk losing that user.

A common approach to try and alleviate these bottlenecks is to cancel network requests as soon as possible. While this approach is valid in some scenarios, I have too often seen it used naively for all network request. When we cancel a network request, we throw away any progress that request has made in downloading the requested asset. If the app then goes on to request that asset again, the download starts at 0% and then spend time redownloading data it previously had.

A photo of a street at night with bright signs

In this post, I want to look at how we can build a better downloading approach that doesn't throw away data and merges duplicate download requests. All without requiring any extra effort from the consumer of the media layer.

This post will build up to a working example however if you're just too excited to wait for that, then head on over to the completed example and take a look at AssetDownloadsSession, AssetDownloadItem and AssetDownloadItemDelegate to see how things end up. To run the example project, follow the instructions below.

Time for a small detour 🗺️

Just because we want to resume a cancelled download does not mean that we can. For resumption to be possible, we need the following to be true:

  • The resource has not changed since it was first requested.
  • The task is an HTTP or HTTPS GET request.
  • The server provides either the ETag or Last-Modified header (or both) in its response.
  • The server supports byte-range requests.

If all of the above is true then congratulations you are ready for the rest of this article; if the above isn't true, then you have some work to do before you can implement the below solution.

Now, before we start building our downloading layer, let's look (briefly) at how iOS handles supporting resuming downloads via URLSession. To resume a download, we need to use a special init'er on URLSessionDownloadTask - downloadTask(withResumeData:completionHandler:) (this example is only going to show the completion handler approach if you want to see a delegation based approach Alexander Grebenyuk has a great article on that approach here). Rather than taking a URL or URLRequest, downloadTask(withResumeData:completionHandler:) takes a Data instance. This Data instance, among other things, tells the URLSessionDownloadTask instance where the already downloaded media data should be on the file system and what parts of the media asset have already been downloaded. We get this Data instance by calling cancel(byProducingResumeData:) when cancelling the original URLSessionDownloadTask instance.

If the above introduction to resuming downloads feels too short, don't fret we will cover the topic in a lot more detail as we build up the media download layer below.

Let's get building 👷

Our media download layer has 5 primary responsibilities:

  1. Scheduling downloads.
  2. Pausing downloads.
  3. Resuming paused downloads.
  4. Removing unwanted paused downloads (freeing memory).
  5. Downloading the requested media.

These responsibilities together produce the following class structure:

Class diagram showing media download layers structure. AssetDataManager is connected to the AssetDownloadsSession so that it can schedule and cancel downloads. AssetDownloadsSession creates instances of AssetDownloadItem to perform the download. AssetDownloadItem communicates with AssetDownloadsSession via a delegate AssetDownloadItemDelegate and AssetDownloadItem communicates directly with AssetDataManager via a passed through closure

  • AssetDataManager is the manager for accessing assets, e.g. downloading an asset or locally retrieving a cached asset.
  • AssetDownloadsSession is the controller for scheduling, pausing, resuming, cancelling and deleting download requests.
  • AssetDownloadItem is a wrapper around an URLSessionDownloadTask instance - adding easy-to-use coalescing and pausing functionality around it.
  • AssetDownloadItemDelegate is a protocol to allow an AssetDownloadItem instance to communicate with the AssetDownloadsSession instance.

Both AssetDownloadItem and AssetDownloadItemDelegate are private and only visible to AssetDownloadsSession.

We won't see the implementation of AssetDataManager as it is the consumer of pausable downloads rather than a part of it. I included it in the above diagram to show how the media download layer can be used.

Before we start adding in the ability to download an asset, let's look at the possible lifecycle states an AssetDownloadItem instance can be in:

  1. Ready indicates that our AssetDownloadItem instance hasn't started downloading yet.
  2. Downloading indicates that our AssetDownloadItem instance is currently downloading.
  3. Paused indicates that our AssetDownloadItem instance isn't actively downloading but has in the past and has kept the previously downloaded data.
  4. Cancelled indicates that our AssetDownloadItem instance isn't actively downloading and has thrown away any previously downloaded data.
  5. Completed indicates that our AssetDownloadItem instance download has finished (either successfully or due to a failure) and the work of this AssetDownloadItem instance is over.

The lifecycle of an AssetDownloadItem instance looks like:

Diagram showing the lifecycle of an AssetDownloadItem instance. Moving from  to  to either: ,  or . With  then moving to , and  moving to either  or

So a download that starts and completes without pausing or cancelling would look like:

Ready -> Downloading -> Completed

Whereas another download that has been paused would look more like:

Ready -> Downloading -> Paused -> Downloading -> Completed

These states are best represented as an enum:

fileprivate enum State: String {
    case ready
    case downloading
    case paused
    case cancelled
    case completed
}

Now that we have the possible lifecycle states represented, let's add in the basic structure of AssetDownloadItem to move between those states:

fileprivate class AssetDownloadItem {

    //Omitted other properties

    private(set) var state: State = .ready

    //Omitted other methods

    func resume() {
        state = .downloading
    }

    func cancel() {
        state = .cancelled
    }

    func pause() {
        state = .paused
    }

    private func complete() {
        state = completed
    }
}

These methods don't do much yet - we will gradually fill them out as we build up the example.

Now that we have the lifecyle in, let's add in the ability to download an asset:

typealias DownloadCompletionHandler = ((_ result: Result<Data, Error&rt;) -> ())

fileprivate class AssetDownloadItem { // 1
    //Omitted other properties

    private let session: URLSession
    private var downloadTask: URLSessionDownloadTask?

    let url: URL
    var completionHandler: DownloadCompletionHandler? // 2

    // MARK: - Init

    init(session: URLSession, url: URL) {
        self.session = session
        self.url = url
    }

    // MARK: - Lifecycle

    // 3
    func resume() {
        state = .downloading

        os_log(.info, "Creating a new download task")
        downloadTask = session.downloadTask(with: url, completionHandler: downloadTaskCompletionHandler)

        downloadTask?.resume()
    }

    // 4
    private func downloadTaskCompletionHandler(_ fileLocationURL: URL?, _ response: URLResponse?, _ error: Error?) {
        var result: Result
        defer {
            downloadCompletionHandler?(result)

            if state != .paused {
                complete()
            }

            cleanup()
        }

        guard let fileLocationURL = fileLocationURL else {
            result = .failure(NetworkingError.retrieval(underlayingError: error))
            return
        }

        do {
            let data = try Data(contentsOf: fileLocationURL)
            result = .success(data)
        } catch let error {
            result = .failure(NetworkingError.invalidData(underlayingError: error))
        }
    }

    // 5
    private func cleanup() {
        downloadTask = nil
        completionHandler = nil
    }

    //Omitted other methods
}

If you have cloned the example project, you will notice that I make extensive use of protocols that are not shown above. The protocols are used to allow me to better unit test this solution; I've omitted them from this post to make it more readable.

Here’s what we did:

  1. AssetDownloadItem is a simple wrapper around creating and resuming an URLSessionDownloadTask instance.
  2. The download completion closure that will be used when the download completes.
  3. Mirroring URLSessionDownloadTask, AssetDownloadItem has its own resume() method that when called causes the download to start.
  4. The completion handler for the download task. Depending on how the download progresses, the completionHandler is triggered either with the requested asset as a Data instance or an error detailing what happened. Only non-paused downloads are considered complete when the closure is called.
  5. Regardless of how a download is completed, we need to clean up this AssetDownloadItem instance by setting its downloadTask and completionHandler to nil so that they can't accidentally be reused.

The possible errors that can be returned from download request are:

enum NetworkingError: Error {
    case unknown
    case retrieval(underlayingError: Error?)
    case invalidData(underlayingError: Error?)
}

Now that we can start a download let's look at how we can cancel a download. URLSessionDownloadTask has two methods for cancellation:

  1. cancel() - download is stopped and any data downloaded so far is discarded.
  2. cancel(byProducingResumeData: @escaping (Data?) -> Void) - download is stopped and any data downloaded so far is stored in a temporary filesystem location. Details of the partially completed download are passed back as a Data instance. It's important to note that the Data instance returned in the completion closure of the cancel method, is not the data that has been downloaded so far but is instead data that will allow the download to be resumed from its partial downloaded state.

In the context of strengthening our media download system, we can think of these cancel methods, respectively as:

  1. Cancel (cancel())
  2. Pause (cancel(byProducingResumeData:))

Lets add the ability to cancel a download:

fileprivate class AssetDownloadItem {

    //Omitted properties and other methods

    // 3
    func cancel() {
        state = .cancelled

        os_log(.info, "Cancelling download")
        downloadTask?.cancel()

        cleanup()
    }
}

cancel() forwards the cancel instruction onto the wrapped URLSessionDownloadTask instance.

Now let's add the ability to pause a download:

fileprivate class AssetDownloadItem {

    //Omitted other properties

    // 1
    private var resumptionData: Data?

    //Omitted other methods

    // 2
    func pause() {
        state == .paused

        os_log(.info, "Pausing download")
        downloadTask?.cancel(byProducingResumeData: { [weak self] (data) in
            guard let data = data else {
                return
            }

            os_log(.info, "Cancelled download task has produced resumption data of: %{public}@ for %{public}@", data.description, self?.url.absoluteString ?? "unknown url")
            self?.resumptionData = data
        })

        cleanup()
    }
}

With the above changes here’s what we did:

  1. Added a property to store any resumption Data instance.
  2. pause() cancels the wrapped URLSessionDownloadTask instance and sets resumptionData with the Data instance returned in the closure.

You may be thinking "Why not just call suspend() on the download?". It's a good idea however calling suspend() on an active download doesn't actually stop that download (even though the URLSessionDownloadTask instance will report that it's stopped downloading). You can see this in action if you use Charles Proxy to snoop on a supposedly suspended download.

Now that we have some resumption data, let's use it by refactoring our resume() method:

fileprivate class AssetDownloadItem {

    //Omitted properties

    func resume() {
        //Omitted start of method

        if let resumptionData = resumptionData {
            os_log(.info, "Attempting to resume download task")
            downloadTask = session.downloadTask(withResumeData: resumptionData, completionHandler: downloadTaskCompletionHandler)
        } else {
            os_log(.info, "Creating a new download task")
            downloadTask = session.downloadTask(with: url, completionHandler: downloadTaskCompletionHandler)
        }

        downloadTask?.resume()
    }

    //Omitted other methods
}

With the above changes, we now have two ways to create a URLSessionDownloadTask instance: with and without resumption data.

It's not uncommon for the same asset to be requested for download multiple times. Making multiple requests for the same asset is wasteful. While caching assets is outside of the scope of the media download layer, coalescing (or merging) active download requests for the same asset isn't:

fileprivate class AssetDownloadItem {

    //Omitted properties and other methods

    //MARK: - Coalesce

    func coalesceDownloadCompletionHandler(_ otherDownloadCompletionHandler: @escaping DownloadCompletionHandler) {
        let initalDownloadCompletionHandler = downloadCompletionHandler

        downloadCompletionHandler = { result in
            initalDownloadCompletionHandler?(result)
            otherDownloadCompletionHandler(result)
        }
    }
}

In coalesceDownloadCompletionHandler(_:) the existing completion handler (initalDownloadCompletionHandler) and the completion handler (otherDownloadCompletionHandler) for the new download are wrapped together in a third completion handler (downloadCompletionHandler). This third completion handler is then set as this AssetDownloadItem instance's downloadCompletionHandler value. This technique means that when this download completes the completion handler for both download requests will be triggered.

It is possible to recursively wrap any number of DownloadCompletionHandler closures using this approach.

Lets add a few connivence properties for interpreting the state property:

fileprivate class AssetDownloadItem {
    //Omitted other properties

    // 1
    var isCoalescable: Bool {
        return (state == .ready) ||
            (state == .downloading) ||
            (state == .paused)
    }

    // 2
    var isResumable: Bool {
        return (state == . ready) ||
            (state == .paused)
    }

    // 3
    var isPaused: Bool {
        return state == .paused
    }

    // 4
    var isCompleted: Bool {
        return state == .completed
    }

    //Omitted methods
}
  1. We only want to be able to coalesce ready, downloading or paused.
  2. We can only resume AssetDownloadItem instances that are not currently downloading or have been completed.
  3. Wrapper around a check for if the state is paused so that it reads better.
  4. Wrapper around a check for if the state is completed so that it reads better.

We will see how these are used in AssetDownloadsSession.

Before we leave AssetDownloadItem lets add a description property to aid our debugging:

fileprivate class AssetDownloadItem {
    //Omitted other properties

    var description: String {
        return url.absoluteString
    }

    //Omitted other methods
}

Our AssetDownloadItem instances description will now show the URL of the asset that it is downloading.

So far we have been building up AssetDownloadItem and while we are not yet done with, AssetDownloadItem now has enough functionality to allow us to turn our attention to AssetDownloadsSession.

As mentioned above AssetDownloadsSession has 4 tasks with regards to download requests:

  • Scheduling.
  • Pausing.
  • Resuming.
  • Cancelling.

However, not all 4 need to be exposed. Only scheduling and cancelling need to be public. Resuming and pausing can private. Resuming a download is just a special case of scheduling and pausing is just a special case of cancellation. By hiding the ability to resume and pause a download, we can keep the interface of AssetDownloadsSession minimal.

First, lets look at how we can schedule a download:

class AssetDownloadsSession {

    // 1
    static let shared = AssetDownloadsSession()

    // 2
    private var assetDownloadItems = [AssetDownloadItem]()

    private var session: URLSession

    // MARK: - Init

    // 3
    init(urlSessionFactory: URLSessionFactory = URLSessionFactory()) {
        self.session = urlSessionFactory.defaultSession()
    }

    // MARK: - Schedule

    // 4
    func scheduleDownload(url: URL, completionHandler: @escaping DownloadCompletionHandler) {
        let assetDownloadItem = AssetDownloadItem(session: session, url: url)
        assetDownloadItem.downloadCompletionHandler = downloadCompletionHandler

        os_log(.info, "Adding new download: %{public}@", assetDownloadItem.description)

        assetDownloadItems.append(assetDownloadItem)

        assetDownloadItem.resume()
    }
}

Here’s what we did:

  1. AssetDownloadsSession is a singleton as we want all asset downloads to go through the same component which will allow any duplicate download requests to be spotted and coalesced.
  2. An array of the AssetDownloadItem instances that are either downloading, paused or cancelled.
  3. URLSessionFactory is a factory that handles the creation of URLSession instances.
  4. In scheduleDownload(url:completionHandler:) a new AssetDownloadItem instance is created, added to the assetDownloadItems array and the download is started.

URLSessionFactory looks like:

class URLSessionFactory {

    // MARK: - Default

    func defaultSession(delegate: URLSessionDelegate? = nil, delegateQueue queue: OperationQueue? = nil) -> URLSession {
        let configuration = URLSessionConfiguration.default

        //For demonstration purposes disable caching
        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
        configuration.urlCache = nil

        let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: queue)

        return session
    }
}

Now that we can schedule a download, let's look at how to pause a download.

As mentioned above, outside of AssetDownloadsSession there is no concept of pausing a download so our pause method will be presented as a cancel method.

class AssetDownloadsSession {
    //Omitted properties and other methods

    // MARK: - Cancel

    func cancelDownload(url: URL) {
        guard let assetDownloadItem = assetDownloadItems.first(where: { $0.url == url }) else {
            return
        }

        os_log(.info, "Download: %{public}@ going to be paused", assetDownloadItem.description)
        assetDownloadItem.pause()
    }
}

In the above method, we first determine whether an existing AssetDownloadItem instance exists for the URL passed in and if it does pause() is called on it.

The only time that we really want to be actually cancelling downloads is when the system is under strain, and we need to free up memory. When this happens iOS posts a UIApplication.didReceiveMemoryWarningNotification notification that we can listen for:

class AssetDownloadsSession {
    //Omitted properties

    // MARK: - Init

    // 1
    init(urlSessionFactory: URLSessionFactory = URLSessionFactory(), notificationCenter: NotificationCenter = NotificationCenter.default) {
        self.session = urlSessionFactory.defaultSession(delegate: self)
        registerForNotifications(on: notificationCenter)
    }

    // MARK: - Notification

    // 2
    private func registerForNotifications(on notificationCenter: NotificationCenter) {
        notificationCenter.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main) { [weak self] _ in
            self?.purgePausedDownloads()
        }
    }

    // 3
    private func purgePausedDownloads() {
        accessQueue.sync {
           os_log(.info, "Cancelling paused items")

           assetDownloadItems = assetDownloadItems.filter { (assetDownloadItem) -> Bool in
               let isPaused = assetDownloadItem.isPaused
               if isPaused {
                   assetDownloadItem.cancel()
               }

               return !isPaused
           }
        }
    }

    //Omitted other methods
}

With the above changes here’s what we did:

  1. Inject the default NotificationCenter instance into the AssetDownloadsSession init'er.
  2. Register for the UIApplication.didReceiveMemoryWarningNotification notification.
  3. Loop through the AssetDownloadItem instances, find those that are paused and the cancel those instances. Finally, filter out those now-cancelled AssetDownloadItem instances to remove them from the assetDownloadItems array.

So far we are able to schedule, pause and cancel downloads so let's add in the ability to resume a download:

class AssetDownloadsSession {
    //Omitted properties and other methods

    func scheduleDownload(url: URL, completionHandler: @escaping DownloadCompletionHandler) {
        if let assetDownloadItem = assetDownloadItems.first(where: { $0.url == url && $0.isCoalescable }) {
            os_log(.info, "Found existing %{public}@ download so coalescing them for: %{public}@", assetDownloadItem.state.rawValue, assetDownloadItem.description)

            assetDownloadItem.coalesceDownloadCompletionHandler(completionHandler)

            if assetDownloadItem.isResumable {
                assetDownloadItem.resume()
            }
        } else {
            //Omitted
        }
    }
}

With the changes above when a URL is passed in, a check is made to see if there is an existing AssetDownloadItem instance with that URL that can be coalesced. If there is an existing AssetDownloadItem instance, then the new download request is coalesced with it. If that coalesced download was paused, it is resumed.

Lets add a delegate so that our AssetDownloadItem instances can inform AssetDownloadsSession that a download has been completed so that it can be removed from the assetDownloadItems array:

fileprivate protocol AssetDownloadItemDelegate {
    func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem)
}

fileprivate class AssetDownloadItem: Equatable {
    //Omitted other properties

    var delegate: AssetDownloadItemDelegate?

    //Omitted other methods

    private func complete() {
        state = .completed

        delegate?.assetDownloadItemCompleted(self)
    }
}

AssetDownloadsSession needs to implement AssetDownloadItemDelegate so that the completed download can be removed:

class AssetDownloadsSession: AssetDownloadItemDelegate // 1 {
    //Omitted properties and other methods

    func scheduleDownload(url: URL, completionHandler: @escaping DownloadCompletionHandler) {
        if let existingCoalescableAssetDownloadItem = assetDownloadItems.first(where: { $0.url == url && $0.isCoalescable }) {
            //Omitted
        } else {
            let assetDownloadItem = AssetDownloadItem(session: session, url: url)
            assetDownloadItem.downloadCompletionHandler = completionHandler
            assetDownloadItem.delegate = self // 2

            os_log(.info, "Created a new download: %{public}@", assetDownloadItem.description)

            assetDownloadItems.append(assetDownloadItem)

            assetDownloadItem.resume()
        }
    }

    // MARK: - AssetDownloadItemDelegate

    // 3
    func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) {
        os_log(.info, "Completed download of: %{public}@", assetDownloadItem.description)

        if let index = assetDownloadItems.firstIndex(where: { $0.url == assetDownloadItem.url && $0.isCompleted }) {
            assetDownloadItems.remove(at: index)
        }
    }
}

Here’s what we did:

  1. AssetDownloadsSession now conforms to AssetDownloadItemDelegate.
  2. Added the current AssetDownloadsSession instance as delegate to the new AssetDownloadItem instance.
  3. When the delegate assetDownloadItemCompleted(_:) method is triggered, we remove that completed download from assetDownloadItems.

We are almost there, but we need to fix a big gotcha in the solution. Most of what AssetDownloadsSession does is manipulate the assetDownloadItems array. This array is updated from multiple locations, and there is currently no guarantee that the same thread will be used in all updates potentially leading to a race condition where thread A has triggered an AssetDownloadItem instance to be removed from the assetDownloadItems array just as thread B is attempting to coalesce that same instance. We can avoid these types of scenarios by using a serial dispatch queue to wrap all assetDownloadItems array updates:

class AssetDownloadsSession: AssetDownloadItemDelegate {
    //Omitted other properties

    private let accessQueue = DispatchQueue(label: "com.williamboles.downloadssession")

    //Omitted other methods

    private func registerForNotifications(on notificationCenter: NotificationCenter) {
        notificationCenter.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main) { [weak self] _ in
            self?.accessQueue.sync {
                //Omitted rest of method
            }
        }
    }

    // MARK: - Schedule

    func scheduleDownload(url: URL, completionHandler: @escaping DownloadCompletionHandler) {
        accessQueue.sync {
            //Omitted rest of method
        }
    }

    // MARK: - Cancel

    func cancelDownload(url: URL) {
        accessQueue.sync {
            //Omitted rest of method
        }
    }

    // MARK: - AssetDownloadItemDelegate

    fileprivate func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) {
        accessQueue.sync {
            //Omitted rest of method
        }
    }
}

And that's the media download layer complete 🥳.

How do we know it actually works? 🕵️

If you've run the example project, you will notice that downloads start, pause and finish but how we know the download is actually resuming after it's paused and isn't just starting again from 0%?

Well we can add in some more logging to get that information. URLSessionDownloadDelegate has a special method for downloads that are resumed. Lets add it into AssetDownloadsSession:

class AssetDownloadsSession: NSObject, AssetDownloadItemDelegate, URLSessionDownloadDelegate  // 1 {
    //Omitted properties

    // MARK: - Init

    init(urlSessionFactory: URLSessionFactory = URLSessionFactory(), notificationCenter: NotificationCenter = NotificationCenter.default) {
        super.init() // 2

        self.session = urlSessionFactory.defaultSession(delegate: self) // 3
        registerForNotifications(on: notificationCenter)
    }

    //Omitted other methods

    // MARK: - URLSessionDownloadDelegate

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { /*no-op*/ }

    // 4
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
        guard let url = downloadTask.currentRequest?.url else {
            return
        }
        let resumptionPercentage = (Double(fileOffset)/Double(expectedTotalBytes)) * 100
        os_log(.info, "Resuming download: %{public}@ from: %{public}.02f%%", url.absoluteString, resumptionPercentage)
    }
}

Here’s what we did:

  1. AssetDownloadsSession now conforms to URLSessionDownloadDelegate. As URLSessionDownloadDelegate inherits from NSObjectProtocol, AssetDownloadsSession now needs to be an NSObject subclass.
  2. As AssetDownloadsSession is now an NSObject subclass, a call to super must be made in its init'er.
  3. When creating the URLSession instance, we need to set the AssetDownloadsSession instance as the delegate of that session.
  4. Implemented the urlSession(_:downloadTask:didResumeAtOffset:expectedTotalBytes:) to log the percentage of when a download is resumed.

It's interesting to note that when creating URLSessionDownloadTask instances, this solution is now using both the completion handler and the delegate.

As well as logging at what percentage a download is resumed, it is also useful to know that downloads percentage when it was paused:

fileprivate class AssetDownloadItem {
    //Omitted other properties

    private var observation: NSKeyValueObservation? // 1

    //Omitted other

    deinit {
        observation?.invalidate()  // 2
    }

    //Omitted other methods

    func resume() {
        //Omitted rest of method

        // 3
        observation = downloadTask?.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] (progress, change) in
            os_log(.info, "Downloaded %{public}.02f%% of %{public}@", (progress.fractionCompleted * 100), self?.url.absoluteString ?? "")
        }
    }

    //Omitted other methods

    // 4
    private func cleanup() {
        observation?.invalidate()
        downloadTask = nil
    }
}

Here’s what we did:

  1. Added a NSKeyValueObservation property so that it can outlive the method it will be created in.
  2. When this AssetDownloadItem instance is deinit'ed, the NSKeyValueObservation instance is invalidated so it will stop observing.
  3. Add an observe onto the progress property of the URLSessionDownloadTask instance. As it changes, a log is made detailing what the new download percentage is.
  4. During cleanup, we invalidate the NSKeyValueObservation instance. N.B. cleanup is called when pausing a download as well as when completing a download so we need both observation?.invalidate() in both cleanup() and deinit().

With this additional logging, it is now possible to get a really good understanding of what is happening with our downloads.

Only half the story 📖

This has been a fairly long post so if you've made it here, take a moment to breathe out and enjoy it 👏.

To recap, we built a download media layer that:

  1. Allows for scheduling, cancelling, pausing and resuming of download requests.
  2. Allows for coalescing multiple download requests for the same asset.

And does this without exposing the complexity of pausing, resuming or coalescing.

With this new media download layer, we have improved download performance by enhancing the mechanics of downloading, but we can improve download performance further by:

  1. Downloading the smallest possible asset required for the UI.
  2. Predicting where the user is going and prefetching those assets.
  3. Caching offline previously downloaded assets.

In the end, our users are going to have to wait for media to download, all that we can do is ensure that they are waiting due to their desire for ever-increasingly media-rich apps rather than our choices over how downloads are handled.

You can download the example project for this post here.


Running the example project 🏃

In the example project I used Imgur as an online image database to populate the app. Imgur has a great JSON based API and an extensive library of freely available media. Imgur API, while being free, does require us to register our example project to get a client-id which needs to be sent with each request.

As the example project does not require access to a user's personal data, when registering select the Anonymous usage without user authorization option for Authorization type. At the time of writing, you had to provide a URL in the Authorization callback URL field even for anonymous authentication - I found any URL value would work for this.

After you register, you should be given a unique client-id which will allow you access to Imgur's content. You will need to add your client-id as the value of the clientID property in RequestConfig. After you've added your client-id, you should be able to run the app and see how our background-transfer solution works.

What do you think? Let me know by getting in touch on Twitter - @wibosco