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

Implement web-request cache in Unity

Edit icon 沒有留言

工作上需求,在遊戲開始前總是會根據伺服器資料,透過 HTTP 下載指定圖片並顯示在遊戲中,這些圖檔下載若沒有 Cache (快取,緩存),長時間下來流量也是相當可觀,因此怎麼建立圖片快取,這篇紀錄思考流程以及最後的解解決方案,並提供程式碼。

注意:Unity 5.5.x 中實作並測試

UnityEngine.Networking Cache 的支援

從網站上下載圖片,第一個想到的方案是 UnityWebRequest.GetTexture,但對於快取的支援,根據平台的不同而有所不同。若是在 WebGL 平臺上,瀏覽器 (Browser) 會幫忙處理 Cache。但在我們目標的手機平台 (Android & iOS) 卻沒有內建支援。

另一個可能的方案是 UnityWebRequest.GetAssetbundle,將圖片打包成 assetbundle 後,再讓遊戲下載使用。但之後營運更新圖片還得使用 Unity,這有點麻煩,而且還需要紀錄圖片包 (texture assetbundles) 所使用的 CRC 以及 Hash,這實在是複雜啊,因此放棄這方案。

既然以上兩種都沒有支援在手機平臺上的 Cache,那麼只能回歸到 HTTP protocol 的規格定義,看看有沒有存在快取機制的說明。

提示:對於 HTTP 不是很了解,可以參考之前的文章 HTTP 淺談

幸運的是,很輕易的在 HTTP 1.1 規格中,發現 ETag 以及 If-None-Match 這兩個標頭 (headers) 可以用來實作 cache 機制。

有支援這功能的 HTTP 伺服器 (我們使用 AWS S3),在回應圖片資源要求時,除了圖片資料外 (response body),在標頭 (response headers) 中也會添加這個檔案的 ETag,類似指紋的東西,只有當該檔案有所改變,其 ETag 才會調整。

當送出要求 (request) 並帶有 If-None-Match 標頭並附上前次下載的 ETag 時,伺服器會先檢測 ETag,是否與目前的資源所算出的 ETag 相同。若相同,僅回應 HTTP 304 以及標頭,表示改資源沒有更動,其內容 (response body) 則不傳輸。反之不同時,則按照一般要求,送出標頭以及資源內容。

透過這個機制,建立本機快取 (local cache),以達到減少傳輸量的目的。

要求流程示意圖

要求流程示意圖,其中 ETag 為伺服器產生的標示,Body (Raws) 標示原始圖片資料,使用此方式減少 Body 資料傳輸

實作自己的快取機制

了解 HTTP ETag 機制後,接下來要處理是本地端的 cache 如何實作,快取檔案放哪裡。了解到 Unity 對於 assetbundle 有自己的 cache,並可以使用 UnityEngine.Caching.CleanCache() 來清空本地端的快取資源。因此想說將自建快取資料跟 Unity 的快取資料放在一起,呼叫同一個函數清除快取。

從實務了解存放位置:

var cacheRootPath = Application.persistentDataPath + "/UnityCache/Shared/"

因此定義自訂快取放置在此位置,呼叫 Caching.CleanCache 也會一併刪除:

var webCacheRootPath = Application.persistentDataPath + "/UnityCache/Shared/WebCache"

實作此 cache 需要記錄三種資訊:

  • Url (網址)
  • ETag
  • Response body (圖片內容)

我們將網址編碼後,當做資料夾名稱建立資料夾,裡頭存放該網址的 ETag 以及圖片內容,因此網址編碼使用 Sha1 + Base64 實作:

static string GetCachePath(string url)  {
   var bytes = System.Text.UTF8Encoding.Default.GetBytes(url);
   var sha1 = new System.Security.Cryptography.SHA1CryptoServiceProvider();
   var hashBytes = sha1.ComputeHash(bytes);
   var base64 = System.Convert.ToBase64String(hashBytes);
   var name = base64.Replace('/', '_');
   return Application.persistentDataPath + "/UnityCache/Shared/WebCache/" + name + "/";
}

模仿 Unity 存放 assetbundle 的資料,資料夾內的 __data 放置圖片內容, __info 存放 ETag:

static void SaveCache(UnityWebRequest www, string etag, byte[] raws) {
   const string DataName = "__data";
   const string InfoName = "__info";

   var path = GetCachePath(www.url);
   var infoPath = path + InfoName;
   var dataPath = path + DataName;
   if (!Directory.Exists(path))
   {
      Directory.CreateDirectory(path);
   }

   using (var infoStream = File.CreateText(infoPath))
   {
      infoStream.Write(etag);
   }
   using (var dataStream = File.CreateText(dataPath))
   {
      dataStream.BaseStream.Write(raws, 0, raws.Length);
   }
}

處理這樣的 cache request,我們最後建立以下呼叫的機制,能夠處理 cache 下載圖片:

using UnityEngine.Networking;

IEnumerator DownloadImageCoroutine(string url)
{
   var www = UnityWebRequestUtility.GetTexture(url);
   yield return www.Send();
   if (!www.isError)
   {
      var texture = DownloadHandlerTextureCache.GetContent(www);
      // TODO: Display texture
   }
}

完整實作:

namespace UnityEngine.Networking
{
   using System.IO;
   using System.Security.Cryptography;
   using System.Text;
   using UnityEngine;

   public static class UnityWebRequestUtility
   {
      public static UnityWebRequest GetTexture(string url)
      {
         var www = new UnityWebRequest(url, "GET");
         var etag = CacheUtility.GetCacheEtag(url);
         if (etag != null)
         {
            www.SetRequestHeader("If-None-Match", etag);
         }

         www.downloadHandler = new DownloadHandlerTextureCache(www);
         return www;
      }
   }

   static class CacheUtility
   {
      const string DataName = "__data";
      const string InfoName = "__info";

      public static string GetCachePath(string url)
      {
         var bytes = UTF8Encoding.Default.GetBytes(url);
         var sha1 = new SHA1CryptoServiceProvider();
         var hashBytes = sha1.ComputeHash(bytes);
         var base64 = System.Convert.ToBase64String(hashBytes);
         var name = base64.Replace('/', '_');
         return Application.persistentDataPath + "/UnityCache/Shared/WebCache/" + name + "/";
      }

      public static string GetCacheEtag(string url)
      {
         var path = GetCachePath(url);
         if (Directory.Exists(path))
         {
            var infoPath = path + InfoName;
            var dataPath = path + DataName;
            if (File.Exists(infoPath) && File.Exists(dataPath))
            {
               var f = File.ReadAllText(infoPath);
               return f;
            }
         }
         return null;
      }

      public static byte[] LoadCache(string url)
      {
         var dataPath = GetCachePath(url) + DataName;
         var raws = File.ReadAllBytes(dataPath);
         return raws;
      }

      public static void SaveCache(string url, string etag, byte[] raws)
      {
         var path = GetCachePath(url);
         var infoPath = path + InfoName;
         var dataPath = path + DataName;

         if (!Directory.Exists(path))
         {
            Directory.CreateDirectory(path);
         }

         using (var infoStream = File.CreateText(infoPath))
         {
            infoStream.Write(etag);
         }

         using (var dataStream = File.CreateText(dataPath))
         {
            dataStream.BaseStream.Write(raws, 0, raws.Length);
         }
      }
   }

   public class DownloadHandlerTextureCache : DownloadHandlerScript
   {

      Texture2D tex;
      bool completed = false;
      UnityWebRequest www;
      MemoryStream stream;

      internal DownloadHandlerTextureCache(UnityWebRequest www) : base()
      {
         this.www = www;
         this.stream = new MemoryStream();
      }

      internal DownloadHandlerTextureCache(UnityWebRequest www, byte[] preallocateBuffer) : base(preallocateBuffer)
      {
         this.www = www;
         this.stream = new MemoryStream(preallocateBuffer.Length);
      }

      public static Texture2D GetContent(UnityWebRequest www)
      {
         return DownloadHandler.GetCheckedDownloader(www).texture;
      }

      public Texture2D texture
      {
         get
         {
            if (!completed)
            {
               throw new System.InvalidOperationException("Not downloaded");
            }

            if (this.tex == null)
            {
               var url = www.url;
               var raws = (byte[])null;
               if (this.www.responseCode == 304)
               {
                  raws = CacheUtility.LoadCache(url);
               }
               else if (this.www.responseCode != 200)
               {
                  return null;
               }
               else
               {
                  raws = this.stream.GetBuffer();

                  var etag = www.GetResponseHeader("Etag");
                  CacheUtility.SaveCache(url, etag, raws);
               }

               this.tex = new Texture2D(1, 1);
               this.tex.LoadImage(raws);
            }
            return this.tex;
         }
      }

      protected override byte[] GetData()
      {
         return null;
      }

      protected override bool ReceiveData(byte[] data, int dataLength)
      {
         this.stream.Write(data, 0, dataLength);
         return true;
      }

      protected override void CompleteContent()
      {
         base.CompleteContent();
         this.completed = true;
      }
   }
}

已知議題

  • 沒有驗證快取資料的機制

    不像 Unity 的 assetbundle,有針對本地檔案進行 CRC 檢查,有檢查成功才從本地快取中取得 assetbundle,但由於這套方案本身沒有提供 CRC 檢查的機制,所以這部分檢查驗證快取就跳過。此外,圖片僅僅顯示,若使用者自己手動修改快取檔案,對遊戲核心機制也不會造成影響。

  • 沒有建立刪除快取的機制

    定期刪除太久沒有使用快取的檔案,已確保足夠的快取容量。這部分未來有機會再實作。

  • 僅實作圖片下載

    目前僅實作下載圖片的 DownloadHandlerTextureCache,未來有需要,再實作聲音等其他資源下載並建立快取方式。之後使用 CacheUtility 擴充是相當容易的事情。

Reference

沒有留言: