1. 程式人生 > 其它 >Unity資產管理與更新系統的一種實現方式

Unity資產管理與更新系統的一種實現方式

一、概況

這個實現來自於我的個人開源專案 UnityGameWheels(以下簡稱 UGW),並已在實際生產中有一定的應用。UGW 的程式碼地址:

Core:純C#部分。其中資產管理和更新相關內容位於Asset。

Unity:和Unity結合的部分。其中資產管理和更新相關內容位於Asset,編輯器相關位於Editor。

Demo:一些示例程式碼。

此間一些設計方式參考了我一位老友的GameFramework。此外,玩具程式碼頗多(比如有個玩具版IOC容器),請見諒並無視。

1.1 企圖

  • 希望為移動平臺(主要是iOS和Android系統)實現具有一定通用性的資產管理與更新系統。
  • 在使用時不必過多顧及資產包(AssetBundle),而是關注單個資產(Asset)。
  • 對更新的內容,做出一定程度的分組,實現邊玩邊下。

1.2 名詞

  • 資產和資產包:即Unity中的Asset和AssetBundle。

  • 兩種模式:

    • 編輯器模式:在編輯器下開發時,通過UnityEditor.AssetDatabase中的方法直接訪問資產檔案。
    • 資產包模式:構建資產包使用的模式。這種模式為後文的主要討論物件。
  • 資源(Resource):在Core中指資產包。這用法也來自GameFramework。

  • 索引(Index)檔案:專指收集、記錄資產和資產包基本資訊(類似於Unity提供的資產包manifest檔案的功能)的檔案。

    • CR:安裝包索引檔案。
    • RR:遠端索引檔案。
    • PR:持久化索引檔案。
  • Manifest 檔案:Unity構建資產包時生成的資料檔案,包含資產包和資產的關係以及資產包間的依賴關係。

  • 資產系統:指本文所描述的資產管理和更新系統。

