On-Demand Resources in tvOS Tutorial
Learn how to download resources in your tvOS apps upon demand – especially useful if you are trying to make your app fit within the 200MB limit! By Jawwad Ahmad.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
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 didSelectVideoAt(_:)
and replace its implementation with the following:
func didSelectVideoAt(_ indexPath: IndexPath) {
// 1
currentVideoResourceRequest?.progress.cancel()
// 2
guard var video = collectionViewDataSource
.videoFor(indexPath: indexPath),
let videoCategory = collectionViewDataSource
.videoCategoryFor(indexPath: indexPath) else {
return
}
// 3
currentVideoResourceRequest = ResourceManager.shared
.requestVideoWith(tag: 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 the old version of
didSelectVideoAt(_:)
; 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))
present(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 as NSError)
In the onSuccess
closure, add the following code to handle the successful download:
guard let currentVideoResourceRequest =
self?.currentVideoResourceRequest else { return }
video.videoURL = currentVideoResourceRequest.bundle
.url(forResource: video.videoName, withExtension: "mp4")
let viewController = PlayVideoViewController
.instanceWith(video: 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 a 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 involves 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 didSelectVideoAt(_:)
:
progressView.isHidden = 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.isHidden = 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: UnsafeMutableRawPointer? = nil
Next, you need to change requestVideoWith(tag:onSuccess:onFailure:)
to accept the progress observer as a parameter.
Replace the declaration of requestVideoWith(tag:onSuccess:onFailure:)
with the following:
func requestVideoWith(tag: String,
progressObserver: NSObject?,
onSuccess: @escaping () -> Void,
onFailure: @escaping (Error) -> 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 OperationQueue.main.addOperation
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 requestVideoWith(tag:onSuccess:onFailure)
to requestVideoWith(tag:progressObserver:onSuccess:onFailure)
.
Open VideoListViewController.swift and change the line with the error to the following:
currentVideoResourceRequest = ResourceManager.shared
.requestVideoWith(tag: 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 observeValue(forKeyPath:of:change:context:)
in the view controller — your observer.
Add the following method to VideoListViewController
:
override func observeValue(forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey : Any]?,
context: UnsafeMutableRawPointer?) {
if context == progressObservingContext
&& keyPath == "fractionCompleted" {
OperationQueue.main.addOperation {
self.progressView.progress
= Float((object as! Progress).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: