When I got my first iPod Touch several years ago, I excitedly opened the App Store and started downloading the first ten games from the Top Charts. It wasnât long before I came to the sad realization that my iPod storage wasnât infinite: the alert telling me I didnât have enough storage to download all the apps quashed my initial enthusiasm.
iOS 9 and tvOS introduced On-Demand Resources (ODR) as a way to manage the size of your apps. The idea behind ODR is that your app only retrieves the resources itâs needed up to that point. When the app needs additional resources, it downloads them on-demand.
There are two main reasons to take advantage of ODR in your apps:
- Faster initial download: The faster the user can download the app, the faster they can start falling in love with it.
- Smaller app size: When storage is limited, the biggest apps are usually the first ones that users will delete. You want to keep your appâs storage footprint well under the radar!
Time to size things up â and dive right into adding ODR to an existing app.
Getting Started
First download these two files:
- The starter project
- The video assets for this tutorial (a separate download due to its large size)
Put both files in a folder somewhere (I called mine ODR) and unzip both files. The resulting directory structure should look like this:
Open RWHomeTheater in Xcode and build and run. Youâll see the following:
In this app, you can pick from a collection of remarkable, entertaining videos that range from a cow eating grass to water boiling. Currently the app has 14 videos, which take up nearly 200 MB by themselves â looks like a case for some ODR!
After you play around with the app a bit, back in Xcode open Main.storyboard and zoom in on the top-right of the RW Home Theater Scene. Notice thereâs a UIProgressView thatâs hidden by default; later on, youâll use this to show the user the status of their downloads.
NSBundleResourceRequest and Tags
To work with ODR, youâll need a basic understanding of NSBundle
and how it handles files.
NSBundle
represents a group of files on the device. In the case of ODR, these files will include resources such as videos, images, sounds, 3D models, and shaders; the main exception is that you canât include Swift, Objective-C or C++ source code in ODR.
Most of the time, youâll use NSBundle.mainBundle()
as your main bundle. By default, all the resources downloaded in your app are included in the main bundle. Think of the main bundle as a big box that holds all of your appâs âstuffâ.
However, when you use ODR, the resources you download arenât in the main bundle; instead, theyâre in a separate bundle that contains all the resources of a given tag. The OS only downloads a tagâs resources when needed.
When you request a tag, the OS downloads all resources for that tag and stores it in an NSBundle
. Then, instead of using the main bundle to find the resource, you simply look inside this alternative bundle.
You request a tag using an instance of NSBundleResourceRequest. This resource request takes strings that represent the tags as parameters and lets you know when your resources are available. Once the resource has been loaded, you can use the resource requestâs bundle property to access the files in the bundle.
Once youâre done with an NSBundleResourceRequest
, you can call endAccessingResources()
on the request and let it become deallocated. This lets the system know youâre done with the resources and that it can delete them if necessary.
Now that you understand the foundations of ODR, you can get started on the coding!
Adding Tags
In the project navigator, expand the videos group and the Animals subgroup under the Resources folder and select cows_eating_grass.mp4:
Show the File Inspector using the Utilities menu. Notice the section named On Demand Resource Tags
:
To add a tag to a file, you simply type the name of the tag in this text box and press Enter.
In order to make things easier to discern from code, youâll add the videoâs name as its tag. Be careful â if you donât name the tag exactly the same as the video, then ODR wonât work properly.
For cows_eating_grass.mp4, add âcows_eating_grassâ to the On Demand Resource Tags text field and press Enter:
Select the project in the project navigator, select the target, and go into the Resource Tags tab; youâll see the Resource Tags menu. The app has a tag named âcows_eating_grassâ with a single associated file:
Next up â adding the tags for every single video file. Oh, come on â itâll be fun!
If you donât want to go through the laborious task of adding these tags â good news! You can simply download an alternative version of the starter project that has the tags pre-filled for you.
Once youâve added all of the tags â whichever way you chose to do it â the Resource Tags menu should look like the following:
Build and run your app; when the app loads, open the debug navigator in Xcode and select Disk. This will show you the status of all tags in the app:
The app wonât work at the moment since you havenât downloaded the files to the Apple TV yet: every tag says âNot Downloadedâ. Time to fix that problem!
Resource utility class
In order to keep all your ODR code organized, youâll create a class to manage your on-demand resources. Select File\New\File\Swift File, and name it ResourceManager.
Add the following class declaration to the file:
class ResourceManager { static let sharedManager = ResourceManager() } |
This code creates a new class named ResourceManager and creates a class variable for a shared instance.
Now, add the following code to ResourceManager:
// 1 func requestVideoWithTag(tag: String, onSuccess: () -> Void, onFailure: (NSError) -> Void) -> NSBundleResourceRequest {  // 2 let videoRequest = NSBundleResourceRequest(tags: [tag]) videoRequest.loadingPriority = NSBundleResourceRequestLoadingPriorityUrgent  // 3 videoRequest.beginAccessingResourcesWithCompletionHandler { error in NSOperationQueue.mainQueue().addOperationWithBlock { if let error = error { onFailure(error) } else { onSuccess() } } } // 4 return videoRequest } |
Thereâs a lot of new stuff going on here, so taking it piece by piece:
- Create a method to easily request a new video in your app. This method takes the tag name as a parameter, as well as two closures â one to call if the download succeeds, and one to call if the download fails.
-
Instantiate a new
NSBundleResourceRequest
with the given tag. By settingloadingPriority
of the request toNSBundleResourceRequestLoadingPriorityUrgent
, the system knows that the user is waiting for the content to load and that it should download it as soon as possible. -
To start loading the resources, call
beginAccessingResourcesWithCompletionHandler
. This completion handler is called on a background thread, so you add an operation to the main queue to respond to the downloadâs result. If there is an error, theonFailure
closure is called; otherwise,onSuccess
is called. - Return the bundle request so that the requestor can access the bundleâs contents after the download finishes.
Before you can add this to the project, you need to make a small change to the video struct. Open Video.swift and find the current declaration of videoURL
:
var videoURL: NSURL { return NSBundle.mainBundle().URLForResource(videoName, withExtension: "mp4")! } |
Replace that declaration with the following:
var videoURL: NSURL! |
Also, change the implementation of videoFromDictionary
to match the following:
static func videoFromDictionary(dictionary: [String: String]) -> Video { let previewImageName = dictionary["previewImageName"]! let title = dictionary["videoTitle"]! let videoName = dictionary["videoName"]! Â return Video(previewImageName: previewImageName, title: title, videoName: videoName, videoURL: nil) } |
In the original code, videoURL
pointed to a location in the main bundle for the file. However, since youâre now using ODR, the resource is now in a different bundle. Youâll set the videoâs URL once the video is loaded â and once you know where the video is located.
Requesting tags on selection
Open VideoListViewController.swift and add the following property to VideoListViewController
:
var currentVideoResourceRequest: NSBundleResourceRequest? |
This property will store the NSBundleResourceRequest
for the most recently requested video.
Next, youâll need to request this video when the user selects one of the videos in the collection view.
Find didSelectVideoAtIndexPath(_:)
and replace its implementation with the following:
func didSelectVideoAtIndexPath(indexPath: NSIndexPath) { // 1 currentVideoResourceRequest?.progress.cancel() // 2 guard var video = collectionViewDataSource .videoForIndexPath(indexPath), let videoCategory = collectionViewDataSource .videoCategoryForIndexPath(indexPath) else { return } // 3 currentVideoResourceRequest = ResourceManager.sharedManager .requestVideoWithTag(video.videoName, onSuccess: { [weak self] in  }, onFailure: { [weak self] error in  } ) } |
The code breaks down as follows:
- If thereâs a video request in progress, cancel that resource request and let the new video request take priority.
-
This is the same code from
didSelectVideoAtIndexPath(_:)
; itâs how you find the video and category of the selected cell. -
Set
currentVideoResourceRequest
equal to a newNSBundleResourceRequest
created byResourceManager
. The resource tag passed as a parameter is the name of the video â this is why you followed that strict naming scheme earlier.
The next step is to handle the failure case. Add the following method to the VideoListViewController
extension:
func handleDownloadingError(error: NSError) { switch error.code{ case NSBundleOnDemandResourceOutOfSpaceError: let message = "You don't have enough storage left to download this resource." let alert = UIAlertController(title: "Not Enough Space", message: message, preferredStyle: .Alert) alert.addAction(UIAlertAction(title: "OK", style: .Cancel, handler: nil)) presentViewController(alert, animated: true, completion: nil) case NSBundleOnDemandResourceExceededMaximumSizeError: assert(false, "The bundle resource was too large.") case NSBundleOnDemandResourceInvalidTagError: assert(false, "The requested tag(s) (\(currentVideoResourceRequest?.tags ?? [""])) does not exist.") default: assert(false, error.description) } } |
handleDownloadingError(_:)
handles all the possible errors from a resource request.
The main three error cases occur when either the device is out of storage, the resource is too large, or youâve requested an invalid tag. If the device is out of storage, you alert the user of the problem. Youâll need to catch the other two issues before you deploy your app; at that point, itâs too late to make changes. So that they donât go unnoticed in your testing phase (you are testing, right?), you crash the app with an error message.
The default
assertion catches any other errors that could occur, such as network loss.
Now, call your new method in the onFailure(_:)
closure:
self?.handleDownloadingError(error) |
In the onSuccess
closure, add the following code to handle the successful download:
guard let currentVideoResourceRequest = self?.currentVideoResourceRequest else { return } video.videoURL = currentVideoResourceRequest.bundle .URLForResource(video.videoName, withExtension: "mp4") let viewController = PlayVideoViewController .instanceWithVideo(video, videoCategory: videoCategory) self?.navigationController?.pushViewController(viewController, animated: true) |
Here you set the URL of the selected video to the downloaded resource within the requested bundle. Then, you present a new instance of PlayVideoViewController
with the video as a parameter.
Run your app; select video to play and youâll notice thereâs a bit of a delay before the video starts. This is because the video is being downloaded from the server â or in this case, from your computer.
To check that your app is using the on-demand bundle, open the debug navigator and select Disk; the Status column of your chosen video will now show âIn Useâ.
If you press Menu on your remote, youâll notice the tag still shows as âIn Useâ. Thatâs not quite right, is it? The resource should change to âDownloadedâ since youâre no longer using the resource. Youâll fix this in the next section.
Purging Content
Responsible management of your resources includes releasing them when youâre done. This takes two steps:
-
Call
endAccessingResources()
on theNSBundleResourceRequest
. - Let the resource request deallocate.
The best time to let ODR know you no longer need the video is once the userâs finished watching the video. This happens when you dismiss PlayVideoViewController
and VideoListViewController
reappears on-screen.
Add the following method to VideoListViewController.swift:
override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) Â currentVideoResourceRequest?.endAccessingResources() currentVideoResourceRequest = nil } |
This code first checks that VideoListViewController
reappears once youâve dismissed a different view controller from the navigation stack. It then calls endAccessingResources()
on the resource request and sets the property to nil
, which lets the resource request deallocate.
Build and run your app; watch the Resource Tags menu as you play a video, then press Menu to go back. The Resource Tags menu now shows the requested resource as âDownloadedâ. Perfect! You wonât delete the resource until the system needs the space. As long as the device has room, the resources will remain in storage.
Progress Reporting
At present, the user has no way to see the progress of the download. Is the video almost completely downloaded? Or is it not downloading at all?
In order to indicate the progress of the download, youâll use a progress bar to observe the progress of the NSBundleResourceRequest
.
Back in VideoListViewController.swift, add the following line to the beginning of didSelectVideoAtIndexPath(_:)
:
progressView.hidden = false |
At the beginning of both the onSuccess
and onFailure
closures, add the following line â which does the exact opposite as the previous line:
self?.progressView.hidden = true |
This code shows the progress bar when the download begins, and hides it when the download ends.
Key-Value Observing
To connect the progress bar with the NSBundleResourceRequest
, you need to use Key-Value Observing.
Open ResourceManager.swift and add the following to the top of the file, above the class declaration:
let progressObservingContext = UnsafeMutablePointer<Void>() |
Next, you need to change requestVideoWithTag
to accept the progress observer as a parameter.
Replace the declaration of requestVideoWithTag
with the following:
func requestVideoWithTag(tag: String, progressObserver: NSObject?, onSuccess: () -> Void, onFailure: (NSError) -> Void) -> NSBundleResourceRequest { |
This method now has a new progressObserver
parameter that will make it easier to use KVO with your custom view controller and progress bar.
Within this method, add the following code before the return
statement:
if let progressObserver = progressObserver { videoRequest.progress.addObserver(progressObserver, forKeyPath: "fractionCompleted", options: [.New, .Initial], context: progressObservingContext) } |
Here, you add the argument as an observer to the requestâs progress.
Just like you added the observer before the resource was loaded, youâll need to remove the observer once itâs loaded. Add the code below to the beginning of the NSOperationQueue.mainQueue().addOperationWithBlock
block:
if let progressObserver = progressObserver { videoRequest.progress.removeObserver(progressObserver, forKeyPath: "fractionCompleted") } |
Xcode will respond with an error in VideoListViewController
; this is because you changed the method signature of requestVideoWithTag
.
Open VideoListViewController.swift and change the line with the error to the following:
currentVideoResourceRequest = ResourceManager.sharedManager .requestVideoWithTag(video.videoName, progressObserver: self, onSuccess: { [weak self] in ... }, onFailure: { [weak self] error in ... } ) |
The only change here is that you added the progressObserver
parameter and passed self
as the observer.
In order to respond to changes as the download progresses, youâll need to implement observeValueForKeyPath
in the view controller â your observer.
Add the following method to VideoListViewController
:
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) { Â if context == progressObservingContext && keyPath == "fractionCompleted"{ Â NSOperationQueue.mainQueue().addOperationWithBlock { self.progressView.progress = Float((object as! NSProgress).fractionCompleted) } } } |
When the value of the downloadâs progress changes, you reflect this change in the progress bar on the main thread.
Build and run your app; select a video to watch and youâll see the progress bar at the top-right corner of the screen. Once the progress bar has filled, it will disappear and the video will play:
w00t â videos on demand!
Where To Go From Here?
Here is the example code from this On-Demand Resources in tvOS tutorial.
You now have all the details you need to bring On-Demand Resources into your tvOS apps. ODR lets you create large, resource-intensive apps that still fit within the 200MB initial download requirement.
If you want to learn more, you should check out our book the tvOS Apprentice. The chapter in the book goes into further detail about anticipating the users actions and the different types of resource tags that are available. You also might enjoy the other 27 chapters and 500+ pages in the book! :]
In the meantime, if you have any questions or comments about using TVML templates, please join the forum discussion below!
The post On-Demand Resources in tvOS Tutorial appeared first on Ray Wenderlich.