Note: This tutorial uses Xcode 9 and Swift 4.
iOS 9 and tvOS introduced the concept of on-demand resources (ODR), a new API used to deliver content to your applications after you’ve installed an app.
ODR allows you to tag specific assets of your application and have them hosted on Apple’s servers. These assets won’t be downloaded until the application needs them, and your app will purge resources from your user’s devices when they’re no longer needed. This results in smaller apps and faster downloads — which always makes users happy.
In this tutorial, you’ll learn the basics of on-demand resources including:
- Tagging and downloading individual resources
- Looking at Xcode’s Disk Report to see which resources are on the device
- How to organize resources into different tag types
- Some best practices to make sure your users get the best experience
Getting Started
Download the starter project for this tutorial. You can find it Bamboo-Breakout-Starter.
The starter project is a game called Bamboo Breakout. Michael Briscoe wrote this app as a SpriteKit tutorial, and it serves as a great example of how simple it is to write a SpriteKit app in Swift. You can find the original tutorial here.
The original game had only one game level, so I’ve added a few changes to the original app: five new game levels and some code to load each level.
Examining the App
Once you have the starter application, open it in Xcode and open the Bamboo Breakout folder.
In this folder, you will see six SpriteKit scenes. Each one of these scenes represents a level in the Bamboo Breakout game. At the moment, you’re packaging all these scenes with the application. By the end of this tutorial, you’ll have only the first level installed.
Build and run the app, and you’ll see the first level of the game in the simulator.
Game States
Time to take a look at the starter project. You don’t need to examine the entire project, but there’s a few things you do need to be familiar with.
Look at the top of the GameScene.swift class. In Xcode, open GameScene.swift and look for the following snippet:
lazy var gameState: GKStateMachine = GKStateMachine(states: [
WaitingForTap(scene: self),
Playing(scene: self),
LevelOver(scene: self),
GameOver(scene: self)
])
Here you see the creation and initialization of a GKStateMachine
object. The GKStateMachine
class is part of Apple’s GameplayKit; it’s a finite-state machine that helps you define the logical states and rules for a game. Here, the gameState
variable has four states:
WaitingForTap:
The initial state of the gamePlaying:
Someone is playing the gameLevelOver:
The most recent level is complete (this is where you’ll be doing most of your work)GameOver:
The game has ended either with a win or a loss
To see where the initial game state is set, scroll down to the bottom of didMove(to:)
.
gameState.enter(WaitingForTap.self)
This is where the initial state of the game is set, and it’s where you’ll begin your journey.
Note: didMove(to:)
is a SpriteKit method and part of the SKScene
class. The app calls this method immediately after it presents the scene.
Starting the Game
The next thing you need to look at is touchesBegan(_:with:)
in GameScene.swift.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
switch gameState.currentState {
// 1
case is WaitingForTap:
gameState.enter(Playing.self)
isFingerOnPaddle = true
// 2
case is Playing:
let touch = touches.first
let touchLocation = touch!.location(in: self)
if let body = physicsWorld.body(at: touchLocation) {
if body.node!.name == PaddleCategoryName {
isFingerOnPaddle = true
}
}
// 3
case is LevelOver:
if let newScene = GameScene(fileNamed:"GameScene\(self.nextLevel)") {
newScene.scaleMode = .aspectFit
newScene.nextLevel = self.nextLevel + 1
let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
self.view?.presentScene(newScene, transition: reveal)
}
// 4
case is GameOver:
if let newScene = GameScene(fileNamed:"GameScene1") {
newScene.scaleMode = .aspectFit
let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
self.view?.presentScene(newScene, transition: reveal)
}
default:
break
}
There’s a lot going on here. Let’s go through this line by line.
-
When the system invokes
touchesBegan(_:with:)
,gameState.currentState
is set toWaitingForTap
. When theswitch
hits thiscase
, the app changesgameState.currentState
to thePlaying
state and setsisFingerOnPaddle
totrue
. The app uses theisFingerOnPaddle
variable to move the paddle. -
The next
case
in theswitch
executes when the game is in thePlaying
state. This state is used to track when the user is playing the game and touching the game paddle. -
The app executes the next
case
statement when the game is in theLevelOver
state. In this case the game loads the next scene based on thenextLevel
variable. ThenextLevel
variable is set to 2 on creation of the very first scene. -
When the game is in the
GameOver
state, it loads the scene GameScene1.sks and restarts the game.
This process assumes you packaged all these scenes with the installed app.
Bundles
Before you start using on-demand resources, you need to know how resource bundles work.
iOS uses Bundles to organize resources into well-defined subdirectories inside an application. You need to use the Bundle object to retrieve the resource you are looking for; the Bundle object provides a single interface for locating items. You can imagine the main bundle looking similar to the following:
This example shows what the main bundle looks like when it contains three game levels.
On-demand resources are different. They’re not packaged with the distributed application. Instead, Apple stores them on their servers. Your app retrieves them only when it needs to, using NSBundleResourceRequest
. You pass the NSBundleResourceRequest
object a collection of tags, which represent the resources you want to retrieve. When the app downloads the resources to the device, they’re stored in an alternative bundle.
In this example, the application is making a request for three on-demand resources. The system will retrieve these resources and store them in an alternative bundle.
Now, what exactly are tags?
Tag Types
- Initial Install Tags: These resources are downloaded when the app is downloaded from the App Store. These resources affect the total size of the in the App Store.
- Prefetched Tag Order Tags: These resources are downloaded once the app has been installed on the user’s device. The App downloads them in the same order they appear in the Prefetched Tag Order group.
- Downloaded Only On Demand Tags: These resources are downloaded when the app requests them.
Note: You can only use Downloaded Only On Demand while in development. You’ll have to deploy the app to the App Store or TestFlight to use the other tag types.
Assigning and Organizing Tags
The first thing to consider is which resources you want to package with the application. For this game app, it makes sense to at least give the user the first level of the game. You don’t want them starting without any game levels.
In the project navigator, select GameScene2.sks from the Bamboo Breakout group:
Open the File Inspector using the Utilities menu. Find the section named On Demand Resource Tags:
When tagging a resource, try to use a meaningful name. This will help you keep all your on-demand resources organized. For GameScene2.sks, which represents Level 2 of the game, you are going to use the tag level2.
Type level2 in the Tags input and press Enter.
Once you’ve finished tagging GameScene2.sks, tag the rest of the scenes using the same pattern. When finished, select the Bamboo Breakout Target, Resource Tags, and then All. You should see all the tags you added.
Introducing NSBundleResourceRequest
Okay, you’ve tagged all your on-demand resources. It’s time to add the code to download them. Before doing this, take a closer look at the NSBundleResourceRequest
object:
// 1
public convenience init(tags: Set<String>)
// 2
open var loadingPriority: Double
// 3
open var tags: Set<String> { get }
// 4
open var bundle: Bundle { get }
// 5
open func beginAccessingResources(completionHandler: @escaping (Error?) -> Swift.Void)
// 6
open var progress: Progress { get }
// 7
open func endAccessingResources()
Taking it step-by-step:
- First, there’s the convenience
init()
method. It takes aSet
of tags representing the resources to download. - Next is the variable
loadingPriority
. It provides a hint to the resource loading system and represents the loading priority of this request. The range of this priority is from 0 to 1, with 1 being the highest priority. The default value is 0.5. - Next, the variable
tags
contains the set of tags to be requested by this object. - Next,
bundle
represents the alternate bundle described earlier. Thisbundle
is where the system stores the retrieved resources. - Next,
beginAccessingResources
starts the request of the resources. You invoke this method and pass it a completion handler that takes an Error object. - Next, there’s a
Progress
object. You can watch this object to see the status of the download. This application won’t use this object, because the assets are so small and download really quickly. It’s good to be aware of it though. - Finally,
endAccessingResources
tells the system you no longer need these resources. The system will now know it can purge these resource from the device.
Using NSBundleResourceRequest
Now that you know the internals of NSBundleResourceRequest
, you can create a utility class to manage the downloading of resources.
Create a new Swift file and name it ODRManager
. Replace the contents of the file with the following:
import Foundation
class ODRManager {
// MARK: - Properties
static let shared = ODRManager()
var currentRequest: NSBundleResourceRequest?
}
Currently the class contains a reference to itself (implementing the singleton approach) and a variable of type NSBundleResourceRequest
.
Next, you’ll need a method to start the ODR request. Add the following method below the currentRequest
property:
// 1
func requestSceneWith(tag: String,
onSuccess: @escaping () -> Void,
onFailure: @escaping (NSError) -> Void) {
// 2
currentRequest = NSBundleResourceRequest(tags: [tag])
// 3
guard let request = currentRequest else { return }
request.beginAccessingResources { (error: Error?) in
// 4
if let error = error {
onFailure(error as NSError)
return
}
// 5
onSuccess()
}
}
Taking each commented section in turn:
- The first thing to look at is the method definition. This method takes three parameters. The first is the tag of the on-demand resource you want to retrieve. The second is a success handler and the third is an error handler.
-
Next, you create an instance of
NSBundleResourceRequest
to perform your request. -
Next, you verify the creation of the instance was successful, and if so, invoke
beginAccessingResources()
to begin the request. - If there was an error, the app executes the error handler, and you cannot use that resource.
- If there wasn’t any error, the app can execute the success handler. At this point, the app can assume the resource is available for use.
Downloading Resources
Now it’s time to put this class to use. Open GameScene.swift, find touchesBegan(_:with:)
and change the LevelOver
case to the following:
case is LevelOver:
// 1
ODRManager.shared.requestSceneWith(tag: "level\(nextLevel)", onSuccess: {
// 2
guard let newScene = GameScene(fileNamed:"GameScene\(self.nextLevel)") else { return }
newScene.scaleMode = .aspectFit
newScene.nextLevel = self.nextLevel + 1
let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
self.view?.presentScene(newScene, transition: reveal)
},
// 3
onFailure: { (error) in
let controller = UIAlertController(
title: "Error",
message: "There was a problem.",
preferredStyle: .alert)
controller.addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil))
guard let rootViewController = self.view?.window?.rootViewController else { return }
rootViewController.present(controller, animated: true)
})
At first glance, this may look like a complex body of code, but it’s pretty straightforward.
-
First, use the shared instance of
ODRManager
and callrequestSceneWith(tag:onSuccess:onFailure:)
. You pass this method the tag of the next level and a success and error handler. - If the request is successful, create the actual game scene it retrieved and present the scene to the user.
-
If there was an error, create an instance of
UIAlertController
and let the user know a problem occurred.
Looking at the Device Disk
Once you’ve made all these changes, build and run the app. See if you can get through the first level and then stop. You should see the following:
You may need to plug in your device and play the game there, since it can be difficult to play in the simulator. Be sure to leave your device plugged in and Xcode attached.
After beating the first level, tap the screen once more and stop. You will now see a screen like the following:
Open Xcode, open the Debug navigator then select Disk. Here, you’ll see the status of all on-demand resources in the app:
At this point, the app has only downloaded Level 2 and it’s In Use. Go ahead and play some more levels and keep an eye on the Disk Usage. You can watch as the app downloads each resource when it’s required.
Best Practices
There’s several things you can do to improve a user’s experience. You can improve error reporting, set download priorities and purge resources no longer in use.
Error Handling
In the previous example, whenever you encountered an error the app would simply state “There was a problem.” There’s not a whole lot the user can do with this.
You can make this a much better experience. Open GameScene.swift, and inside touchesBegan(_:with:)
, replace onFailure
within the LevelOver
with the following:
onFailure: { (error) in
let controller = UIAlertController(
title: "Error",
message: "There was a problem.",
preferredStyle: .alert)
switch error.code {
case NSBundleOnDemandResourceOutOfSpaceError:
controller.message = "You don't have enough space available to download this resource."
case NSBundleOnDemandResourceExceededMaximumSizeError:
controller.message = "The bundle resource was too big."
case NSBundleOnDemandResourceInvalidTagError:
controller.message = "The requested tag does not exist."
default:
controller.message = error.description
}
controller.addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil))
guard let rootViewController = self.view?.window?.rootViewController else { return }
rootViewController.present(controller, animated: true)
})
Take a moment to look over this change. It is a fair amount of code, but the main change is the addition of the switch
statement. You’ll see that it’s testing the error code returned by the request object. Depending on which case the switch
hits, the app will change the error message. This is much nicer. Take a look at each one of these errors.
-
NSBundleOnDemandResourceOutOfSpaceError
is encountered when the user does not have enough space on their device to download the requested resources. This is useful since it gives your user a chance to clear up some space and try again. -
NSBundleOnDemandResourceExceededMaximumSizeError
is returned when this resource would exceed the maximum memory for in-use on-demand resources for this app. This would be a good time to purge some resources. -
NSBundleOnDemandResourceInvalidTagError
is returned when the resource tag being requested cannot be found. This would most likely be a bug on your part, and you may want to make sure you have the correct tag name.
Loading Priorities
The next improvement you can make is setting the loading priority of the request. This only requires a single line.
Open ODRManager.swift and add the following to requestSceneWith(tag:onSuccess:onFailure:)
immediately after guard let request = currentRequest else { return }
:
request.loadingPriority =
NSBundleResourceRequestLoadingPriorityUrgent
NSBundleResourceRequestLoadingPriorityUrgent
tells the operating system to download the content as soon as possible. In the case of downloading the next level of a game, it’s very urgent. You don’t want your users waiting. Remember, if you want to customize the loading priorities, you can use a Double
between 0 and 1.
Purging Resources
You can get rid of unneeded resources by calling the method endAccessingResources
on the current NSBundleResourceRequest
.
Still in ODRManager.swift, add the following line immediately after guard let request = currentRequest else { return }
:
// purge the resources associated with the current request
request.endAccessingResources()
Calling endAccessingResources
now cleans up after yourself and purges the any resources you no longer need. You’re now being a courteous iOS citizen and cleaning up after yourself.
Where to Go From Here?
You can find the completed project here.
I hope that knowing how to use on-demand resources helps you reduce the size of your initial app downloads and makes your users a little happier.
For more in-depth coverage of on-demand resources, check out this excellent 2016 WWDC Video on Optimizing On-Demand Resources.
If you have any questions or comments, please join the forum discussion below!
The post On-Demand Resources in iOS Tutorial appeared first on Ray Wenderlich.