Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4370

On-Demand Resources in iOS Tutorial

$
0
0

Note: This tutorial uses Xcode 9 and Swift 4.

On-Demand Resources in iOS Tutorial

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.

BambooBreakou on-demand resources

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.

Level 1

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 game
  • Playing: Someone is playing the game
  • LevelOver: 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.

  1. When the system invokes touchesBegan(_:with:), gameState.currentState is set to WaitingForTap. When the switch hits this case, the app changes gameState.currentState to the Playing state and sets isFingerOnPaddle to true. The app uses the isFingerOnPaddle variable to move the paddle.
  2. The next case in the switch executes when the game is in the Playing state. This state is used to track when the user is playing the game and touching the game paddle.
  3. The app executes the next case statement when the game is in the LevelOver state. In this case the game loads the next scene based on the nextLevel variable. The nextLevel variable is set to 2 on creation of the very first scene.
  4. 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:

Main Bundle

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.

Alternate 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:

Game Levels

Open the File Inspector using the Utilities menu. Find the section named On Demand Resource Tags:

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.

Tagging Level 2

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.

Allthetags.

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:

  1. First, there’s the convenience init() method. It takes a Set of tags representing the resources to download.
  2. 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.
  3. Next, the variable tags contains the set of tags to be requested by this object.
  4. Next, bundle represents the alternate bundle described earlier. This bundle is where the system stores the retrieved resources.
  5. Next, beginAccessingResources starts the request of the resources. You invoke this method and pass it a completion handler that takes an Error object.
  6. 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.
  7. 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:

  1. 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.
  2. Next, you create an instance of NSBundleResourceRequest to perform your request.
  3. Next, you verify the creation of the instance was successful, and if so, invoke beginAccessingResources() to begin the request.
  4. If there was an error, the app executes the error handler, and you cannot use that resource.
  5. 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.

  1. First, use the shared instance of ODRManager and call requestSceneWith(tag:onSuccess:onFailure:). You pass this method the tag of the next level and a success and error handler.
  2. If the request is successful, create the actual game scene it retrieved and present the scene to the user.
  3. 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:

Device Disk

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.

  1. 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.
  2. 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.
  3. 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.


Viewing all articles
Browse latest Browse all 4370

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>