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 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.
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
andAssetDownloadItemDelegate
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
orHTTPS
GET
request. - The server provides either the
ETag
orLast-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:
- 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:
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 anURLSessionDownloadTask
instance - adding easy-to-use coalescing and pausing functionality around it.AssetDownloadItemDelegate
is a protocol to allow anAssetDownloadItem
instance to communicate with theAssetDownloadsSession
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:
Ready
indicates that ourAssetDownloadItem
instance hasn't started downloading yet.Downloading
indicates that ourAssetDownloadItem
instance is currently downloading.Paused
indicates that ourAssetDownloadItem
instance isn't actively downloading but has in the past and has kept the previously downloaded data.Cancelled
indicates that ourAssetDownloadItem
instance isn't actively downloading and has thrown away any previously downloaded data.Completed
indicates that ourAssetDownloadItem
instance download has finished (either successfully or due to a failure) and the work of thisAssetDownloadItem
instance 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 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:
AssetDownloadItem
is a simple wrapper around creating and resuming anURLSessionDownloadTask
instance.- The download completion closure that will be used when the download completes.
- Mirroring
URLSessionDownloadTask
,AssetDownloadItem
has 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
completionHandler
is triggered either with the requested asset as aData
instance 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
AssetDownloadItem
instance by setting itsdownloadTask
andcompletionHandler
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:
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 aData
instance. It's important to note that theData
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:
- Cancel (
cancel()
) - 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:
- Added a property to store any resumption
Data
instance. pause()
cancels the wrappedURLSessionDownloadTask
instance and setsresumptionData
with theData
instance 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 theURLSessionDownloadTask
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
}
- We only want to be able to coalesce
ready
,downloading
orpaused
. - We can only resume
AssetDownloadItem
instances that are not currently downloading or have been completed. - Wrapper around a check for if the
state
ispaused
so that it reads better. - Wrapper around a check for if the
state
iscompleted
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:
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.- An array of the
AssetDownloadItem
instances that are either downloading, paused or cancelled. URLSessionFactory
is a factory that handles the creation ofURLSession
instances.- In
scheduleDownload(url:completionHandler:)
a newAssetDownloadItem
instance is created, added to theassetDownloadItems
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:
- Inject the default
NotificationCenter
instance into theAssetDownloadsSession
init'er. - Register for the
UIApplication.didReceiveMemoryWarningNotification
notification. - Loop through the
AssetDownloadItem
instances, find those that arepaused
and the cancel those instances. Finally, filter out those now-cancelledAssetDownloadItem
instances to remove them from theassetDownloadItems
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:
AssetDownloadsSession
now conforms toAssetDownloadItemDelegate
.- Added the current
AssetDownloadsSession
instance as delegate to the newAssetDownloadItem
instance. - 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 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:
AssetDownloadsSession
now conforms toURLSessionDownloadDelegate
. AsURLSessionDownloadDelegate
inherits fromNSObjectProtocol
,AssetDownloadsSession
now needs to be anNSObject
subclass.- As
AssetDownloadsSession
is now anNSObject
subclass, a call tosuper
must be made in its init'er. - When creating the
URLSession
instance, we need to set theAssetDownloadsSession
instance as thedelegate
of 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
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:
- Added a
NSKeyValueObservation
property so that it can outlive the method it will be created in. - When this
AssetDownloadItem
instance is deinit'ed, theNSKeyValueObservation
instance is invalidated so it will stop observing. - Add an observe onto the
progress
property of theURLSessionDownloadTask
instance. As it changes, a log is made detailing what the new download percentage is. - 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 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.
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.