關於 web service, unity, blogger 等軟體工程筆記

Unity AssetBundle 快取機制與載入範例

Edit icon 沒有留言
Unity

前陣子看到有人詢問關於 AssetBundle 載入以及其快取機制 (caching) 的處理問題,因此整理這篇筆記,供之後給團隊解說與教學之用。

使用 UnityWebRequest 載入 AssetBundle

Assetbundle 載入方式目前已知有三種,前兩種通常是有特殊需求才會使用到,例如自定義 Assetbundle 加解密的保護機制,否則較常使用會是第三種方式:

  • 從檔案,AssetBundle.LoadFromFile
  • 從記憶體,AssetBundle.LoadFromMemory
  • 從網路位置,UnityWebRequest.GetAssetBundle

記錄最常使用的 UnityWebRequest,配合 Coroutine,一個簡單載入 AssetBundle 的範例;

// using System.Collections;
// using UnityEngine;
// using UnityEngine.Networking;

IEnumerator LoadAssetBundle(string url)
{
   var request = UnityWebRequest.GetAssetBundle(url);
   yield return request.Send();
   if (request.isError)
   {
      // TODO: Handle error, request.error
      yield break;
   }
   var assetBundle = DownloadHandlerAssetBundle.GetContent(request);
}

如果載入完成後,發現已經沒有必要取得該 AssetBundle 資源,應該要強制釋放其資源:

request.Dispose()

使用 AssetBundle 的載入快取 (caching)

UnityWebRequest 本身有實作快取,當用戶端載入 AssetBundle 後,Unity 會將該 AssetBundle 另存一份到檔案系統裡頭,當下一次要求載入其 AssetBundle 時,Unity 若發現檔案系統有該 AssetBundle,便直接從檔案中載入,而不用再透過網路下載載入。

使用快取機制很簡單,先確認快取已經準備好:

while (!Caching.ready)
{
   yield return null;
}

要求載入時,多加入 Hash 參數即可,若要驗證 AssetBundle 檔案可多帶入 CRC:

var request = UnityWebRequest.GetAssetBundle(url, hash, crc);

Hash 以及 CRC 參數,可以在建置後的 manifest 找得到,或是使用 Script 去計算:

// using UnityEditor;

Hash128 hash;
uint crc;

BuildPipeline.GetHashForAssetBundle(assetBundlePath, out hash);
BuildPipeline.GetCRCForAssetBundle(assetBundlePath, out crc);

至於快取放在哪裡,經過實測發現快取會存在這個位置:

Application.persistentDataPath + "/UnityCache/Shared/"

若要檢查之前是否已經有快取,可以以下方式檢查:

Caching.IsVersionCached(url, hash)

取得載入 AssetBundle 實際檔案大小

一般載入進度 0%-100% 可以從 AsyncOperator 拿得到:

var request = UnityWebRequest.GetAssetBundle(url);
var op = request.Send();
while (!op.isDone)
{
  var progress = op.progress;
  // TODO: Display progress [0-1]
}

但存在一個問題是,該載入進度已經正規化 (normalized) 成 0-1 的數值,當多個 AssetBundles 載入時,其總進度會因為缺少 AssetBundle 的檔案大小數值,計算上會有些問題 (假設每個 AssetBundles 檔案大小都不同)。

較好的方式會是先取得 AssetBundle 的檔案大小,然後再開始進行加載,但問題是如何取得 AssetBundle 的檔案大小?這時候就要靠 HTTP 通訊協定中的 HEAD 來取得,若不清楚該通訊協定,可以參考這篇筆記 HTTP 淺談

long fileSize = 0;
using (var headRequest = UnityWebRequest.Head(url))
{
  yield return headRequest.Send();
  if (headRequest.responseCode != 200)
  {
  // TODO: Error response
  }
  else
  {
  var contentLength = headRequest.GetResponseHeader("CONTENT-LENGTH");
  long.TryParse(contentLength, out fileSize);
  }
}

實作載入多個 AssetBundles 範例

以下提供一個較完整使用 Coroutine 的載入範例,首先先定義 AssetBundleLoader 以及主要的回傳資料結構,用於傳遞進度條以及載入的 AssetBundles 資料:

using System.Collections;
using UnityEngine;

public static partial class AssetBundleLoader
{
   public class AsyncResult : IEnumerator
   {
      private bool done = false;

      public AssetBundle[] AssetBundles
      {
         get;
         internal set;
      }

      public float Progress
      {
         get;
         internal set;
      }

      public bool Done
      {
         get
         {
            return done;
         }

         internal set
         {
            if (!done)
            {
               done = true;
               Progress = 1.0f;
            }
         }
      }

      public bool IsError
      {
         get
         {
            return this.Error != null;
         }
      }


      public Exception Error
      {
         get;
         internal set;
      }

      object IEnumerator.Current
      {
         get
         {
            throw new NotImplementedException();
         }
      }

      bool IEnumerator.MoveNext()
      {
         return !this.Done;
      }

      void IEnumerator.Reset()
      {
         throw new NotImplementedException();
      }
   }
}

可以注意到的是 AssetBundleLoader 是 static class,並沒有繼承 MonoBehaviour,由於需要 Coroutine 的控制手段,因此透過以下方式,先建立一個 Worker,之後使用 Work.StartCoroutine 來開始新的 Coroutine 工作:

