Keeping downloads going with background transfers
In the past, iOS focused solely on what the user was doing, ensuring a responsive user experience on performance-constrained devices. While beneficial for users, this focus meant that as soon as an app went into the background, iOS suspended it. However, with more powerful and energy-efficient devices, iOS has changed. iOS now allows apps to perform limited background work while maintaining foreground responsiveness.
It is now possible for download and upload requests to continue when an app has been suspended or even terminated. These network requests are collectively known as background-transfers
. Support for background-transfers was introduced in iOS 7. While not a recent change, it remains a powerful tool.
In this post, we'll explore background-transfers
and build a working solution to use this functionality.
This post will gradually build up to a working example, but I get it, this is exciting stuff π, so if you are unable to contain that excitement, then head on over to the completed example and take a look at
BackgroundDownloadService
andBackgroundDownloadStore
to see how things end up.
Different types of sessions
When Apple introduced the URLSession
suite of classes, they addressed a number of the shortfalls that were present in a NSURLConnection
based networking stack. One particular pain point was that with an NSURLConnection
networking stack, there was no built-in way to group related types of requests together - each request in a group had to be individually configured. URLSession
changed this by moving that group configuration to a session
. A session represents a configurable object that handles the configuration and coordination of network requests. An app can have multiple sessions. Any request scheduled on that session would inherit that session's configuration. While useful for app developers to group requests, this session-level configuration also allowed iOS to offer more functionality as standard, enabling support for background-transfers on special background sessions.
Each session can be described as one of the following three favours:
- Default - supports all
URLSessionTask
subtypes for making a network request. These network requests can only be performed with the app in the foreground. - Ephemeral - similar to
default
but does not persist caches, cookies, or credentials to disk. - Background - supports all
URLSessionTask
subtypes for making a network request.URLSessionDataTask
can only be performed with the app in the foreground whereasURLSessionDownloadTask
andURLSessionUploadTask
can be performed with the app in the foreground, suspended or terminated.
Each session has its use case; let's explore how we can use a background session to enable our uploads and downloads to continue when the user leaves the app.
So how do background-transfers work? π΅οΈ
When scheduling a transfer on a background session, that transfer is passed to the nsurlsessiond
daemon to be processed. As nsurlsessiond
lives outside of the lifecycle of any given app, any transfer scheduled on nsurlsessiond
will continue even if the app that the transfer belongs to is suspended/terminated. Once a background-transfer is complete, if the app has been suspended/terminated, iOS will wake the app in the background and allow the app to perform any post-transfer processing (within a limited time frame); if the app is in the foreground, control will be passed back to the app as if the transfer has been scheduled on a default
or ephemeral
session (without the limited time frame for post-transfer processing).
You might be thinking:
"That sounds pretty amazing! Why isn't this the default behaviour for transfers?"
Well, there are a few reasons why this isn't the default behaviour:
- Resource Management - excessive background processing can drain battery life and consume unexpected bandwidth; Apple wants to ensure that as app developers, we use it responsibly and only where it's actually adding value.
- Programming Complexity - implementing background-transfers requires forgoing the convenient closure-based methods of
URLSession
and instead requires conforming toURLSessionDownloadDelegate
to receive updates about the transfers, adding complexity when compared to foreground-only sessions.
Setting up the project
The app must be granted additional permissions to enable background-transfers:
- Open the
Signing & Capabilities
tab in the target. - Add the
Background Modes
capability. - In the newly added
Background Modes
section, select theBackground fetch
andBackground processing
checkboxes.
After completing these steps, your settings should look like this:
With the project correctly configured, it is time to start coding.
This post will focus on downloading using a background session as it's much easier to find services that allow content to be downloaded than services that allow content to be uploaded. To see the complete working example, visit the repo and clone the project.
Let's get downloading
Our background-transfer layer has five primary responsibilities:
- Configuring a background session.
- Scheduling the download request.
- Responding to download updates.
- Processing any completed downloads by moving files to a permanent location on the file system or reporting an error.
- Recovering from a terminated/suspended state to process completed downloads.
And needs to be able to handle a download completing with the app in the following states:
- Foregrounded - when the app is open, the app needs to behave as if the session used the
default
configuration. - Suspended - when the app has been suspended, the app needs to be woken up to finish the transfer.
- Terminated - when iOS has terminated the app, the app needs to be relaunched to finish the transfer.
Together, these responsibilities and states produce the following class structure and interactions:
BackgroundDownloadService
sets up the background session, schedules downloads, and responds to download updates.BackgroundDownloadStore
manages the metadata for each download, ensuring data persistence beyond app launches.AppDelegate
informsBackgroundDownloadService
when the app has been woken/relaunched to complete background downloads.URLSession
informsBackgroundDownloadService
of any updates to in-progress downloads via theURLSessionDownloadDelegate
.
Don't worry if some parts of this diagram don't make sense; we will explore each part in turn as we build our solution.
Three pieces of metadata are needed to perform a download:
- Remote URL - the URL of the content to be downloaded.
- File path URL - the URL of where the downloaded content should be stored on the local file system.
- Completion handler - the closure to be called when the download is complete.
Let's take these three pieces of metadata and start implementing BackgroundDownloadStore
, starting with adding the functionality to store that metadata:
typealias BackgroundDownloadCompletion = (_ result: Result<URL, Error>) -> ()
class BackgroundDownloadStore {
//1
private var inMemoryStore = [String: BackgroundDownloadCompletion]()
// 2
private let persistentStore = UserDefaults.standard
// MARK: - Store
// 3
func storeMetadata(from fromURL: URL,
to toURL: URL,
completionHandler: @escaping BackgroundDownloadCompletion) {
inMemoryStore[fromURL.absoluteString] = completionHandler
persistentStore.set(toURL, forKey: fromURL.absoluteString)
}
}
Here's what we did:
- Background session downloads don't support the completion-handler methods on
URLSession
; instead any download progress updates are provided viaURLSessionDownloadDelegate
however a closure based interface is perfect for downloading content where the caller is really only interested in either having the downloaded content or an error. So, to recreate that convenient closure-based interface,BackgroundDownloadService
takes a closure when a download is requested and passes it ontoBackgroundDownloadStore
to store it in theinMemoryStore
dictionary. As the outcome of a download can either succeed or fail, theResult
type can represent both outcomes in one type. As the closure here is a communication path between the caller and callee, when the app is terminated, the memory holding the caller and callee will be freed, so the storage of these closures is only in-memory. - As a download may complete while the app is terminated, details of where the downloaded content should end up on the local file system must be persisted outside of memory.
UserDefaults
is a simple and effective means of persisting that information. - The method to store a download's metadata into
inMemoryStore
andpersistentStore
usingfromURL
as the key.
Storing metadata is all good, but that metadata needs to be retrieved:
class BackgroundDownloadStore {
// Omitted methods and properties
// MARK: - Retrieve
// 1
func retrieveMetadata(for forURL: URL) -> (URL?, BackgroundDownloadCompletion?) {
let key = forURL.absoluteString
let toURL = persistentStore.url(forKey: key)
let metaDataCompletionHandler = inMemoryStore[key]
return (toURL, metaDataCompletionHandler)
}
}
Here's what we did:
- Any metadata associated with
forURL
is extracted frompersistentStore
andinMemoryStore
. As there is no guarantee that the metadata associated withforURL
was ever stored (or in the case of the closures stored ininMemoryStore
, there is no guarantee the closure is still in memory), this method returns that metadata as a tuple with two optional values.
Finally, once a download has been completed, we need a way to remove that download's metadata from the store:
class BackgroundDownloadStore {
// Omitted methods and properties
// MARK: - Remove
// 1
func removeMetadata(for forURL: URL) {
let key = forURL.absoluteString
inMemoryStore[key] = nil
persistentStore.removeObject(forKey: key)
}
}
Here's what we did:
- Any metadata associated with
forURL
is deleted frompersistentStore
andinMemoryStore
.
That's all we need to do to store the metadata associated with a download; let's move on to configuring a background session and making a background-transfer.
Before looking at BackgroundDownloadService
, let's take a short detour and look at a supporting type that will used in BackgroundDownloadService
:
enum BackgroundDownloadServiceError: Error {
case missingInstructionsError
case fileSystemError(_ underlyingError: Error)
case networkError(_ underlyingError: Error?)
case unexpectedResponseError
case unexpectedStatusCode
}
BackgroundDownloadServiceError
is an enum that conforms to the Error
protocol and will be used to provide details about the unhappy download paths through BackgroundDownloadService
.
Detour over πΊοΈ.
Let's build the skeleton of BackgroundDownloadService
:
class BackgroundDownloadService: NSObject { // 1
// 2
private var session: URLSession!
// MARK: - Singleton
// 3
static let shared = BackgroundDownloadService()
// 4
override init() {
super.init()
configureSession()
}
private func configureSession() {
// 5
let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session")
// 6
configuration.sessionSendsLaunchEvents = true
let session = URLSession(configuration: configuration,
delegate: self,
delegateQueue: nil)
self.session = session
}
}
Let's look at what we did above:
- As mentioned earlier, background session downloads don't support the completion-handler methods on
URLSession
; InsteadBackgroundDownloadService
will need to become the delegate to the backgroundURLSession
session by implementing theURLSessionDownloadDelegate
. The need to conform toURLSessionDownloadDelegate
is whyBackgroundDownloadService
is a subclass ofNSObject
. - There exists a
cyclic dependency
betweenBackgroundDownloadService
andURLSession
whereBackgroundDownloadService
is responsible for not only creating theURLSession
instance but also being itsdelegate
. However, thedelegate
ofURLSession
can only be set at initialisation of theURLSession
. Both these conditions cannot be satisfied. To break the cyclic dependency betweenBackgroundDownloadService
andURLSession
, thesession
property is set afterBackgroundDownloadService
is initialised, and to avoid having an optional property,session
is implicitly unwrapped. BackgroundDownloadService
is singleton to ensure that any background-transfer updates are funnelled into the same type that holds the metadata of that transfer.init()
is private to ensure that another instance ofBackgroundDownloadService
cannot be created.- A session's configuration is defined through a
URLSessionConfiguration
instance, which offers numerous configuration options. EveryURLSessionConfiguration
for a background session requires a unique identifier within that app's lifecycle. If an existingURLSession
already has that identifier, iOS returns the previously createdURLSession
instance. The session identifier plays a vital role in ensuring that a download can survive app termination - for any download update, iOS will attempt to find aURLSession
instance with the same identifier that the download was kicked off on to inform of the update; that foundURLSession
instance does not need to be the same instance that was used to kick off the download. To ensure the app always has aURLSession
that can pick up any download updates,session
is consistently configured with the same identifier. sessionSendsLaunchEvents
tells iOS that any background-transfers scheduled on this session can launch the app if it is suspended/terminated.
So far, we have configured a session for performing background-transfers but can't yet download anythign; let's change that in:
class BackgroundDownloadService: NSObject {
// Omitted other properties
// 1
private let store = BackgroundDownloadStore()
// Omitted other methods
// MARK: - Download
func download(from remoteURL: URL,
saveDownloadTo localURL: URL,
completionHandler: @escaping ((_ result: Result<URL, BackgroundDownloadServiceError>) -> ())) {
os_log(.info, "Scheduling to download: %{public}@", remoteURL.absoluteString)
// 2
store.storeMetadata(from: remoteURL,
to: localURL,
completionHandler: completionHandler)
// 3
let task = session.downloadTask(with: remoteURL)
task.resume()
}
}
Here's what we did:
- A
BackgroundDownloadStore
instance is stored as a property to ensure access to a download's metadata. - The metadata associated with the current download request is added to the store.
- A
URLSessionDownloadTask
instance is created using the background session, and the download request is kicked off.
To better see the downloads happening in the background, you may wish to add a slight delay before the download starts, making it easier to put the app into the background:
task.earliestBeginDate = Date().addingTimeInterval(5)
Now that BackgroundDownloadService
can kick off a download, we need to add in the code to handle the happy-path when a download completes by implementing urlSession(_:downloadTask:didFinishDownloadingTo)
from URLSessionDownloadDelegate
:
extension BackgroundDownloadService: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// 1
guard let fromURL = downloadTask.originalRequest?.url else {
os_log(.error, "Unexpected nil URL")
// Unable to call the closure here as fromURL is the key to retrieving this download's closure
return
}
// 2
defer {
store.removeMetadata(for: fromURL)
}
let fromURLAsString = fromURL.absoluteString
os_log(.info, "Download request completed for: %{public}@", fromURLAsString)
// 3
let (toURL, completionHandler) = store.retrieveMetadata(for: fromURL)
// 4
guard let toURL else {
os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString)
completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError))
return
}
os_log(.info, "Download successful for: %{public}@", fromURLAsString)
// 5
do {
try FileManager.default.moveItem(at: location,
to: toURL)
// 6
completionHandler?(.success(toURL))
} catch {
completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error)))
}
}
}
This delegate method can be called when the app is foregrounded, suspended, or terminated, so all three states need to be handled in the same method:
- In the
download
method, theremoteURL
was used as the key for storing a download's metadata because that value is present when a download completes in theURLSessionDownloadTask
instance. AURLSessionDownloadTask
will automatically follow redirect instructions, so it is essential to ensure that the correct URL is used to connect the completed download with its metadata - specifically, the original URL stored in theoriginalRequest
property. IforiginalRequest
is nil, the method exits as no further action can be taken. - As there are multiple paths through
urlSession(_:downloadTask:didFinishDownloadingTo)
, adefer
block is used to clean up the download by deleting its metadata on any exit. - The downloads associated metadata is retrieved from the store.
- If
toURL
is nil, the download cannot be processed because there is no known location to move the downloaded content to. As a result, the method exits with no further action taken. - When
URLSessionDownloadTask
completes a download, it will store that downloaded content in a temporary location and allow us to move that downloaded file to a permanent location before iOS deletes it. UsingFileManager
, the downloaded content is moved from its temporary location to its permanent location. If the move is successful, the completion handler is called with the file's new location; if the move fails, the completion handler is called with an error. - If the app has been relaunched from a terminated state, any closure associated with this download is no longer available, so there is no enforcement that
completionHandler
must be non-nil.
That's the happy path completed; let's add in the unhappy-path.
The unhappy-path comes in two forms: server-side and client-side errors. Let's start with the server-side errors by updating urlSession(_:downloadTask:didFinishDownloadingTo:)
:
extension BackgroundDownloadService: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
guard let fromURL = downloadTask.originalRequest?.url else {
os_log(.error, "Unexpected nil URL")
// Unable to call the closure here as fromURL is the key to retrieving this download's closure
return
}
defer {
store.removeMetadata(for: fromURL)
}
let fromURLAsString = fromURL.absoluteString
os_log(.info, "Download request completed for: %{public}@", fromURLAsString)
let (toURL, completionHandler) = store.retrieveMetadata(for: fromURL)
guard let toURL else {
os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString)
completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError))
return
}
// 1
guard let response = downloadTask.response as? HTTPURLResponse,
response.statusCode == 200 else {
os_log(.error, "Unexpected response for: %{public}@", fromURLAsString)
completionHandler?(.failure(BackgroundDownloadError.serverError(downloadTask.response)))
return
}
os_log(.info, "Download successful for: %{public}@", fromURLAsString)
do {
try FileManager.default.moveItem(at: location,
to: toURL)
completionHandler?(.success(toURL))
} catch {
completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error)))
}
}
}
Here's what we changed:
- To determine if a download was successful, a 200 HTTP status code is expected. Here, the response is checked. If the status code is anything other than a 200, then
completionHandler
is triggered withserverError
.
If your server returns other status codes that should be treated as success, then it is simple enough to add those by refactoring the
guard
to accommodate those additional status codes.
Now that server-side errors are handled, let's take care of any client-side errors:
extension BackgroundDownloadService: URLSessionDownloadDelegate {
// Omitted other methods
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?) {
// 1
guard let error = error else {
return
}
// 2
guard let fromURL = task.originalRequest?.url else {
os_log(.error, "Unexpected nil URL")
return
}
let fromURLAaString = fromURL.absoluteString
os_log(.info, "Download failed for: %{public}@", fromURLAaString)
// 3
let (_, completionHandler) = store.retrieveMetadata(for: fromURL)
completionHandler?(.failure(BackgroundDownloadError.clientError(error)))
// 4
store.removeMetadata(for: fromURL)
}
}
Here's what we did:
- This delegate method is called when any network request completes, meaning it will be triggered for both successful and unsuccessful network requests. If a network request fails,
error
will contain a value. To exclude successful downloads from the unhappy-path,error
is checked fornil
. Iferror
isnil
, the method exits. - Like in
urlSession(_:downloadTask:didFinishDownloadingTo:)
, the remote URL is extracted from the completedURLSessionTask
instance asfromURL
. IforiginalRequest
isnil
, the method exits as no further action can be taken. - The closure associated with this download is retrieved from
store
and triggered withnetworkError
. - The now complete downloads metadata is deleted.
If you've been putting together the code snippets, you should be able to download files in the foreground; however, as soon as the app is put into the background, a stream of errors will begin to appear in the Xcode console, similar to:
So far, BackgroundDownloadService
only works when the app is in the foreground. Let's extend it to support downloads when the app enters a suspended/terminated state.
Keeping downloads going
When an app is in a suspended/terminated state and a download completes, iOS will wake the app up (in the background) by calling application(_:handleEventsForBackgroundURLSession:completionHandler:)
on the AppDelegate
. Regardless of how many downloads are in progress, application(_:handleEventsForBackgroundURLSession:completionHandler:)
is only called once - when all downloads have been completed. If the user foregrounds the app before the above method is called, urlSession(_:downloadTask:didFinishDownloadingTo:)
is called for each completed download.
class AppDelegate: UIResponder, UIApplicationDelegate {
// Omitted properties and methods
// 1
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
// 2
BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler
}
}
Here's what we did:
- iOS provides the app with the identifier of the session that the download was scheduled on and a completion handler to be called once all post-download work is complete. As the background session always uses the same identifier,
identifier
can ignored. completionHandler
is passed toBackgroundDownloadService
so that it can be called once all post-download work is complete.
As the completionHandler
closure is passed to the shared BackgroundDownloadService
instance, a new property needs to be added to that class:
class BackgroundDownloadService: NSObject {
var backgroundCompletionHandler: (() -> Void)?
// Omitted properties and methods
}
When all the downloads are complete, urlSession(_:downloadTask:didFinishDownloadingTo:)
will be called for each download. After all the calls to urlSession(_:downloadTask:didFinishDownloadingTo:)
have been made, a special background-transfer only delegate call is made:
extension BackgroundDownloadService: URLSessionDownloadDelegate {
// Omitted methods
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// 1
DispatchQueue.main.async {
self.backgroundCompletionHandler?()
self.backgroundCompletionHandler = nil
}
}
}
Here's what we did:
- Triggering
backgroundCompletionHandler
instructs iOS to take a new snapshot of the app's UI for the app switcher preview. As such,backgroundCompletionHandler
must be called from the main queue. Failure to callbackgroundCompletionHandler
will result in iOS treating the app as a bad citizen, reducing future background processing opportunities.
While it's possible to make additional network requests with the app running in the background, they are subject to a rate limiter to prevent abuse of background sessions - this rate limiter delays the start of those network requests. With each separate network request, the delay increases - this delay is reset when the user brings the app to the foreground or if your app does not make any additional network requests during the delay.
And that's all the changes required to continue network requests when the app is suspended/terminated.
Living in a multi-threaded world
Using the code we've written so far on a single thread works fine, but using it simultaneously from multiple threads will eventually cause the app to crash. When accessing BackgroundDownloadStore
from multiple threads, one thread may be modifying the internal mutable state of BackgroundDownloadStore
while another thread is also modifying it. As BackgroundDownloadStore
doesn't lock its state during a modification operation, simultaneous modification operations will eventually result in data corruption and the app crashing.
Thoughtful use of a DispatchQueue
can prevent these issues. Our DispatchQueue
instance will allow concurrent read operations but serial write operations. If a write operation is being processed, any new operation (write or read) added to the queue will be blocked until the in-progress write operation completes. Using a DispatchQueue
instance will ensure that only one write operation runs at a time, preventing data corruption and stopping the app crashes we are currently experiencing.
Let's add state access controls into BackgroundDownloadStore
to protect its shared mutable state:
class BackgroundDownloadStore {
// 1
private let queue = DispatchQueue(label: "com.williamboles.background.download.service",
qos: .userInitiated,
attributes: .concurrent)
// Omitted properties and methods
}
Here's what we did:
- As the downloads are triggered via user action, the queue priority is set to the higher end of the scale by setting its
qos
touserInitiated
; this will ensure that operations on this queue are given greater resources when compared to lower priority operations on other queues. By default, an instance ofDispatchQueue
is a serial queue; this default is overridden by creating a concurrent queue using theconcurrent
attribute.
With the above queue
property, let's update the rest of BackgroundDownloadStore
, starting with the storeMetadata(from:to:completionHandler:)
method:
class BackgroundDownloadStore {
// Omitted properties and methods
func storeMetadata(from fromURL: URL,
to toURL: URL,
completionHandler: @escaping BackgroundDownloadCompletion) {
// 1
queue.async(flags: .barrier) { [weak self] in
self?.inMemoryStore[fromURL.absoluteString] = completionHandler
self?.persistentStore.set(toURL, forKey: fromURL.absoluteString)
}
}
}
Here's what we did:
- Using our
DispatchQueue
instance, we asynchronously schedule the metadata storage to happen on that queue. Adding the operation asynchronously ensures that the calling thread won't be blocked. As this is a write operation, thebarrier
flag is set when adding the operation toqueue
; this flag will switch the dispatch queue from concurrent processing to serial processing for this operation.
The changes needed to protect removeMetadata(for:)
are almost the same as those made for storeMetadata(from:to:completionHandler:)
:
class BackgroundDownloadStore {
// Omitted properties and methods
func removeMetadata(for forURL: URL) {
queue.async(flags: .barrier) { [weak self] in
let key = forURL.absoluteString
self?.inMemoryStore[key] = nil
self?.persistentStore.removeObject(forKey: key)
}
}
}
retrieveMetadata(for:)
requires slightly more change as it needs to return a download's metadata without blocking the calling thread. The best way to achieve this is to return its result via a closure:
class BackgroundDownloadStore {
// Omitted properties and methods
func retrieveMetadata(for forURL: URL,
completionHandler: @escaping ((URL?, BackgroundDownloadCompletion?) -> ())) {
// 1
return queue.async { [weak self] in
let key = forURL.absoluteString
let toURL = self?.persistentStore.url(forKey: key)
let metaDataCompletionHandler = self?.inMemoryStore[key]
completionHandler(toURL, metaDataCompletionHandler)
}
}
}
Here's what we did:
- The retrieval of the metadata associated with
forURL
is asynchronously scheduled onqueue
. Adding the retrieval operation asynchronously ensures that the calling thread won't be blocked. As this is a read operation, the operation can run concurrently with any other read operations, so unlike instoreMetadata(from:to:completionHandler:)
andremoveMetadata(for:)
the operation is not flagged as abarrier
operation.
We could have added the read operation as a synchronous operation to the dispatch queue using
queue.sync
as we know thatBackgroundDownloadService
will call this method from a background thread and so avoid having to add a closure to return the result of the retrieval. However, I felt that adding a blocking thread call here would lead to a nasty surprise ifretrieveMetadata(for:)
was ever used on the main thread.
With the changes to the interface of BackgroundDownloadStore
to ensure thread safety, BackgroundDownloadService
needs to be updated:
extension BackgroundDownloadService: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
guard let fromURL = downloadTask.originalRequest?.url else {
os_log(.error, "Unexpected nil URL")
// Unable to call the closure here as fromURL is the key to retrieving this download's closure
return
}
let fromURLAsString = fromURL.absoluteString
os_log(.info, "Download request completed for: %{public}@", fromURLAsString)
// 1
let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent)
try? FileManager.default.moveItem(at: location,
to: tempLocation)
// 2
store.retrieveMetadata(for: fromURL) { [weak self] toURL, completionHandler in
// 3
defer {
self?.store.removeMetadata(for: fromURL)
}
guard let toURL else {
os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString)
completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError))
return
}
guard let response = downloadTask.response as? HTTPURLResponse,
response.statusCode == 200 else {
os_log(.error, "Unexpected response for: %{public}@", fromURLAsString)
completionHandler?(.failure(BackgroundDownloadError.serverError(downloadTask.response)))
return
}
os_log(.info, "Download successful for: %{public}@", fromURLAsString)
do {
// 4
try FileManager.default.moveItem(at: tempLocation,
to: toURL)
completionHandler?(.success(toURL))
} catch {
completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error)))
}
}
}
// Omitted other methods
}
Here's what we changed:
- As mentioned before, iOS only guarantees until the end of this method that the downloaded content will be found at
location
. AsretrieveMetadata(for:completionHandler:)
is an asynchronous method, waiting on the completion handler being called with the download's metadata will mean thaturlSession(_:downloadTask:didFinishDownloadingTo)
will have exited and the downloaded content will have been deleted. So before attempting to fetch the download's metadata, the downloaded content is moved from its current temporary location to a different temporary location thatBackgroundDownloadService
controls. Moving to a temporary location ensures that the downloaded content will still be accessible when the completion handler calls back with the metadata. - Using the closure-based interface for retrieving the completed download's metadata.
- Moved the
defer
block into the closure so that it is associated with the closure block rather than the method itself. - Using the
tempLocation
URL as the source URL for the move operation.
And finally, let's use the same pattern with urlSession(_:task:didCompleteWithError)
extension BackgroundDownloadService: URLSessionDownloadDelegate {
// Omitted other methods
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?) {
guard let error = error else {
return
}
guard let fromURL = task.originalRequest?.url else {
os_log(.error, "Unexpected nil URL")
return
}
let fromURLAaString = fromURL.absoluteString
os_log(.info, "Download failed for: %{public}@", fromURLAaString)
// 1
store.retrieveMetadata(for: fromURL) { [weak self] _, completionHandler in
completionHandler?(.failure(BackgroundDownloadError.clientError(error)))
self?.store.removeMetadata(for: fromURL)
}
}
}
Here's what we did:
- Using the closure-based interface for retrieving the completed download's metadata.
If you run the project now in a multi-threaded app, downloads should complete without app crashes due to data corruption.
And that's all the code needed to support background-transfers π.
However, there is one gotcha π§ when testing the above code. If an app is manually force-quit, iOS interprets that action as the user explicitly saying that they want to stop all activity associated with that app. As a result, all scheduled background-transfers are cancelled, preventing testing of the restoration functionality. To overcome this gotcha, app termination needs to happen programmatically using exit(0)
, which shuts down the app without user intervention:
class AppDelegate: UIResponder, UIApplicationDelegate {
// Omitted properties and methods
func applicationDidEnterBackground(_ application: UIApplication) {
//Exit the app to test restoring the app from a terminated state. Comment out to test restoring the app from a suspended state.
DispatchQueue.main.asyncAfter(deadline: .now()) {
os_log(.info, "App is about to quit")
exit(0)
}
}
}
Remember to remove this in production π.
This time we are really at the end π - congratulations on making it here.
Downloads keep going π€―
Background-transfers provide a powerful tool to meet your users' ever-increasing expectations about what your app can do for them even when your app isn't in the foreground. I believe that the enhanced user experience we gain from supporting background-transfers outweighs any additional complexity supporting background-transfers adds. As we seen with careful planning, that complexity can be isolated from the rest of the app so we end up providing functionality without getting in the way of features.
To see the complete working example, visit the repo and clone the project.
Running the example project
I've used TheCatAPI in the example project to populate the app with images. TheCatAPI
has a great JSON-based API and an extensive library of lovely, freely available cat photos. While free to use, TheCatAPI
requires you to register to get an x-api-key
token to access those cats. You must add this token as the APIKey
property in NetworkService
for any network requests to work.