How to bundle assets in an iOS app?

Picture this: you’re just dying to play a car racing game. After a quick scroll through the App Store you find the perfect one. It has cool graphics and some sweet retro cars to race. And it’s even free! You’re pumped, hit download and wait. Finally, you launch the game only to be bombarded with ads and a loading screen. This time for game assets…

Now, flip the script and think like the game developers. To make a game look top-notch, they needed high-res assets. To make an app download faster, the assets are often pulled out from the app and hosted as On-Demand Resources (ODRs). Until these are downloaded a loading screen is presented. The developers probably figured users wouldn’t mind waiting a minute longer… 

It’s easy to dismiss this as not your concern. But is that truly the case? Try substituting “game assets” with any large files your app needs to function, such as documents, videos, hi-fi audio files, RAW photos, or machine learning models. It seems like a lose-lose situation for everyone: users, developers, and Apple. But, could there be a way to turn this into a win? Let’s dive in and find out! 

Prefer listening over reading?

“Traditional” way of handling assets in iOS application.

The situation seems eerily similar to the one I described in the app analytics post. Dealing with all these challenges can feel like a real headache, especially when you’re juggling multiple games within one app. Take a casino app as an example. Some games can be real space hogs, taking up anything between 70 and 200+ MB. The thought of downloading a game this size, especially over a 3G connection, can be a bit intimidating. And with everyone expecting top-notch animations, videos, etc., there’s not much room to cut down on size.

You could think about embedding these files into the app itself. But then you’re looking at long installation times. Alternatively, maybe storing the files on a server and downloading them when needed could work. Since January 2024, Apple has updated the App Store Review Guidelines to allow “mini-games” and “mini-apps” that your app provides to exist outside the App Store. However, point 4.2 on minimal app functionality still remains. And for good reason! Hardly any App Store user wants an influx of applications that are just wrappers for web views. Moreover, it’s in our vital interest to make users keep coming back to play our games or watch our content. So… should we use web cache to store downloaded files? What if that cache eventually gets purged? Would users be happy to be forced to download e.g. their favourite game(s) again? And again?

Trying to keep Apple’s requirements and user expectations in balance does sometimes feel like a losing battle…

Life of a games developer. How to efficiently bundle assets in an iOS application?

What if there was another way?

Spoiler alert: there is! Starting with iOS 16, Apple has extended an “olive branch” to the developers through the Background Assets framework.

So, what does this framework entail? It provides a legitimate way to store app assets outside the App Store and to automatically download them when an app is installed, updated, or when the user’s device is idle. The definition includes virtually every large file the app needs to function, including game assets and complete games. The framework features a built-in download manager and a unique app extension. While it’s possible to start downloads the traditional way, when the app is active, the real advantage of the framework lies outside the app’s lifecycle. The system schedules downloading requested files and activates the extension to manage them. Consequently, most of the enqueued files should be ready for use when the app is launched. 

Moreover, certain files may be designated as Essentials. The framework will attempt to download these immediately after the app is installed, but the installation progress indicator will be adjusted by the time required to do so. More on the subject later.

It appears that Apple is presenting us, the developers, with a “trade offer”:

We can take advantage of the option to pre-download files, which don’t need to be uploaded to the App Store along with our app. In exchange, Apple gains reduced load on App Store servers and a degree of security, as the Background Assets framework likely scans the traffic passing through it. But the true winners are the users, being able to enjoy e.g. their favorite games without enduring long loading screens.

According to recent updates in the App Store Review Guidelines, the Background Assets framework can now be used to acquire complete games, plugins, “mini-apps”, etc. This wasn’t the case when it was introduced in iOS 16. At that time, it was necessary to extract the logic from a game, plugin, etc., and submit it for review along with the app. However, an On-Demand Resources (ODR) package, containing only that logic, was downloaded quickly due to its small size. e.g., in my experience with HTML games, the logic typically constitutes up to no more than 10% of the total game size. This is because game logic files, like JS, HTML, CSS, etc., are mainly plain text files, which can be compressed efficiently, further reducing their size. If the Background Assets framework did the “heavy lifting”, the user simply had to wait a few seconds for the ODR package containing the logic to be fetched from the App Store to start playing the game. Now, even that brief delay is no longer needed as the framework can download complete games! Simply brilliant!

Lastly, exercise caution when selecting which assets you wish to put online. The exact URLs of these files can easily be spoofed to enable downloads outside of the application. To avoid leaks, consider keeping only encrypted files on the general access server. The app can then decrypt them using a key injected as a secret.

How can we implement that?

So, where should we begin? By defining what files our app needs and in what order. After determining that, we can add the necessary keys to your app’s info.plist files.

  • AppGroupIdentifier – An app group shared between the app and the extension.
  • BAManifestURL – A URL to the manifest file.
  • BAEssentialMaxInstallSize – The combined size of the essential files after unpacking (approximate).
  • BAMaxInstallSize – The combined size of the non-essential files after unpacking (approximate).
  • BAInitialDownloadRestrictions – An array containing files download restrictions:
    • BADownloadAllowance – The combined size of the initial set of non-essential files downloads.
    • BADownloadDomainAllowList – Array of whitelisted domains to download files from.
    • BAEssentialDownloadAllowance – The combined size of the initial set of Essential files downloads, including the manifest.

The manifest file should contain a description of packages that need to be downloaded. It can be a JSON or a plist file – the choice is yours. However, you should consider the following:

  • The file has to be hosted on one of the whitelisted domains, defined under the BADownloadDomainAllowList key in the app info.plist.
  • It should be fairly lightweight.
  • It must contain precise file sizes of the packages. If the package is of a different size than the value provided in the manifest, the download will automatically fail!

An info.plist containing a link to a sample manifest file can be found on my Github.

Let’s examine some code. To interact with the framework, the app extension must implement BADownloaderExtension. The first task is to parse the manifest file and generate a set of BADownload objects. The framework will use these to schedule the downloads. It’s important to note that the manifest file is already downloaded at this point (1), so your task is simply to parse it.
Next, verify which files have already been downloaded. Shared UserDefaults storage can be used to sync data between the app and the extension (2). Additionally, if you’ve designated any downloads as Essentials, ensure they are scheduled for download only when the app is being installed or updated (3):

import BackgroundAssets

@main
class AssetsDownloaderExtension: BADownloaderExtension {

    func downloads(for request: BAContentRequest, manifestURL: URL, extensionInfo: BAAppExtensionInfo) -> Set<BADownload> {
        let manifestPackages = manifestURL.getManifestPackages() // (1)
        let storedAssets = storage.readAssetsFromStorage() // (2)
        let essentialDownloadsPermitted = request == .install || request == .update // (3)
        let currentAssets = currentAssetsComposer.compose(
            storedAssets: storedAssets,
            manifestPackages: manifestPackages,
            essentialDownloadsPermitted: essentialDownloadsPermitted
        )
        ...
        return Set(currentAssets.assetsToDownload.map { $0.baDownload })
    }
    
    ...

}

Now, let’s handle downloaded files:

let downloadManager = BADownloadManager.shared

...

func backgroundDownload(_ finishedDownload: BADownload, finishedWithFileURL fileURL: URL) {
    let ephemeralFileURL = fileManager.ephemeralStorageAssetFile(for: finishedDownload.identifier)
    _ = try? fileManager.replaceItemAt(
        ephemeralFileURL, withItemAt: fileURL, backupItemName: nil, options: []) // (1)

    downloadManager.withExclusiveControl { [weak self] lockAcquired, error in // (2)
        guard let self, lockAcquired else {
            return
        }

        moveDownloadedPackage(finishedDownload: finishedDownload, tempFileURL: ephemeralFileURL)
    }
}

where:

func moveDownloadedPackage(finishedDownload: BADownload, tempFileURL: URL) {
    let targetURL = fileManager.sharedStorageAssetFile(for: finishedDownload.identifier)
    try? fileManager.replaceItemAt(
        targetURL, withItemAt: tempFileURL, backupItemName: nil, options: []) // (3)
    updateAssetState(assetID: finishedDownload.identifier, newState: .toBeTransferred)
}

func updateAssetState(assetID: String, newState: AssetData.State) {
    let updatedAssets = assets.updateState(assetID: assetID, newState: newState)
    storage.writeAssetsToStorage(updatedAssets) // (4)
}

First of all, we need to move the downloaded file to a temporary folder (1). We then proceed to handle the file and update the app’s state (3). This is necessary since establishing exclusive control within the framework can be time-consuming (2). There’s no guarantee that the extension will remain active when the control is established. In fact, the extension may be terminated and re-launched multiple times before all files are downloaded. That is why it’s crucial to save your shared state immediately after it’s been modified (4).

While we’d prefer all downloads to succeed, it’s not always the case. In fact, you should expect some of them to fail. This is because the extension is only allowed to work for a very brief period of time:

func backgroundDownload(_ failedDownload: BADownload, failedWithError error: Error) {
    guard type(of: failedDownload) == BAURLDownload.self else {
        return  // (1)
    }
    if failedDownload.isEssential {
        rescheduleFailedEssentialDownload(failedDownload: failedDownload)  // (2)
    } else {
        downloadManager.withExclusiveControl { [weak self] lockAcquired, error in
            guard let self, lockAcquired else {
                return
            }
            updateAssetState(assetID: failedDownload.identifier, newState: .failed)  // (3)
        }
    }
}

where:

func rescheduleFailedEssentialDownload(failedDownload: BADownload) {
    let optionalDownload = failedDownload.removingEssential()
    try? downloadManager.scheduleDownload(optionalDownload)
}

Initially, we need to verify that a failed download is indeed… a failed download of a requested file (1). This might seem odd, but a failed manifest file download will also trigger this method. Next, we should try to reschedule any failed essential files (2). The Background Assets framework offers a handy removingEssential() method for this.

Lastly, we should save information about failed non-essential files in the storage shared between the app and the extension.

This seems pretty reasonable, doesn’t it? Let’s examine how to use the framework in the app. Naturally, the first step is to resume the downloads from where the extension left off:

func reloadAssets() async {
    let currentAssets = await getCurrentAssets()  // (1)
    assets = currentAssets.allAssets
    storage.writeAssetsToStorage(assets)
    transfer(currentAssets.assetsToTransfer)  // (2)
    startDownloading(assets: currentAssets.assetsToDownload)  // (3)
}

and:

func getCurrentAssets() async -> CurrentAssets {
    let storedAssets = storage.readAssetsFromStorage()
    let manifestAssets = await fetchManifestPackages()
    return currentAssetsComposer.compose( // (4)
        storedAssets: storedAssets,
        manifestPackages: manifestAssets,
        essentialDownloadsPermitted: false
    )
}

func transfer(_ assetsToTransfer: [AssetData]) {
    assetsToTransfer.forEach { asset in
        let from = fileManager.sharedStorageAssetFile(for: asset.id)
        let to = fileManager.permanentStorageAssetFile(for: asset.id)
        do {
            try fileManager.moveItem(at: from, to: to)
            updateAssetState(assetID: asset.id, newState: .loaded)
        } catch {
            updateAssetState(assetID: asset.id, newState: .failed)
        }
    }
}

It’s crucial to acquire the most recent version of the manifest file and the status of previously downloaded files (1). This prevents duplicate downloads. The following step is to assemble a set of files for download. The logic for this is almost identical to the one we implemented in the extension, so it’s recommended to extract it to shared code (4). Additionally, we need to manage the files downloaded by the extension. Currently, these files are in a shared folder between the app and the extension. We should relocate them to a more permanent location (2).
Finally, we need to begin downloading files (3). This procedure is similar to the one we’ve already implemented in the extension:

func startDownloading(assets: [AssetData]) {
    downloadManager.withExclusiveControl { [weak self] lockAcquired, error in  // (1)
        guard let self, lockAcquired else {
            return
        }
        assets.forEach(download)
    }
}

func download(package: AssetData) {
    do {
        let download: BADownload
        let currentDownloads = try downloadManager.fetchCurrentDownloads()  // (2)
        if let existingDownload = currentDownloads.first(where: { $0.identifier == package.id }) {
            download = existingDownload // (3)
        } else {
            download = package.baDownload // (4)
        }
        try downloadManager.startForegroundDownload(download)  // (5)
    } catch {  }
}

First of all, we need to secure exclusive control to interact with the framework (1). Next, we can check for any ongoing downloads that match our intended start (2). If found, simply use the corresponding BADownload object (3). If not, create a new one (4). Finally, allow the framework to handle the rest of the process (5). It’s that simple!

To be informed about the result of our scheduled downloads, we need to implement BADownloadManagerDelegate:

extension LiveAssetsManager: BADownloadManagerDelegate {

    func downloadDidBegin(_ download: BADownload) {
        updateAssetState(assetID: download.identifier, newState: .loading(0))  // (1)
    }

    func download(
        _ download: BADownload,
        didWriteBytes bytesWritten: Int64,
        totalBytesWritten: Int64,
        totalBytesExpectedToWrite totalExpectedBytes: Int64
    ) {
        guard type(of: download) == BAURLDownload.self else {
            return
        }
        let progress = Double(totalBytesWritten) / Double(totalExpectedBytes)  // (2)
        updateAssetState(assetID: download.identifier, newState: .loading(progress))
    }

    func download(_ download: BADownload, failedWithError error: Error) {
        updateAssetState(assetID: download.identifier, newState: .failed)  // (3)
    }

    func download(_ download: BADownload, finishedWithFileURL fileURL: URL) {
        let targetURL = fileManager.permanentStorageAssetFile(for: download.identifier)
        do {
            _ = try fileManager.replaceItemAt(
                targetURL, withItemAt: fileURL, backupItemName: nil, options: [])  // (4)
            updateAssetState(assetID: download.identifier, newState: .loaded)
        } catch {
            updateAssetState(assetID: download.identifier, newState: .failed)
        }
    }
}

As you can see, there are more options here than with the extension, primarily for updating the UI.
For example, you might want to know when the download of a specific file starts (1). Likewise, you’d likely want to monitor the download progress (2) to keep updating some sort of visual indicator.
While it’s less likely for the download to fail (3) than with the extension, it might still happen. It’s often a result of a mismatch between the declared and actual size of the file you’re trying to download. Another common problem is designating a file as essential. Keep in mind that the framework does not allow the application to download essential files. By the time the application launches, all essential files should have already been downloaded.
Finally, once the download is successful, we should move it to its permanent location (4).

The good, the bad, the ugly

So, let’s take a look at all the pros and cons of using the Background Assets framework to download assets/games in an iOS app:

The Good:

  • Almost unlimited number of games:
    Say “goodbye” to the 20GB On-Demand Resources (ODR) limit in the App Store. The size of your cloud hosting is your only limitation.

  • Update assets/games without re-submitting the app:
    No need to submit the app for a review just to replace a texture in one of the games!

  • Faster load times:
    Bye-bye long “game is loading” screens. Nobody will miss you.

  • Pre-download files you need:
    When the users go to sleep, their devices can get their favourite games for them.

The Bad:

  • iOS 16+ only (but really…):
    The Background Assets framework was introduced in iOS 16, and Apple has no plans to incorporate it into older iOS versions.

  • Apple’s policy might change over time:
    Currently, the regulations regarding what can and cannot be downloaded using the Background Assets framework are quite reasonable. Let’s hope this remains unchanged.

The Ugly:

  • Testing on a “living organism”:
    The only way to make sure we interpreted Apple’s intentions regarding the Background Assets framework correctly is to submit the app for review.

Summary

I must admit, I was somewhat sceptical when I first learned about the Background Assets framework. Apple has consistently maintained a firm stance against storing in-app resources outside of the App Store, particularly complete HTML5 games or game assets. Then they introduced this comprehensive, user-friendly, and convenient solution to alleviate long-standing industry issues. And in January 2024, they even allowed in-app games to be hosted outside of the App Store. It seemed too good to be true, but it appears there’s no catch. It looks like Apple finally decided to trust developers to provide users with quality apps, serving quality content. The Background Assets framework is an ideal tool for fetching this content in a user-friendly, convenient way. While it’s possible to implement such a tool on your own, wouldn’t your time be better spent elsewhere?

Apple may add more regulations to the ways we use the framework, but the benefits to users outweigh the potential maintenance costs. You can reduce e.g. game loading screen times while keeping the app size small. You can update the machine learning model without re-submitting the app for review. How about hosting the “mini-apps” or “streaming games” that are finally allowed in the App Store? Possibilities are many and enticing. And there’s virtually no limit to the size of the content you can offer in the app.

So… Do you accept Apple’s offer?

Don’t miss any new blogposts!

    By subscribing, you agree with our privacy policy and our terms and conditions.

    Disclaimer: All memes used in the post were made for fun and educational purposes only 😎
    They were not meant to offend anyone 🙌
    All copyrights belong to their respective owners🧐
    The thumbnail was generated with text2image ❤️ 
    Some images were generated using Leonardo AI ❤️
    Audio version generated with TTS Maker ❤️