1. 程式人生 > >iOS 按需載入資源教程

iOS 按需載入資源教程

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 的精彩視訊:優化按需載入資源

有任何問題和評論,請在下面留言!