27
2017
09

iOS 按需加载资源教程

原文:On-Demand Resources in iOS Tutorial
作者:James Goodwill
译者:kmyhy

注意: 本教程使用 Xcode 9 和 Swift 4。

iOS 9 和 TVOS 中提出了“按需加载资源”(on-demand resources,ODR)的概念,这是一个全新的 API,允许在 app 在安装完成之后,下发内容到 app。

ODR 允许你将 app 中的某些资源标记为存储到苹果服务器上。这些资源如果 app 没有用到的话,不会被下载,同时当它们不再需要时也可以从用户的设备上清除。这可以让 app 变小,下载更快——这是用户所乐意见到的。

在本教程中,你将学习按需加载资源的基本概念,包括:

  • 标记并分别下载资源
  • 查看 Xcode 的磁盘报告以了解哪些资源位于设备上
  • 如何将不同的资源组织到不同的 tag 类型
  • 一些能够让用户获得最佳体验的忠告

开始

这里现在开始项目。

这是一个小游戏,叫做 Bamboo Breakout。这是 Michael Briscoe 在一篇 SpriteKit 教程中编写的 app,很好地演示了如何用 swift 编写 SpriteKit app。原始教程在这里

原本的游戏只有第一关,我在此基础主要增加了:5 个新的游戏关卡,以及加载这些关卡的代码。

探索开始项目

下载好开始项目,在 Xcode 中打开它。

在这个文件夹中,你会看到 6 个 SpriteKit 场景。每一个场景就是一个游戏关卡。现在,所有的游戏场景都放在了 app 中。最终,我们只需要安装第一个关卡。

Build & run,马上会在模拟器中看到第一关的场景。

游戏状态

来看一眼开始项目。不需要整个项目都浏览一遍,只需要熟悉几个地方就够了。首先是最上面的 GameScene.swift 类。在 Xcode中打开 GameScene.swift,看一眼这些代码:

lazy var gameState: GKStateMachine = GKStateMachine(states: [
  WaitingForTap(scene: self),
  Playing(scene: self),
  LevelOver(scene: self),
  GameOver(scene: self)
])

这里创建并初始化了一个 GKStateMachine 对象。这个类属于苹果的 GameplayKit;是一个有限状态机,你可以用来定义游戏的逻辑状态和规则。这里,gameState 变量有 4 中状态:

  • WaitingForTap: 游戏的初始状态
  • Playing: 用户正在玩游戏
  • LevelOver: 完成的最后一关(这是我们要工作的地方)
  • GameOver: 游戏结束,游戏已经输了或者赢了。

要游戏状态在哪里初始化,拉到 didMove(to:)方法底部。

gameState.enter(WaitingForTap.self)

在这里设置了游戏的初始状态,这将是我们旅程的起点。

注意:didMove(to:) 是一个 SpriteKit 方法,属于 SKScene 类。当游戏场景被显示之后立即就会调用这个方法。

游戏开始

接下来需要看的是 GameScene.swift 中的 touchesBegan(_:with:) 方法。

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
  }

代码有点多。我们来挨个过一下这些代码。

  1. 当系统调用 touchesBegan(_:with:) 方法时,gameState.currentState 被设置为 WaitingForTap。这会进入到这个 case 分支,app 将 gameState.currentState 修改成 Playing 状态,isFingerOnPaddle 设置为 true。isFingerOnPaddle 变量用于移动木板。
  2. 第二个 case 分支在游戏处于 Playing 状态时执行。这个状态用于记录用户正在玩游戏并按住了木板。
  3. 当游戏处于 LevelOver 状态时执行这个 case 分支。在这个分支中,会根据 nextLevel 变量加载下一个场景。nextLevel 变量在创建完第一个场景之后就被设置为 2 了。
  4. 当游戏处于 GameOver 状态,它会加载 GameScene1.sks,并重新开始游戏。

这里假设所有的场景都随 app 一起安装。

Bundles

在开始按需加载资源之前,我们来了解一下资源 bundle 是什么。

iOS 用 bundle 将资源组织在 app 内部预先定义好的子目录结构中。你需要用 Bundle 对象来检索要用到的资源;Bundle 对象提供了查找这些资源的唯一接口。你可以把主 Bundle 看成是:

上图显示了当主 bundle 中包含 3 个游戏关卡的样子。

按需加载资源稍有不同。它们不会打包在 app 发布包中。相反,苹果将它们放在苹果服务器上。你的 app 根据需要通过 NSBundleResourceRequest 来下载它们。你需要传递一个 tags 集合给 NSBundleResourceRequest 对象,这个 tags 集合用于表示你想下载的资源。当 app 下载完这些资源,会把它们保存到一个备用的 bundle 中。

在上图中,app 请求李 3 个按需加载资源。系统会下载这些资源并保存到备用 bundle 中。

但是,tag 是什么?

Tag 类型

  • Initial Install Tags: 这些资源当 app 从 App 商店下载时就下载。这些资源会占用 app 在 App 商店中的总文件大小
  • Prefetched Tag Order Tags: 这些资源在 app 安装到用户设备之后下载一次。App 按照它们在 Prefetched Tag Order 组中的顺序下载它们。
  • Downloaded Only On Demand Tags: 这些资源在 app 请求它们时进行下载。

注意:在开发中,你只能使用 Downloaded Only On Demand Tags。你必须将 app 部署到 App 商店或者 TestFlight 才能使用其他 tag 类型。

分配和组织 Tags

首先来看你想打包进 app 中的资源。对于这个游戏来说,至少需要让用户能够玩第一关吧! 不可能让用户一个关卡都没有就开始游戏。

在项目导航器中,选择 Bamboo Breakout 文件组下面的 GameScene2.sks。

打开文件面板。找到 On Demand Resource Tags 一栏:

在为资源设置 tag 时,尽量使用具有明显意义的名词。这会让你的按需加载资源组织良好。对于 GameScene2.sks,它代表的是游戏的第二关,你可以用 level2 作为它的 tag。

在 Tags 中输入 level2 然后回车。

搞定 GameScene2.sks,再用同样方式搞定其余的场景。最后,选择 Bamboo Breakout Target,Resource Tags,然后是 All。你会看到所有你添加的 tag。

NSBundleResourceReuqest 介绍

好,所有的按需加载资源都标记了 tag。接下来编写下载它们的代码。在此之前,先来看一眼 NSBundleResourceRequest 对象:

// 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()

分别说明一下:

  1. 首先是便利初始化方法 init()。它使用了一个 Set,包含了要下载的资源的 tag。

  2. 然后是 loadingPriority 变量。它提供给资源下载系统一个暗示,表明这个请求的加载优先级。取值范围从 0 到 1,1 的优先级最高。默认值为 0.5.

  3. tags 变量包含了这个对象将要请求的 tag 的集合。
  4. bundle 代表之前提到的备用 bundle。这个 bundle 用于保存下载的资源。
  5. beginAccessingResources 方法开始请求资源。调用方法时需要传入一个带 Error 参数的完成闭包。
  6. Progress 对象。通过这个对象了解下载状态。在本 app 中不会使用这个对象,因为资源不大而且下载很快就完成了。但我们需要知道这个变量。
  7. 最后是 endAccessingResources 方法,告诉系统你不再需要这些资源了。告诉系统可以从设备上删除这些资源了。

使用 NSBundleResourceRequest

了解李 NSBundleResourceRequest 之后,你可以用一个工具类来负责资源的下载了。

新建 Swift 文件,名为 ODRManager。编辑内容为:

import Foundation

class ODRManager {

  // MARK: - Properties
  static let shared = ODRManager()
  var currentRequest: NSBundleResourceRequest?
}

这个类包含了一个对自身的引用(实现单例),以及一个 NSBundleResourceRequest 变量。

然后,需要一个开始 ODR 请求的方法。在 currentRequest 后面添加:

// 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()
  }
}

代码解释如下:

  1. 首先是方法的定义。这个方法有 3 个参数。第一个参数是需要下载的 ODR 资源的 tag。第二个参数是成功回调,第三个参数是错误回调。
  2. 然后创建一个 NSBundleResourceRequest 对象以执行请求。
  3. 判断对象是否创建成功,如果创建成功,调用 beginAccessingResources() 进行请求。
  4. 如果请求失败,app 进入错误回调,这表示你无法使用这些资源。
  5. 如果没有任何错误,app 执行成功回调。这时,app 可以假定资源可以使用。

下载资源

然后是使用这个类。打开 GameScene.swift,找到 touchesBegan(_:with:) 方法,将 LevelOver 分支修改为:

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)
    })

乍一看,代码好像很复杂,但其实并不复杂。

  1. 首先,用 ODRManager 的共享实例来调用 requestSceneWith(tag:onSuccess:onFailure:) 方法。传入下一关的 tag、成功回调、错误回调。
  2. 如果请求成功,创建下载到的游戏场景并呈现给用户。
  3. 如果请求失败,创建一个 UIAlertController,提示用户有错误发生。

查看设备磁盘空间

修改完成之后,Build & run。如果你能通过第一关,游戏会暂停。你会看到:

你可能需要插上你的手机再来玩这个游戏,因为在模拟器上不好操作。确保保持设备和 Xcode 的连接。

打完第一关之后,当游戏暂停,点击屏幕。你会看到:

打开 Xcode,打开 Debug 导航器,选择 Disk。这里,你会看到所有 ODR 资源在 app 中的状态:

这里,app 只下载了第二关,它的状态是 In Use。继续游戏的其它机关,并随时查看磁盘空间的用量。你会看到每个资源在必要的时候会自动下载。

最佳实践

有几个地方可以改善用户体验。你可以改进错误提示,修改下载优先级以及在资源不再需要时删除它。

错误处理

在之前的例子里,当出现错误时,app 会简单地说一句“这有一个错误”。这对用户来说没有任何价值。

你可以提供更好的体验。打开 GameScene.swift,在 touchesBegan(_:with:) 方法, 将 onFailure case 分支修改为:


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)        
})

稍微看一下有改动的地方。代码虽然多,但主要的改变是添加了一个 switch 语句。它会测试请求对象所返回的错误 code。根据错误码的实际值,app 会提示不同的错误信息。这要好得多。你可以大概看一下这些错误类型。

  • NSBundleOnDemandResourceOutOfSpaceError 错误在用户设备控件不足,无法下载请求的资源时发生。这会告诉用户清理空间,然后重试。
  • NSBundleOnDemandResourceExceededMaximumSizeError 错误在资源超过该 app 按需加载资源的最大内存限制时发生。这会允许用户去清理部分资源。
  • NSBundleOnDemandResourceInvalidTagError 在所请求的资源 tag 无法找到时发生。这可能是一个 Bug,你应该去确认一下正确的 tag 名称是什么。

加载优先级

第二个改进是设置请求加载的优先级。这只需要改变一行代码。

打开 ODRManager.swift 在 requestSceneWith(tag:onSuccess:onFailure:) 方法 guard let request = currentRequest else { return } 一句之后添加:

request.loadingPriority = NSBundleResourceRequestLoadingPriorityUrgent

NSBundleResourceRequestLoadingPriorityUrgent 告诉操作系统尽可能下载指定内容。对于下载游戏的下一个关卡来说,这是非常迫切的。你不想让用户等待。注意,如果你想自定义加载优先级,你可以使用 0 到 1 之间的小数。

清理资源

你可以通过当前 NSBundleResourceRequest 对象调用 endAccessingResources 方法来删除不再需要的资源。

仍然是在 ODRManager.swift 中,在 guard let request = currentRequest else { return } 一句之后添加:

// 清理当前请求所包含的资源
request.endAccessingResources()

调用 endAccessingResources 清理自己用过的东西和不再需要的资源。现在,你变成了一个 iOS 的良好公民,自己的垃圾自己清理。 Y

结束

这里下载完成后的项目。

希望你已经学会如何使用按需加载资源减少 app 大小以及如何让用户心情更加愉悦。

关于按需加载资源的更多内容,请参考 wwdc 2016 的精彩视频:优化按需加载资源

有任何问题和评论,请在下面留言!

上一篇:Android Mediacodec H.265文件播放 下一篇:MapKit 进阶教程: 自定义瓦片