1.3 主要組成部分

  • Core(純C#)部分

  • AssetService類(實現IAssetService介面)是資產包模式的主入口,提供資產管理與更新的入口。

    • 通過Prepare方法來進行資產系統的準備工作。
    • 通過CheckUpdate方法來檢查是否需要進行更新以及哪些內容需要更新。
    • 通過IResourceUpdater介面(實現為AssetService.ResourceUpdater)來進行資產包(資源)的更新。
    • 通過LoadAssetLoadSceneAssetUnloadAsset等方法來載入和解除安裝資源。
  • Unity 部分:

    • Asset 資料夾提供依賴於 Unity 庫的實現,和編輯器模式下使用的IAssetService8的實現。
    • Editor/AssetBundle 資料夾提供構建資產包相關的編輯器工具。

二、一些重要概念

2.1 資產包的分組
在Unity中,每個資產檔案至多顯式打入一個資產包,所以對資產包分組(Group),就相當於對顯式打入資產包的每個資產都分組。為什麼要分組呢?一方面,是為了按組為單位做資產包更新;另一方面,是控制依賴關係的複雜度。

使用非負整數來標記每個資產包的組號。

  • 0 代表公共組,可以被其他組依賴。
  • 正整數代表其他組,不允許組間依賴,但是都可以依賴 0 組。

在分組更新的基礎上,這樣的限制帶來的好處是,不需要為了更新一個分組的內容而大量更新其他分組的內容。當然,這種選擇同時也是一種侷限。

在應用啟動過程中,“正式”進入遊戲之前,應將 0 組的內容更新完畢。

2.2 索引檔案
Unity自身在構建資產包時,提供了manifest檔案,用於指明每個資產包中包含哪些資產以及依賴於哪些其他資產包。索引檔案,在此基礎上加入了包括資產之間的依賴關係、資產包分組(後文解釋)在內的若干其他資訊。編輯器工具構建資產包時會生成三個資料夾:

  • Client:用於放在StreamingAssets中、打入首包的資產包;
  • ClientFull:用於放在StreamingAssets中的全量資產包,適合除錯或者關閉更新功能的情形。
  • Server:用於放在CDN上用於更新的全量資產包。

這三個資料夾中各自會有一個索引檔案。前兩者自然格式一致,稱為安裝包索引檔案(記為CR,其中C代表Client,R代表Resource),隨首包釋出。Server資料夾中的索引檔案稱為遠端索引檔案(記為RR,其中第一個R代表Remote)。在資源更新和使用的過程中,本地持久化目錄中會存放一份索引檔案,稱為持久化索引檔案(記為PR,其中P代表Persistent),它記載的是本地儲存的那些資產包的資訊。

注意,在Server資料夾中的每個檔案都會後綴它自身的CRC-32校驗和,用於下載之後的校驗。

2.3 版本號
資產系統使用的資產包版本號包括兩部分,是由應用程式版本號VerApp (其實是UnityEngine.Application.version的值)和資源內部版本號 VerRes 拼接而成,對於每一個VerApp,在每個平臺上,打包的時候VerRes最好從 1 開始自增。如VerApp為 1.0.1,在這個應用程式版本下,Android平臺第19次資產包構建,其版本號為1.0.1.19。如果應用程式版本升為1.1.0,則再度打Android資源包的時候版本號就是1.1.0.1。這也是後文講的資產包構建器的預設行為。

應用程式執行時,如果開啟了資源更新,則本系統只是根據輸入的資訊來判定應該下載哪個RR,而不會去檢查版本的新舊。標準的做法,是應用程式從某個伺服器獲取當前VerApp 對應的最新的VerRes,以及相應的檔案尺寸、CRC-32 等資訊,來判定是否需要下載這個版本的RR。

三.更新資產包

3.1 初始化和準備階段
構造AssetService物件時需要傳入一些配置資訊,包括但不限於CDN伺服器的根目錄、同時進行的資產載入任務數量限制、同時進行的資產包載入任務數量限制等內容。

系統初始化之後,通過AssetService.Prepare方法進行的準備工作,其實就是要把CR和PR從各自所在的檔案系統中載入記憶體。CR是必須要存在的,而PR一開始的時候不存在,就認為存在一個空的PR。

3.2 更新檢測階段
在準備階段完成之後,就要通過AssetService.CheckUpdate來檢測是否有需要更新的內容。這裡需要傳入一個AssetIndexRemoteFileInfo(索引檔案資訊)物件,是使用者從相關伺服器獲取的關於RR的資訊,其中包括如下一些欄位:
<img src='remote_index_info.jpg' height=150/>

其中nternalAssetVersion就是前面所說的VerRes,指這個RR對應的資產包版本,Crc32是該RR的CRC-32校驗和,FileSize是該檔案的大小(位元組)。後面這兩個欄位都是為了下載之後的校驗。

更新檢測又有幾種情況。

  • 如果關閉了更新,則直接使用CR作為PR。此時,認為安裝包中StreamingAssets目錄下的內容是完整可用的(即從前述之ClientFull資料夾複製而來,如果之前下載了任何資源,我們都認為是沒用的。
  • 如果開啟更新,且本地快取的RR的Crc32FileSize均和AssetIndexRemoteFileInfo中提供的資料一致,說明不需要從伺服器下載RR,用本地快取的即可。
  • 其餘情況,需要從遠端下載RR。UGW中有支援檔案下載系統的實現,超出本文範疇,不贅述。

對於上述後兩種情形,系統會對CR、RR和PR做三方比較,來決定哪些資產包是需要下載的,哪些資產包是需要(從持久化目錄刪除的)。具體地:

  • RR中沒有的資產包(說明已經沒用了),如果PR中有,則應該從本地持久化目錄中刪除。
  • RR中有和CR中相同(通過比較Unity生成的Hash值和檔案尺寸來決定)的資產包,則刪去PR中包含的那個版本(如果有的話)。
  • 對RR中有,但是CR中缺少或內容不同(通過比較Unity生成的Hash值和檔案尺寸來決定)的資產包,需要更新。

在三方比較的同時,系統還會對每個資產包分組構造資產包更新摘要資訊。這摘要由ResourceGroupUpdateSummary類描述,包含其所指向的資產包分組中的資產包總量、剩餘下載量等資訊。這些摘要物件將用於後面的資產包更新。

3.3 更新
前述準備工作完成後,就可以使用AssetService.ResourceUpdater更新器物件進行更新了,通過它(實現IResourceUpdater介面)可以:

  • 獲取可用的資產包分組都有哪些。
  • 對給定的資產包分組,獲取其中資源狀態(需要更新、正在更新、已經最新)。
  • 對某一組的資產包開始、停止更新;
  • 通過前述ResourceGroupUpdateSummary類,獲取各組資產包更新進度和狀態(是否在更新、是否已經最新等)。

更新資產包的過程中,會更新PR中的內容並在適當的時候儲存到持久化目錄中。對於每個資產包分組,一定要全部更新完才可使用其中的內容。

四.使用資產

資產系統中提供了一些輔助方法,來判定資產是否已經可以使用,也就是判斷資產的存在性、以及所屬的資產包分組是否已經更新完畢。在此基礎上,使用者可以使用(邏輯層面的)載入、解除安裝介面來使用和釋放資產。

4.1 載入介面與資產訪問器
AssetService提供LoadAssetLoadSceneAsset方法來載入一般資產和場景資產。鑑於後者沒有進行仔細測試,此處暫時僅對前者做出說明。LoadAsset的函式簽名為

IAssetAccessor LoadAsset(string assetPath, LoadAssetCallbackSet callbackSet, object context);

使用者將資產路徑(從 "Assets/" 開始)、回撥函式和可選的自定義上下文物件傳入,即可同步地獲得一個IAssetAccessor,即資產訪問器(簡稱AA)。AA的引入,是由於載入資產操作在概念上是非同步的(儘管由於內部快取等原因可能實際上是同步完成的)。如果在載入未完成的情況下,使用者不想用這個資產了,通過這個訪問器可以解除安裝資產。通過IAssetAccessor介面,使用者可以獲取資產路徑、資產物件(如果已經載入完成)以及其狀態。

一般情況下,任何使用某一資產的程式碼,都應通過LoadAsset獲得一個該資產的訪問器。資產和訪問器是一對多的關係。

4.2 解除安裝介面
AssetService提供UnloadAsset方法來(從邏輯上)解除安裝資產。

void UnloadAsset(IAssetAccessor assetAccessor);

解除安裝資產時,只需要傳入AA即可。要注意,一個資產訪問器只允許解除安裝一次。解除安裝之後,就不可再使用/引用這個AA物件,否則可能造成很難查詢的bug。

4.3 內部實現的基本資料模型
AssetService內部,用資產快取(AssetService.Loader.AssetCache內部類,簡稱ACache)來描述一個資產,用資產包快取(AssetService.Loader.ResourceCache內部類,簡稱RCache)來描述一個資產包。這兩種快取內部都儲存了自己代表的資產(包)的引用計數。

首先,一個ACache可對應多個資產訪問器。每個AA都繫結一個ACache,ACache的狀態變化會反映到訪問器中。

其次,ACache內部會記錄它所代表的資產依賴於哪些其他資產和資產包(從索引檔案PR中獲得),這些資訊用來維護ACache和RCache的引用計數,最終決定資產和資產包的何時釋放。這裡要注意,單獨看ACache的時候,它們構成有向無環圖(即不允許資產間的依賴構成環路)。而即使有資產包分組間的依賴關係限制,和資產間不允許依賴成環路的限制,RCache之間仍然可能構成環路,如下圖所示(實線代表依賴關係,虛線代表資產和資產包的從屬關係)。

由於上圖中資產a依賴於資產c,c又依賴於依賴於資產b,而a、b屬於資產包x,c屬於資產包y,因此x和y是相互依賴的。

注意:AA、ACache和RCache實際上都有相應的物件池來管理,以便減少執行時的GC Alloc。

4.4 載入資產的過程
當嘗試(通過檔案路徑)載入一個資產的時候(即呼叫AssetService.LoadAsset方法時),如果沒有相應的ACache物件,則從物件池獲取一個或建立一個;否則,這資產應該已經被要求載入過,直接使用已有的ACache物件即可。不論哪種情況,一個AA將和這個ACache繫結(並增加ACache的引用計數使之一定為正的)並同步返回。

ACache初始化的時候,會做以下事情:

  • 遞迴的初始化它依賴的資產的ACache(如果需要的話),增加後者的引用計數,並觀察後者的狀態變化。由於ACache 構成有向無環圖,所以簡單遞迴即可完成這步操作。
  • 初始化自身指向的資產所在的資產包的RCache物件(如果需要的話),增加後者的引用計數,並作為後者狀態變化的觀察者。
  • 從自身所屬資產包的RCache物件出發,在RCache構成的圖結構中做遍歷,增加過程中每個RCache的引用計數。

由於依賴關係相關問題都在ACache中處理,RCache的業務相對簡單,只是負責自身指向的資產包的載入和傳送狀態變化的通知給觀察者。

ACache會等待自己代表的資產所屬的資產包的 RCache 載入完成,以及自己依賴的其他ACache載入完成,之後再載入自身代表的資產。於是,只要一個ACache載入完成(其資產物件對所有繫結到自身的AA都已可用),它所依賴的(顯示打資產包的)資產都載入完成了,於是相關聯的資產包也是載入完成了的。

使用者需要注意:

  • 本資產系統中,載入失敗即為錯誤情況,不可繼續使用。使用者在載入一個資產時,需要確定它是可用的,比如資產本身是否存在、所在資產包分組是否更新完畢等。
  • 某些Android裝置上,檔案IO很容易出現問題,儘管Unity層的實現(ResourceLoadingTaskImpl類)增加了重試機制,仍然可能在從檔案建立資產包的時候失敗(連續失敗多次)。目前只能降低同時載入的資產包的數量限制來減少出問題的概率。

4.5 解除安裝資產的過程
解除安裝資產時(AssetService.UnloadAsset方法),使用者進行的操作實際上是歸還AA物件,歸還時不需要在意真實的資產是否仍處於正在被載入的狀態。資產系統會清理AA內部儲存的回撥(通過AssetService.LoadAsset方法傳入),以防止在AA被完全清理之前恰好有回調發生。此時對於使用者,這個AA物件已經失效,不應再以任何方式引用或使用它。後面系統進行輪詢的時候會回收或丟棄被解除安裝的AA物件。依前述AA、ACache和RCache之間的關係,相關的ACache和RCache的引用計數會減少。

如果一個ACache或RCache的引用計數減少到 0,它將進入一個集合,以便進行清理。真正清理將也在系統輪詢時進行,主要步驟是:

  • 清理被歸還的AA物件。
  • 按資產間依賴關係,遞迴清理引用為 0 的ACache。因為Unity實際上不允許取消載入資產的操作,所以如果ACache 指向的資產正在被載入,就暫緩清理。注意,雖然清理了ACache 物件,但不會真的解除安裝單個資產,這算是一種實現選擇。
  • 隔一段時間,或者使用者要求清理時,如果引用計數為 0 的這些RCache中,其指向的資產包均不處於載入狀態,則將它們一同解除安裝。這時候Unity層的實現部分是會真實呼叫AssetBundle.Unload(true)方法,將資產包真正解除安裝。

對引用計數為 0 的資產包的同時解除安裝規則,主要是為了保障,彼此存在依賴關係的資產包會被一起解除安裝掉,否則可能出現一些很難查明的資產丟失bug。

4.6 資產包的規劃
一個相對獨立的功能,從直覺上說,可以打成一個或多個放在一個分組中的資產包。實際操作中,在一個功能內部,經常是按資料夾來分割資產包的,而資料夾又經常是按資產型別分的。

考慮一個問題:如果一個貼圖資料夾中有很多貼圖,在同一個功能的兩個不同介面p、q上使用,由於這個資料夾打在一個資產包中,它只會作為一個總體釋放。介面p可能是掛在遊戲主介面上的,長期存在,只使用了少量貼圖;而介面q是這個功能的主介面,使用了大量貼圖。在執行時,p的生命期明顯比q長,一旦載入了q使用的貼圖資產,只是關閉和銷燬q,是釋放不掉q使用的這些貼圖的。直到p也被銷燬,這些貼圖才會一併被解除安裝。如果有很複雜的資產包間的依賴關係,這個釋放來得可能很晚。

可以通過按“生命期”劃分資產包(從資料夾層面就可以這樣做),以及簡化資產包之間的依賴關係來規避這樣的問題。

五.編輯器

5.1 資產包組織器
編輯器層面提供了一個資產包組織器類AssetBundleOrganizer來配置將哪些資產打入哪些資產包,並配有一個簡單的視覺化工具(AssetBundleOrganizerEditorWindow)來進行編輯。

組織器視覺化工具的功能大致如下:

  • 左數第一欄為資產根目錄(可以有多個),設定將哪些目錄視為根目錄並從中讀取資產,以及讀取什麼型別的資產。
  • 左數第二欄為資產目錄,森林結構,每個資產根目錄下的資產再為一棵樹。
  • 左數第三欄為資產包目錄結構,可在其中新增、刪除、編輯資產包,指定分組等。
  • 左數第四欄展示在第三欄中選中的資產包內的資產內容。
    結合右邊三欄,可以選中資產檔案或目錄分配入資產包中,也可以從資產包中刪除內容。

此外,組織器還支援一個忽略某些資產的標籤(AssetBundleOrganizer.IgnoreAssetLabel屬性),給資產檔案加上指定的標籤(Label),組織器將忽略這些資產,從而不會顯示將它們打入資產包。

組織器會將資訊存放在一個 xml 檔案中,如上圖左下角的Config path所示。對於規模較小的專案,直接用這個視覺化工具也許就夠了。但如果專案規模較大,則建議使用AssetBundleOrganizer提供的API來編寫“規則”程式碼,來動態生成這些內容。

5.2 資產包資訊提供器
資產包資訊提供器由類AssetBundleInfosProvider實現,用於將組織器中的資料轉換成構建資產包可用的資料。譬如,資產包組織器中可以將某個目錄分配到某個資產包中,但是實際構建資產包需要將目錄中的資產檔案和資產包對應起來。資產包資訊提供器就能進行此轉換。此外,還可以檢測(打包用的)資產間依賴關係、資產包間的依賴關係是否合法(比如前述資產包編輯器視覺化工具中的Check Dependency Legality按鈕)等等。

5.3 構建
資產包構建器(AssetBundleBuilder類)封裝了構建資產包的過程(方法BuildPlatform)。主要步驟如下:

  • 通過資產包組織器和資訊提供器,得到資產和資產包的對應關係,構造Unity的AssetBundleBuild列表。
  • 呼叫Unity的方法,構建資產包,獲得manifest檔案。
  • 利用manifest檔案和其他資料,生成在索引檔案中需要的資產包資訊,如分組、CRC-32校驗和、Unity生成的Hash值等。
  • 生成Client,ClientFull,Server資料夾及相應的索引檔案。

使用者可以通過實現IAssetBundleBuilderHandler介面來指定構建各個階段的回撥。例如:使用Lua指令碼的專案可以在自己的IAssetBundleBuilderHandler實現中,用OnPreBeforeBuild回撥來給 .lua字尾的檔案改名為 .txt之類的字尾,以便能被Unity識別為文字資產(Text asset);同樣,在OnBuildSuccessOnBuildFailure回撥中將重新命名的檔案復原。

六.侷限性

  • 目前對已經發起的資產載入呼叫是沒有優先順序的,內部又有一些 Hash 儲存,不能保證實際的載入順序和發起載入呼叫的順序一致。
  • 記憶體中同時有資產間的依賴關係和資產包間的依賴關係,不知道是否可以捨棄後者,還能保證邏輯正確,不出現資產丟失的問題。
  • 載入資產名義上是非同步,但實際上有可能是同步返回的。實際使用時,為了便利起見可以增加中間層。
  • 目前採用“集總式”索引檔案,可能一次解析的內容較多,在遊戲啟動階段造成一些卡頓現象。
  • 未能支援子資產(Sub-asset)或泛型載入資產。例如:對圖集(如Texture Packer這類外掛輸出的)這種型別的資產,需要用一個SerializableObject來存放其中精靈圖的引用。

這是侑虎科技第1076篇文章,感謝作者加菲教主供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯絡我們,一起探討。(QQ群:793972859)

作者主頁:https://www.jianshu.com/u/56cdb7666533

再次感謝加菲教主的分享,如果您有任何獨到的見解或者發現也歡迎聯絡我們,一起探討。(QQ群:793972859)