using UnityEngine;

public static partial class AssetBundleLoader
{
   static LoaderWorker worker = null;
   static LoaderWorker Worker
   {
      get
      {
         if (worker == null)
         {
            var go = new GameObject("AssetBundleLoader");
            worker = go.AddComponent<LoaderWorker>();
            GameObject.DontDestroyOnLoad(go);
            go.hideFlags = HideFlags.DontSave | HideFlags.HideInHierarchy;
         }

         return worker;
      }
   }
}

定義主要的載入函數宣告 LoadAssetBundles:

using System;
using System.Collections;
using UnityEngine;

public static partial class AssetBundleLoader
{

   public static AsyncResult LoadAssetBundles(string[] urls)
   {
      var result = new AsyncResult();
      Worker.StartCoroutine(LoadAssetBundleCoroutine(urls, result));
      return result;
   }

   static IEnumerator LoadAssetBundleCoroutine(string[] urls, AsyncResult result)
   {
      throw new NotImplementedException();
   }
}

因此對於主流程載入多個 AssetBundles 變成較為簡單:

IEnumerator LoadAssetBundles(string[] urls)
{
   var r = AssetBundleLoader.LoadAssetBundles(urls);
   yield return r;
   if (r.IsError)
   {
      // TODO: Error handle, r.Error
      yield break;
   }
   // TODO: Handle assetbundles, r.AssetBundles
}

若要處理進度調顯示,也可以調整成以下方式:

IEnumerator LoadAssetBundles(string[] urls)
{
   var r = AssetBundleLoader.LoadAssetBundles(urls);
   while (true)
   {
      // TODO: Progress display, r.Progress
      if (r.Done) break;
      yield return null;
   }

   if (r.IsError)
   {
      // TODO: Error handle, r.Error
      yield break;
   }
   // TODO: Handle assetbundles, r.AssetBundles
}

回到 AssetBundleLoader 的主要 Coroutine 實作,原則上分成兩個步驟,第一個步驟送出 HEAD 取得 AssetBundles 檔案大小,順便檢查是否遠端伺服器有正常回應,若回傳非 HTTP 200,那麼表示一定有問題則直接回傳錯誤 (例如收到 HTTP 404 找不到該資源)。而第二步驟則是實際載入 AssetBundles:

static IEnumerator LoadAssetBundleCoroutine(string[] urls, AsyncResult result)
{
   var filesizes = new long[urls.Length];
   var requests = new UnityWebRequest[urls.Length];
   var operations = new AsyncOperation[urls.Length];

   // Phase1: Get assetbundles file-size
   for (var i = 0; i < urls.Length; i++)
   {
      requests[i] = UnityWebRequest.Head(urls[i]);
      operations[i] = requests[i].Send();
   }

   while (true)
   {
      var done = true;
      for (var i = 0; i < operations.Length && done; i++)
      {
         done = done && operations[i].isDone;
      }

      if (!done)
      {
         yield return null;
      }
      else
      {
         break;
      }
   }

   // Check error
   for (var i = 0; i < requests.Length; i++)
   {
      var request = requests[i];
      if (request.isError)
      {
         result.Done = true;
         result.Error = new Exception(request.error);
         yield break;
      }
      else if (request.responseCode != 200)
      {
         result.Done = true;
         result.Error = new Exception(request.url + ", HTTP " + request.responseCode);
         yield break;
      }
      else
      {
         var header = request.GetResponseHeader("CONTENT-LENGTH");
         long.TryParse(header, out filesizes[i]);
      }
   }

   // Phase2: Load assetbundles
   var totalSize = 0L;
   for (var i = 0; i < urls.Length; i++)
   {
      requests[i] = UnityWebRequest.GetAssetBundle(urls[i]);
      operations[i] = requests[i].Send();
      totalSize += filesizes[i];
   }

   while (true)
   {
      var done = true;
      var loadedSize = 0L;
      for (var i = 0; i < operations.Length; i++)
      {
         var operation = operations[i];
         done = done && operation.isDone;
         loadedSize += (long)(filesizes[i] * (double)operation.progress);
      }

      result.Progress = (float)((double)loadedSize / totalSize);
      if (!done)
      {
         yield return null;
      }
      else
      {
         break;
      }
   }

   // Get assetBundles
   var assetBundles = new AssetBundle[urls.Length];
   for (var i = 0; i < requests.Length; i++)
   {
      var request = requests[i];
      if (request.isError)
      {
         result.Done = true;
         result.Error = new Exception(request.error);
         yield break;
      }

      assetBundles[i] = DownloadHandlerAssetBundle.GetContent(request);
   }

   result.Done = true;
   result.AssetBundles = assetBundles;
}

也許會問這個範例沒有處理快取?如果要啟用快取,那麼傳入的參數可就不能只有字串 Url,而是要調整成以下方式,那會更加複雜一些:

public struct AssetBundleLoadRequest
{
   public string Url;
   public UnityEngine.Hash128 Hash;
   public uint Crc;
}

public static AsyncResult LoadAssetBundles(AssetBundleLoadRequest requests)
{
   ...
}

系列文章

Reference

沒有留言: