Keeping things going when the user leaves
In the past iOS was always about living in the moment - totally focused on whatever the user was attempting to do. This approach was good news for users as it allowed for a very responsive user experience on performance constrained devices however as app developers it limited what we could do because as soon as an app went into the background iOS suspended it. As devices have become more powerful and energy-efficient, iOS has eased the "living in the moment" restrictions while maintaining responsiveness.
One way that iOS has eased restrictions is allowing apps to start/continue download and upload requests in the background - collectively known as background-transfers
. Support for background-transfers was first introduced in iOS 7, so it's not a recent change, but it's such a powerful tool that I wanted to explore it further and especially show a solution for recovering from a terminated state.
Different types of sessions
When Apple introduced the URLSession
suite of classes, they addressed a long standing complaint - how to configure the networking stack to handle various types of network requests that an app has to make. Rather than having one networking stack like with NSURLConnection
, NSURLSession
instead allowed for multiple independent networking stacks (or sessions as they are known). The configurations of these independent sessions are controlled through URLSessionConfiguration
. The main session configurations are:
Default
- allowing for all types of network requests in the foreground.Ephemeral
- similar todefault
but more forgetful (doesn’t write caches, cookies, or credentials to disk).Background
- allowing for downloading and uploading content even when the app isn't running.
Of course, it's possible to further differentiate sessions within those configurations by adjusting the properties on each session.
At the time URLSession
was introduced I was building a very media-heavy app, and the promise of being able to continue to upload or download content when the app wasn't running by using a background session was very appealing.
Peeking under the hood 🕵️
When scheduling a background-transfer on a background session, that transfer is passed to the nsurlsessiond
daemon, which runs as a separate process from the app, to be actioned. As this daemon lives outside of the lifecycle of the app if the app is suspended or killed the transfer will continue unaffected - this is the main difference from scheduling an URLSessionDownloadTask
or URLSessionUploadTask
request on a default
or ephemeral
session where the request is tied to the lifecycle of the app. Once a background-transfer is complete, if the app has been suspended/terminated iOS will wake it up (in the background) and allow the app to perform any post-transfer work (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 default
or ephemeral
session (without a limited time frame for post-transfer processing).
Important to note here that you can schedule
URLSessionDataTask
requests on a background session but they will only be acted on when the app is in the foreground.
You're probably thinking:
"That sounds pretty amazing! Why isn't this the default behaviour for transfers?"
Well, there are a few reasons:
-
The more background processing that the device has to support the quicker the battery will drain and the more bandwidth that will be consumed. So while Apple understands that background processing is needed, it wants to ensure that as app developers we use it responsibly and only where it's actually adding value.
-
From a developer-POV supporting background-transfers requires a bit more programming than a foreground-only session. Rather than the simple closure based API that foreground-only sessions support, to use background sessions the developer needs to conform to various protocols. This additional programming while not hugely taxing (as we shall see shortly) does require each developer to know more about how
URLSession
works before they can make a network request. So in true Apple fashion, they have opted to not only keep it simple for the users but also for us developers.
Now that the hood has been peeked under, let's get back to how to use background-transfers to provide a better experience for our users.
Scenarios
Any background-transfer solution should be able to handle 3 different transfer scenarios:
Foreground
- when the app is open, the transfer should behave the same way as if the session was using thedefault
configuration.Suspended/Backgrounded
- when the app is in a suspended state and the asset is downloaded, the app should be able to be woken up to finish the transfer.Terminated
- when the app has been terminated by iOS, the app should be able to be relaunched to finish that transfer.
In the rest of this post, we will build an image download system that will look to handle the 3 scenarios described above. This example will populate a collectionview using data retrieved from Imgur
with all image retrieval happening on a background session.
This post will focus solely on downloading using a background session as it's much easier to find services that allow us to retrieve content from their system than services that allow us to upload content.
This post will gradually build up to a working example however if you're on a tight deadline and/or there is murderous look creeping into your manager's eyes 😡, then head on over to the completed example and take a look at BackgroundDownloader
, BackgroundDownloaderContext
, BackgroundDownloadItem
and AppDelegate
to see how things end up - in order to run this you will need to follow the instructions below.
Let's get downloading
To support background-transfers we need to grant permission for Background Modes
. This is done by opening the Capabilities
tab in the target and switching the Background Modes
toggle to ON
.
As multiple viewcontrollers within the project could be retrieving images I decided to handle these download requests within their own class - BackgroundDownloader
. BackgroundDownloader
will be responsible for configuring our background session, scheduling download requests against that session and responding to any delegate callbacks.
Lets build the skeleton of the BackgroundDownloader
class:
class BackgroundDownloader: NSObject {
private var session: URLSession!
// MARK: - Singleton
static let shared = BackgroundDownloader()
// MARK: - Init
private override init() {
super.init()
let configuration = URLSessionConfiguration.background(withIdentifier: "background.download.session")
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
}
}
In the above code snippet, we create an instance of an URLSessionConfiguration
instance and URLSession
that will handle any background tasks. configuration
is initialised using the background
convenience init'er to allow any URLSession
instances to support background-transfers. Each URLSessionConfiguration
instances needs to have an identifier. This identifier plays a critical role in allowing a background-transfer that started on one instance of URLSession
to be completed on another. This happens when the app has been terminated and iOS wakes the terminated app up to finish the transfer - configuration
uses a static string as an identifier so it can be recreated (we will see this in action later). It's important to note here that each session needs to have a unique identifier within that app's execution lifecycle. As BackgroundDownloader
is a singleton, only one session will have this identifier.
An interesting difference between background sessions and the other session types is that with background sessions we can't use the completionHandler
methods when creating a URLSessionDownloadTask
task (doing so won't result in the compiler throwing an error, but the app will throw an exception at run time). Instead of using the closure approach, BackgroundDownloader
will need to implement the URLSessionDownloadDelegate
protocol (and eventually URLSessionDelegate
) - this need to implement these delegates is the reason that BackgroundDownloader
is a subclass of NSObject
.
The more eagle-eyed among you will have noticed that the session
property is implicitly unwrapped. I usually try and avoid implicitly unwrapped properties but as session
is a private property and is set in the init'er I felt making it implicitly unwrapped resulted in more readable code with very little danger of a crash. The reason that session
can't be a let
is that when creating the session, the BackgroundDownloader
instance sets itself as the session's delegate so the BackgroundDownloader
has to exist which means that the initialising of the session
has to happen after the super.init()
call is made.
So far, we have configured a session for performing background-transfers but don't yet have the ability to download anything so let's add that. A download needs 3 pieces of information:
Remote URL
- URL of the asset to be downloaded.File path URL
- URL of where the downloaded asset should be moved to on the local file system.Completion handler
- a closure to be called when the download is complete.
Rather than store these pieces of information about each download in separate arrays, we can create a model class to group these related pieces of information:
typealias ForegroundDownloadCompletionHandler = ((_ result: DataRequestResult) -> Void)
class DownloadItem {
let remoteURL: URL
let filePathURL: URL
var foregroundCompletionHandler: ForegroundDownloadCompletionHandler?
// MARK: - Init
init(remoteURL: URL, filePathURL: URL) {
self.remoteURL = remoteURL
self.filePathURL = filePathURL
}
}
An instance of DownloadItem
will be created when a new download request is made against BackgroundDownloader
. This DownloadItem
instance can then be stored in a dictionary to be used once the download is complete:
class BackgroundDownloader: NSObject {
// Omitted properties
private var downloadItems: [URL: DownloadItem] = [:]
// Omitted methods
func download(remoteURL: URL, filePathURL: URL, completionHandler: @escaping ForegroundDownloadCompletionHandler) {
print("Scheduling download of: \(remoteURL)")
let downloadItem = DownloadItem(remoteURL: remoteURL, filePathURL: filePathURL)
downloadItem.foregroundCompletionHandler = completionHandler
downloadItems[remoteURL] = downloadItem
let task = session.downloadTask(with: remoteURL)
task.resume()
}
}
It's not unusual to have a scenario where before a download request is finished another request for that same download is made - think of a user scrolling a tableview up and down. There are many articles written on how to prevent these additional (unneeded) download requests so I won't complicate this example about background-transfers by implementing any of them here. However if you are really interested on how to prevent these additional download requests, check out my coalescing closures article on this subject which shows one possbile approach.
The URLSessionDownloadDelegate
protocol has been mentioned a few times now so lets see how it's implemented:
extension BackgroundDownloader: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
guard let originalRequestURL = downloadTask.originalRequest?.url, let downloadItem = downloadItems[originalRequestURL] else {
return
}
print("Downloaded: \(downloadItem.remoteURL)")
do {
try FileManager.default.moveItem(at: location, to: downloadItem.filePathURL)
downloadItem.foregroundCompletionHandler?(.success(downloadItem.filePathURL))
} catch {
downloadItem.foregroundCompletionHandler?(.failure(APIError.invalidData))
}
downloadItems[originalRequestURL] = nil
}
}
If the download has been a success, the asset is moved from its temporary location to its permanent location on the file system. Then the completionHandler
is triggered with the success case of the DataRequestResult
enum containing the permanent location URL.
If the download has failed the completionHandler
is triggered with the failure case containing an error.
(This method is deliberately kept as simple as possible but you can easily imagine how it and DownloadItem
could be extended to e.g. update Core Data or perform image manipulation)
Finally we have been using DataRequestResult
enum as a generic result enum so lets look at as well:
enum DataRequestResult<T> {
case success(T)
case failure(Error)
}
And that's all that's required for performing foreground transfers using a background configuration.
If you want to see this version of
BackgroundDownloader
running, checkout out theforeground_only_transfers
branch on the example project - to run this you will need to follow the instructions below.
Working behind the users back
If you run the app in the foreground you will notice that everything works as expected and the app is populated with downloaded assets 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 BackgroundDownloader
only works when the app is in the foreground, let's extend it to support downloads when the app enters a suspended/backgrounded state.
When an app is in a suspended/terminated state and a download is completed, iOS will wake the app up (in the background) by calling:
class AppDelegate: UIResponder, UIApplicationDelegate {
// Omitted properties and methods
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
//TODO: Handle processing downloads
}
}
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 those downloads that have been completed.
In the above method, iOS has provided the app with the identifier of the session that the task was scheduled on and also a completion-closure. As our background session always uses the same identifier, we don't need to do anything with identifier
however we do need to pass completionHandler
onto BackgroundDownloader
so that it can be triggered once all download processing has been completed:
class AppDelegate: UIResponder, UIApplicationDelegate {
// Omitted properties and methods
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
BackgroundDownloader.shared.backgroundCompletionHandler = completionHandler
}
}
As the completionHandler
closure is passed to the shared BackgroundDownloader
instance we need to add a new property to that class:
class BackgroundDownloader: NSObject {
var backgroundCompletionHandler: (() -> Void)?
// Omitted properties and methods
}
When all the downloads are are complete, just like before urlSession(_:downloadTask:didFinishDownloadingTo:)
is be called for each download to allow any post-download processing to occur. After all the calls to urlSession(_:downloadTask:didFinishDownloadingTo:)
have been made, a special background-transfer only call is made:
extension BackgroundDownloader: URLSessionDelegate {
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
self.backgroundCompletionHandler?()
self.backgroundCompletionHandler = nil
}
}
}
The above method's main job is to allow backgroundCompletionHandler
to be triggered. Triggering backgroundCompletionHandler
instructs iOS to take a new snapshot of the app's UI for the app switcher preview - as such it needs to be called from the main queue (urlSessionDidFinishEvents(forBackgroundURLSession:)
may be called from a background queue). It's important to ensure that your app does indeed call backgroundCompletionHandler
- failure to do so will result in iOS treating the app as a bad citizen app; reducing the opportunity for any background processing in the future and resulting in the error that we seen at the start of this section.
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/backgrounded.
If you want to see this version of
BackgroundDownloader
running, checkout out theforeground_and_suspended_transfers
branch on the example project - to run this you will need to follow the instructions below. In order to better see the downloads happening in the background you may want to add a slight delay before the download actually starts so as to allow you to more leisurely put the app into the background:task.earliestBeginDate = Date().addingTimeInterval(5)
Coming back from the dead 🧟
After an app has been terminated, all of the memory which was allocated to it is freed. This means that when iOS relaunches the app to complete a background-transfer we don't have the details in memory that we can use to determine how that background-transfer should be completed. Instead, these details need to be persisted in the file system between app executions. In our example, the only information that we really need to persist is filePathURL
(as you will see the remoteURL
is also persisted but this is primarily for informational purposes) so introducing something like Core Data
to handle this would be overkill. Instead, let's stick with KISS principles and take advantage of the Codable
protocol. By conforming DownloadItem
to Codeable
we can encode that DownloadItem
object into its Data
representation which can then be stored in User Defaults
. The only issue with this approach is that the foregroundCompletionHandler
property on DownloadItem
can't conform to Codable
, so the DownloadItem
implementation of Codable
will be slightly more involved than usual. Thankfully Codable
has an easy way to declare which properties should and shouldn't be encoded.
class DownloadItem: Codable {
// Properties omitted
private enum CodingKeys: String, CodingKey {
case remoteURL
case filePathURL
}
// Methods omitted
}
With these changes, the DownloadItem
class now has a dual nature:
- Freshly created.
- Restored from
User Defaults
.
This presents a problem with determining which class should be responsible for handling this dual nature - knowing how to save into, load from and clean up User Defaults
. I toyed with placing this responsibility in DownloadItem
but wanted to keep this class as simple as possible which ruled that approach out. Then I moved onto BackgroundDownloader
but again felt that it wasn't the correct class for handling this - I didn't want to expose that some DownloadItem
instances were freshly created while some had been restored from User Defaults
to BackgroundDownloader
whose primary responsibility centred on downloading rather than persistence. In the end, I drew inspiration from Core Data and decided to create a downloading context.
This context class needs to handle 3 different tasks:
- Loading.
- Saving.
- Deleting.
Lets start building this BackgroundDownloaderContext
class:
class BackgroundDownloaderContext {
private var inMemoryDownloadItems: [URL: DownloadItem] = [:]
private let userDefaults = UserDefaults.standard
}
Like before, a simple dictionary is being used as an in-memory store which will hold all active DownloadItem
instances.
When it comes to loading/retrieving an existing DownloadItem
instance, this can be from two possible stores:
- In-memory.
User Defaults
.
Again, we don't want to expose these details so let's add a load method to BackgroundDownloaderContext
that can load from either of the two possible stores:
class BackgroundDownloaderContext {
// Omitted properties
func loadDownloadItem(withURL url: URL) -> DownloadItem? {
if let downloadItem = inMemoryDownloadItems[url] {
return downloadItem
} else if let downloadItem = loadDownloadItemFromStorage(withURL: url) {
inMemoryDownloadItems[downloadItem.remoteURL] = downloadItem
return downloadItem
}
return nil
}
private func loadDownloadItemFromStorage(withURL url: URL) -> DownloadItem? {
guard let encodedData = userDefaults.object(forKey: url.path) as? Data else {
return nil
}
let downloadItem = try? JSONDecoder().decode(DownloadItem.self, from: encodedData)
return downloadItem
}
}
First, the in-memory store is checked for an existing DownloadItem
using the network request's URL as the key. If an existing DownloadItem
instance isn't found in the in-memory store, the User Defaults
store is checked. If an object is found in User Defaults
, an attempt is made to decode it from its raw Data
representation to a new DownloadItem
instance. As you will have spotted, decoding can potentially throw an exception, if an exception is thrown it will be silently ignored due to the try?
statement. I chose this approach as an error during decoding wouldn't be repairable, instead just returning nil
would suffice.
In order to get DownloadItem
instances into User Defaults
we need to save them:
class BackgroundDownloaderContext {
// Omitted properties and methods
func saveDownloadItem(_ downloadItem: DownloadItem) {
inMemoryDownloadItems[downloadItem.remoteURL] = downloadItem
let encodedData = try? JSONEncoder().encode(downloadItem)
userDefaults.set(encodedData, forKey: downloadItem.remoteURL.path)
userDefaults.synchronize()
}
}
In the above code snippet, the DownloadItem
instance is first placed into the in-memory store before being saved into User Defaults
as a Data
object.
Finally when a download is complete the in-memory and User Default
objects need to be cleaned away:
class BackgroundDownloaderContext {
// Omitted properties and methods
func deleteDownloadItem(_ downloadItem: DownloadItem) {
inMemoryDownloadItems[downloadItem.remoteURL] = nil
userDefaults.removeObject(forKey: downloadItem.remoteURL.path)
userDefaults.synchronize()
}
}
By implementing the BackgroundDownloaderContext
, the BackgroundDownloader
logic can be kept pretty much the same as before despite this significant introduction of new functionality. First we need to add a new property to hold an instance of BackgroundDownloaderContext
and remove the existing downloadItems
property
class BackgroundDownloader: NSObject {
private let context = BackgroundDownloaderContext()
// Omitted properties and methods
}
Now let's use that context when requesting a new download:
class BackgroundDownloader: NSObject {
// Omitted properties and methods
func download(remoteURL: URL, filePathURL: URL, completionHandler: @escaping ForegroundDownloadCompletionHandler) {
print("Scheduling download of: \(remoteURL)")
let downloadItem = DownloadItem(remoteURL: remoteURL, filePathURL: filePathURL)
downloadItem.foregroundCompletionHandler = completionHandler
context.saveDownloadItem(downloadItem)
let task = session.downloadTask(with: remoteURL)
task.resume()
}
}
Then when a download is completed, the DownloadItem
instance needs to be deleted from the context:
extension BackgroundDownloader: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
guard let originalRequestURL = downloadTask.originalRequest?.url, let downloadItem = context.loadDownloadItem(withURL: originalRequestURL) else {
return
}
print("Downloaded: \(downloadItem.remoteURL)")
do {
try FileManager.default.moveItem(at: location, to: downloadItem.filePathURL)
downloadItem.foregroundCompletionHandler?(.success(downloadItem.filePathURL))
} catch {
downloadItem.foregroundCompletionHandler?(.failure(APIError.invalidData))
}
context.deleteDownloadItem(downloadItem)
}
}
Again, here we have managed to avoid exposing to the BackgroundDownloader
if that downloadItem
was purely an in-memory object or if it also had a backing User Defaults
entry.
As with the suspended/backgrounded code changes, when the app is awoken from a terminated state the app-delegate is called:
class AppDelegate: UIResponder, UIApplicationDelegate {
// Omitted properties and methods
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
BackgroundDownloader.shared.backgroundCompletionHandler = completionHandler
}
}
This is actually the exact same code as in the suspended/backgrounded example above but I wanted to highlight that when the app is awoken from a terminated state, this method not only passes the completionHandler
along but also sets up the BackgroundDownloader
(by calling shared
) so that it can respond to the session delegate calls.
An interesting point around terminating the app is that if you manually force quit the app then iOS will take this as definite confirmation that you are finished with the app and so cancel any scheduled background-transfers. This presents a problem when it comes to testing how our solution handles termination. The easiest way I found to overcome this issue was to add a manual exit
call to cause the app to terminate without any user involvement:
class AppDelegate: UIResponder, UIApplicationDelegate {
// Omitted properties and methods
func applicationDidEnterBackground(_ application: UIApplication) {
//Exit app to test restoring app from a terminated state. Comment out to test restoring app from a suspended state.
DispatchQueue.main.asyncAfter(deadline: .now()) {
print("App is about to quit")
if let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first {
debugPrint("Gallery assets will be saved to: \(documentsPath)")
}
exit(0)
}
}
}
In the above code snippet, the file path of the directory that the downloaded assets will end up in is printed to the console and then the app terminates. With this file path, you can then navigate to that directory and see the folder fill up with downloaded assets.
Downloads keep going 🤯
With background sessions, we have a powerful weapon in ensuring that we can meet our user's wants without having to trap them in the app. As we seen above adding support for background sessions isn't too tricky and what extra complexity there is, can mostly be hidden from the wider app.
Congratulations on making it to the end 🎉.
Running the example project
In "Don't throw anything away with pausable downloads" I used Imgur as an online image database and I've used it again in this post to power the example project. 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.