Don't Throw Anything Away with Pausable Downloads
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 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 requests. 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 spends time redownloading data it previously had.

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,AssetDownloadItemandAssetDownloadItemDelegateto 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
HTTPorHTTPSGETrequest. - The server provides either the
ETagorLast-Modifiedheader (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 post; if the above isn't true, then you have some work to do before you can implement the solution below.
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 post 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:
- Scheduling downloads.
- Pausing downloads.
- Resuming paused downloads.
- Removing unwanted paused downloads (freeing memory).
- Downloading the requested media.
These responsibilities together produce the following class structure:

AssetDataManageris the manager for accessing assets, e.g. downloading an asset or locally retrieving a cached asset.AssetDownloadsSessionis the controller for scheduling, pausing, resuming, cancelling and deleting download requests.AssetDownloadItemis a wrapper around anURLSessionDownloadTaskinstance - adding easy-to-use coalescing and pausing functionality around it.AssetDownloadItemDelegateis a protocol to allow anAssetDownloadIteminstance to communicate with theAssetDownloadsSessioninstance.
Both AssetDownloadItem and AssetDownloadItemDelegate are private and only visible to AssetDownloadsSession.
We won't see the implementation of
AssetDataManageras 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:
Readyindicates that ourAssetDownloadIteminstance hasn't started downloading yet.Downloadingindicates that ourAssetDownloadIteminstance is currently downloading.Pausedindicates that ourAssetDownloadIteminstance isn't actively downloading, but has in the past and has kept the previously downloaded data.Cancelledindicates that ourAssetDownloadIteminstance isn't actively downloading and has thrown away any previously downloaded data.Completedindicates that ourAssetDownloadIteminstance download has finished (either successfully or due to a failure) and the work of thisAssetDownloadIteminstance is over.
The lifecycle of an AssetDownloadItem instance looks like:

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 lifecycle in, let's add 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:
AssetDownloadItemis a simple wrapper around creating and resuming anURLSessionDownloadTaskinstance.- The download completion closure that will be used when the download completes.
- Mirroring
URLSessionDownloadTask,AssetDownloadItemhas its ownresume()method that, when called, causes the download to start. - The completion handler for the download task. Depending on how the download progresses, the
completionHandleris triggered either with the requested asset as aDatainstance or an error detailing what happened. Only non-paused downloads are considered complete when the closure is called. - Regardless of how a download is completed, we need to clean up this
AssetDownloadIteminstance by setting itsdownloadTaskandcompletionHandlerto nil so that they can't accidentally be reused.
The possible errors that can be returned from the 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:
cancel()- download is stopped, and any data downloaded so far is discarded.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 aDatainstance. It's important to note that theDatainstance 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 partially downloaded state.
In the context of strengthening our media download system, we can think of these cancellation methods, respectively, as:
- Cancel (
cancel()) - Pause (
cancel(byProducingResumeData:))
Let's 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:
- Added a property to store any resumption
Datainstance. pause()cancels the wrappedURLSessionDownloadTaskinstance and setsresumptionDatawith theDatainstance returned in the closure.
You may be thinking "Why not just call
suspend()on the download?". It's a good idea, however, callingsuspend()on an active download doesn't actually stop that download (even though theURLSessionDownloadTaskinstance 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
DownloadCompletionHandlerclosures using this approach.
Let's add a few convenience 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
}
- We only want to be able to coalesce
ready,downloadingorpaused. - We can only resume
AssetDownloadIteminstances that are not currently downloading or have been completed. - Wrapper around a check for if the
stateispausedso that it reads better. - Wrapper around a check for if the
stateiscompletedso that it reads better.
We will see how these are used in
AssetDownloadsSession.
Before we leave AssetDownloadItem let's 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, it now has enough functionality to allow us to turn our attention to AssetDownloadsSession.
As mentioned above, AssetDownloadsSession has 4 tasks with regard 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 be 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, let's 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:
AssetDownloadsSessionis 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.- An array of the
AssetDownloadIteminstances that are either downloading, paused or cancelled. URLSessionFactoryis a factory that handles the creation ofURLSessioninstances.- In
scheduleDownload(url:completionHandler:), a newAssetDownloadIteminstance is created, added to theassetDownloadItemsarray, 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:
- Inject the default
NotificationCenterinstance into theAssetDownloadsSessioninit'er. - Register for the
UIApplication.didReceiveMemoryWarningNotificationnotification. - Loop through the
AssetDownloadIteminstances, find those that arepausedand cancel those instances. Finally, filter out those now-cancelledAssetDownloadIteminstances to remove them from theassetDownloadItemsarray.
So far, we can 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.
Let's 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:
AssetDownloadsSessionnow conforms toAssetDownloadItemDelegate.- Added the current
AssetDownloadsSessioninstance as a delegate to the newAssetDownloadIteminstance. - When the delegate
assetDownloadItemCompleted(_:)method is triggered, we remove that completed download fromassetDownloadItems.
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 do we know the download is actually resuming after it's paused and isn't just starting again from 0%?
Well, we can add some more logging to get that information. URLSessionDownloadDelegate has a special method for downloads that are resumed. Let's add it to 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:
AssetDownloadsSessionnow conforms toURLSessionDownloadDelegate. AsURLSessionDownloadDelegateinherits fromNSObjectProtocol,AssetDownloadsSessionnow needs to be anNSObjectsubclass.- As
AssetDownloadsSessionis now anNSObjectsubclass, a call tosupermust be made in its init'er. - When creating the
URLSessioninstance, we need to set theAssetDownloadsSessioninstance as thedelegateof that session. - Implemented the
urlSession(_:downloadTask:didResumeAtOffset:expectedTotalBytes:)to log the percentage of when a download is resumed.
It's interesting to note that when creating
URLSessionDownloadTaskinstances, 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 the percentage when the download 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:
- Added a
NSKeyValueObservationproperty so that it can outlive the method it will be created in. - When this
AssetDownloadIteminstance is deinit'ed, theNSKeyValueObservationinstance is invalidated, so it will stop observing. - Add an observer onto the
progressproperty of theURLSessionDownloadTaskinstance. As it changes, a log is made detailing what the new download percentage is. - During cleanup, we invalidate the
NSKeyValueObservationinstance. N.B. cleanup is called when pausing a download as well as when completing a download, so we need bothobservation?.invalidate()in bothcleanup()anddeinit().
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:
- Allows for scheduling, cancelling, pausing and resuming of download requests.
- 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:
- Downloading the smallest possible asset required for the UI.
- Predicting where the user is going and prefetching those assets.
- 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.
To see the complete working example, visit the repository and clone the project.
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 that 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.