AssetBundle的原理及最佳實踐

本篇包含了Addressable基礎篇系列的第三節和第四節,第一節《淺談Assets——Unity資源映射》,第二節《Resources目錄的優點與痛點》,可點擊回顧。本文主要介紹Addressable基礎篇系列中的AssetBundle原理和AssetBundle最佳實踐。

三、AssetBundle原理

AssetBundle系統提供了一種壓縮文件的格式,可以把一個到多個文件進行索引和序列化。

Unity項目在交付安裝之後,會通過AssetBundle對不包含代碼的資源進行更新。這就允許開發人員先提交一個小的應用程序包,將運行時內存壓力降到最低,並有選擇地加載針對不同終端用戶設備優化後的內容。

3.1 AssetBundle結構

總的來說,AssetBundle就像傳統的壓縮包一樣,由兩個部分組成:包頭和數據段。

包頭包含有關AssetBundle的信息,比如標識符、壓縮類型和內容清單。清單是一個以Objects name爲鍵的查找表。每個條目都提供一個字節索引,用來指示該Objects在AssetBundle數據段的位置。在大多數平臺上,這個查找表是用平衡搜索樹實現的。具體來說,Windows和OSX派生平臺(包括iOS)都採用了紅黑樹。因此,構建清單所需的時間會隨着AssetBundle中Assets的數量增加而線性增加。

數據段包含通過序列化AssetBundle中的Assets而生成的原始數據。如果指定LZMA爲壓縮方案,則對所有序列化Assets後的完整字節數組進行壓縮。如果指定了LZ4,則單獨壓縮單獨Assets的字節。如果不使用壓縮,數據段將保持爲原始字節流。

在Unity 5.3之前,是無法對AssetBundle中單獨Objects進行壓縮的。因此,如果在5.3之前的Unity版本被要求從壓縮的AssetBundle中讀取一個或多個對象時,Unity必須解壓整個AssetBundle。通常,Unity會緩存AssetBundle的解壓縮副本,以提高在相同AssetBundle上的後續加載請求的加載性能。

3.2 加載AssetBundles

AssetBundles可以通過四個不同的API進行加載。但受限於兩個標準,這四個API的行爲是不同的。兩個標準如下:

  • AssetBundles的壓縮方式:LZMA、LZ4、還是未壓縮的。
  • AssetBundles的加載平臺。

而四個API分別是:

  • AssetBundle.LoadFromMemory(Async optional)
  • AssetBundle.LoadFromFile(Async optional)
  • UnityWebRequest's DownloadHandlerAssetBundle
  • WWW.LoadFromCacheOrDownload (on Unity 5.6 or older)

下面來詳細講一下4個API的區別。

3.2.1 AssetBundle.LoadFromMemory(Async)
Unity的建議是——不要使用這個API

LoadFromMemory(Async) 是從託管代碼的字節數組裏加載AssetBundle。也就是說你要提前用其它的方式將資源的二進制數組加入到內存中。然後該接口會將源數據從託管代碼字節數組複製到新分配的、連續的本機內存塊中。

但如果AssetBundle使用了LZMA壓縮類型,它將在複製時解壓AssetBundle。而未壓縮和LZ4壓縮類型的AssetBundle將逐字節的完整複製。

之所以不建議使用該API是因爲,此API消耗的最大內存量將至少是AssetBundle的兩倍:本機內存中的一個副本,和LoadFromMemory(Async)從託管字節數組中複製的一個副本。

因此,從通過此API創建的AssetBundle加載的資產將在內存中冗餘三次:一次在託管代碼字節數組中,一次在AssetBundle的棧內存副本中,第三次在GPU或系統內存中,用於Asset本身。

注意:在Unity 5.3.3之前,這個API被稱爲AssetBundle.CreateFromMemory。但功能沒有改變。

3.2.2 AssetBundle.LoadFromFile(Async)
LoadFromFile是一種高效的API,用於從本地存儲(如硬盤或SD卡)加載未壓縮或LZ4壓縮格式的AssetBundle。

在桌面獨立平臺、控制檯和移動平臺上,API將只加載AssetBundle的頭部,並將剩餘的數據留在磁盤上。

AssetBundle的Objects會按需加載,比如:加載方法(例如:AssetBundle.Load)被調用或其InstanceID被間接引用的時候。在這種情況下,不會消耗過多的內存。

但在Editor環境下,API還是會把整個AssetBundle加載到內存中,就像讀取磁盤上的字節和使用AssetBundle.LoadFromMemoryAsync一樣。

如果在Editor中對項目進行了分析,此API可能會導致在AssetBundle加載期間出現內存尖峯。但這不應影響設備上的性能,在做優化之前,這些尖峯應該在設備上重新再測試一遍。

要注意,這個API只針對未壓縮或LZ4壓縮格式,因爲前面說過了,如果使用LZMA壓縮,它是針對整個生成後的數據包進行壓縮的,所以在未解壓之前是無法拿到AssetBundle的頭信息的。

注意:這裏曾經有過一個歷史遺留問題,即在Unity 5.3或更老版本的Android設備上,當試圖從Streaming Assets路徑加載AssetBundles時,此API將失敗。這個問題已在Unity 5.4中解決。

在Unity 5.3之前,這個API被稱爲AssetBundle.CreateFromFile。其功能沒有改變。

3.2.3 AssetBundleDownloadHandler
DownloadHandlerAssetBundle的操作是通過UnityWebRequest的API來完成的。

UnityWebRequest API允許開發人員精確地指定Unity應如何處理下載的數據,並允許開發人員消除不必要的內存使用。使用UnityWebRequest下載AssetBundle的最簡單方法是調用UnityWebRequest.GetAssetBundle。

就實戰項目而言,最有意思的類是DownloadHandlerAssetBundle。它使用工作線程,將下載的數據流存儲到一個固定大小的緩衝區中,然後根據下載處理程序的配置方式將緩衝數據放到臨時存儲或AssetBundle緩存中。

所有這些操作都發生在非託管代碼中,消除了增加堆內存的風險。此外,該下載處理程序並不會保留所有下載字節的棧內存副本,從而進一步減少了下載AssetBundle的內存開銷。

LZMA壓縮的AssetBundles將在下載和緩存的時候更改爲LZ4壓縮。這個可以通過設置Caching.CompressionEnable屬性來更改。

如果將緩存信息提供給UnityWebRequest對象,一旦有請求的AssetBundle已經存在於Unity的緩存中,那麼AssetBundle將立即可用,並且此API的行爲將會與AssetBundle.LoadFromFile相同操作。

在Unity 5.6之前,UnityWebRequest系統使用了一個固定的工作線程池和一個內部作業系統來防止過多的併發下載,並且線程池的大小是不可配置的。在Unity 5.6中,這些安全措施已經被刪除,以便適應更現代化的硬件,並允許更快地訪問HTTP響應代碼和報頭。

3.2.4 WWW.LoadFromCacheOrDownload
這是一個很古老的API了,從Unity 2017.1開始,就只是簡單地包裝了UnityWebRequest。因此,使用Unity 2017.1或更高版本的開發者應該直接使用UnityWebRequest來工作。Unity已經放棄了對改接口的維護,並可能在未來的某個版本中移除。

所以下面說的這些內容只適合於Unity 5.6或更老的版本。

WWW.LoadFromCacheOrDownload允許從遠程服務器和本地存儲加載對象。也可以通過文件//URL從本地存儲加載文件。如果AssetBundle存在於Unity Cache中,則此API的行爲將與AssetBundle.LoadFromFile完全相同。

如果AssetBundle尚未緩存,則WWW.LoadFromCacheOrDownload會將從它的源文件讀取AssetBundle。如果AssetBundle被壓縮過,它會使用工作線程進行解壓縮並寫入緩存中。否則,它將通過工作線程直接寫入緩存。

在緩存AssetBundle之後,WWW.LoadFromCacheOrDownload將從緩存的、解壓縮的AssetBundle加載頭信息。然後,和AssetBundle.LoadFromFile加載AssetBundle行爲相同。

此緩存會在WWW.LoadFromCacheOrDownload和UnityWebRequest之間共享。一個API下載的任何AssetBundle也可以通過另一個API獲得。

雖然數據將通過固定大小的緩衝區解壓縮並寫入緩存,但WWW對象會在本機內存中保留AssetBundle字節的完整副本。這個額外副本被保留的原因是因爲要支持WWW.bytes字節屬性。

由於在WWW對象中緩存AssetBundle的字節的內存開銷,所以,實際項目開發中AssetBundles應該要保持較少的體積以便減少內存。

與UnityWebRequest不同的是,每次調用這個API都會產生一個新的工作線程。因此,在手機等內存有限的平臺上,最好限定一次只能下載一個AssetBundle,以避免內存激增。而在其它平臺也要小心創建過多的線程。如果需要下載5個以上的AssetBundles,建議在腳本代碼中創建和管理下載隊列,以確保只有少數幾個AssetBundle同時下載。

3.2.5 建議
(1)一般來說,只要有可能,就應該使用AssetBundle.LoadFromFile。這個API在速度、磁盤使用和運行時內存使用方面是最有效的。

(2)對於必須下載或熱更新AssetBundles的項目,強烈建議對使用Unity 5.3或更高版本的項目使用UnityWebRequest,對於使用Unity 5.2或更老版本的項目使用WWW.LoadFromCacheOrDownload。

(3)當使用UnityWebRequest或WWW.LoadFromCacheOrDownload時,要確保下載程序代碼在加載AssetBundle後正確地調用Dispose。另外,C#的using語句是確保WWW或UnityWebRequest被安全處理的最方便的方法。

(4)對於需要獨特的、特定的緩存或下載需求的大項目,可以考慮使用自定義的下載器。編寫自定義下載程序是一項重要並且複雜的任務,任何自定義的下載程序都應該與AssetBundle.LoadFromFile保持兼容。

3.3 從AssetBundles中加載Assets

到這裏,我們已經能夠獲得AssetBundles了,那麼接下來就是要從AssetBundles裏獲取Assets。

Unity提供了三個不同的API從AssetBundles加載UnityEngine.Objects,這些API都綁定到AssetBundle對象上,並且這些API具有同步和異步變體:

  • LoadAsset (LoadAssetAsync)
  • LoadAllAssets (LoadAllAssetsAsync)
  • LoadAssetWithSubAssets (LoadAssetWithSubAssetsAsync)

並且這些API的同步版本總是比異步版本快至少一個幀(其實是因爲異步版本爲了確保異步,都至少延遲了1幀),異步加載每幀會加載多個對象,直到它們的時間切片切出。

加載多個獨立的UnityEngine.Objects時應使用LoadAllAsset。並且只有在需要加載AssetBundle中的大多數或所有對象時,才應該使用它。與其它兩個API相比,LoadAllAsset比對LoadAsset的多個單獨調用略快一些。因此,如果要加載的Asset數量很大,但如果需要一次性加載不到三分之二的AssetBundle,則要考慮將AssetBundle拆分爲多個較小的包,再使用LoadAllAsset。

加載包含多個嵌入式對象的複合Asset時,應使用LoadAssetWithSubAsset,例如嵌入動畫的FBX模型或嵌入多個精靈的sprite圖集。也就是說,如果需要加載的對象都來自同一Asset,但與許多其它無關對象一起存儲在AssetBundle中,則使用此API。

任何其它情況,請使用LoadAsset或LoadAssetAsync。

3.3.1 低層級的加載細節
Object加載是在主線程上執行,但數據從工作線程上的存儲中讀取。任何不觸碰Unity系統中線程敏感部分(腳本、圖形)的工作都將在工作線程上轉換。例如,VBO將從網格創建,紋理將被解壓等等。

從Unity 5.3開始,Object加載就被並行化了。在工作線程上反序列化、處理和集成多個Object。當一個Object完成加載時,它的Awake回調將被調用,該對象的其餘部分將在下一個幀中對UnityEngine可用。

同步AssetBundle.Load方法將暫停主線程,直到Object加載完成。但它們也會加載時間切片的Object,以便Object集成不會佔用太多的毫秒幀時間。應用程序屬性設置毫秒數的屬性爲Application.backgroundLoadingPriority。
ThreadPriority.High:每幀最多50毫秒
ThreadPriority.Normal:每幀最多10毫秒
ThreadPriority.BelowNormal:每幀最多4毫秒
ThreadPriority.Low:每幀最多2毫秒

從Unity 5.2開始,加載多個對象的時候,會一直進行直到達到對象加載的幀時間限制爲止。假設所有其它因素相等,Asset加載API的異步變體將總是比同步版本花費更長的時間,因爲發出異步調用和對象之間有最小的一幀延遲。

3.3.2 AssetBundle依賴項
根據運行時環境的不同,使用兩個不同的API自動跟蹤AssetBundles之間的依賴關係。在UnityEditor中,可以通過AssetDatabaseAPI查詢AssetBundle依賴項。AssetBundles分配和依賴項可以通過AssetImportAPI訪問和更改。在運行時,Unity提供了一個可選的API,通過基於ScriptableObject的AssetBundleManifest API加載在AssetBundle構建過程中生成的依賴信息。

當一個或多個AssetBundle的UnityEngine.Objects引用了一個或者多個其它AssetBundle的UnityEngine.Objects,那麼這個AssetBundle就會依賴於另外的AssetBundle。AssetBundles充當由它包含的每個對象的FileGUID和LocalID標識的源數據。

因爲一個對象是在其Instance ID第一次被間接引用時加載的,而且由於一個對象在加載其AssetBundle時被分配了一個有效的Instance ID,所以加載AssetBundles的順序並不重要。相反,在加載對象本身之前,重要的是加載包含對象依賴關係的所有AssetBundles。Unity不會嘗試在加載父AssetBundle時自動加載任何子AssetBundle。

示例:
假設Material A引用Texture B。Material A被打包到AssetBundle1中,Texture B被打包到AssetBundle2中:

 

在本用例中,AssetBundle2必須在Material A從AssetBundle1中加載之前先加載。這並不意味着AssetBundle 2必須在AssetBundle 1之前加載,或者Texture B必須從AssetBundle 2中顯式加載。在將Material A從AssetBundle 1加載之前加載AssetBundle 2就足夠了。

簡單來說就是AssetBundle之間的加載沒有先後,但是Asset的加載有。

有關AssetBundle依賴項的詳細信息,請參閱手冊頁

3.3.3 AssetBundle manifests
當使用BuildPiine.BuildAssetBundles API執行AssetBundle構建管線時,Unity會序列化一個包含每個AssetBundle依賴項信息的對象。此數據存儲在單獨的AssetBundle中,其中包含AssetBundleManifest類型的單個對象。

此Asset將存儲在與構建AssetBundles的父目錄同名的AssetBundle中。如果一個項目將其AssetBundles構建到位於(Projectroot)/Build/Client/的文件夾中,那麼包含清單的AssetBundle將被保存爲(Projectroot)/build/client/Client.manifest。

包含Manifest的AssetBundle可以像任何其它AssetBundle一樣加載、緩存和卸載。

AssetBundleManifest對象本身提供GetAllAssetBundles API來列出與清單同時構建的所有AssetBundles,以及查詢特定AssetBundle的依賴項的兩個方法:

  • AssetBundleManifest.GetAllDependencies返回AssetBundle的所有層次依賴項,其中包括AssetBundle的直接子級、其子級的依賴項等。
  • AssetBundleManifest.GetDirectDependations只返回AssetBundle的直接子級

請注意,這兩個API分配的都是字符串數組。因此,最好是在性能要求不敏感的時候使用。

3.3.4 建議
在多數情況下,最好在玩家進入應用程序的性能關鍵區域(如主遊戲關卡或世界)之前加載儘可能多的所需對象。這在移動平臺上尤爲重要,因爲在移動平臺上,訪問本地存儲的速度很慢,並且在運行時加載和卸載對象會觸發垃圾回收。


四、AssetBundle最佳實踐

上一節,我們介紹了AssetBundle的基礎原理,並且講解了從加載AssetBundle到加載Asset的各種過程,以及底層API實現等細節內容,這一節,我們會討論下在實際使用AssetBundles的過程中遇到的問題以及解決方案。

4.1 管理已經加載的Assets

在一些內存敏感的環境中,嚴格控制加載對象的大小和數量是至關重要的。當對象從活動場景中被移除時,Unity並不會自動卸載對象。Assets的清理工作是在特定時間觸發的,當然也可以手動觸發(其實就是GC了)。

AssetBundle必須要小心管理。在本地存儲(通過Unity緩存或通過AssetBundle.LoadFromFile加載的文件)裏的文件支持的AssetBundle具有最小的內存開銷,很少超過幾十K字節。但是,如果存在大量的AssetBundles,這種開銷仍然會成爲問題。

由於大多數項目允許用戶重複體驗遊戲內容(例如重新打一個關卡),因此瞭解何時加載或卸載AssetBundle就變得非常重要。如果AssetBundle卸載不當,則會導致內存中的對象產生冗餘副本。而在某些情況下,不適當地卸載AssetBundles也會導致不良行爲,例如紋理丟失。

在管理Asset和AssetBundle時,最重要的一點是調用AssetBundle.unload時的方式,unload參數爲true或false。

此API將卸載正在調用的AssetBundle的包頭信息。unload參數決定是否也卸載從此AssetBundle實例化的所有對象。如果設置爲true,那麼從AssetBundle創建的所有對象也將立即卸載,即使它們目前正在活動場景中被引用。

舉個例子,假設Material M是從AssetBundle AB加載的,並且假設M當前在活動場景中。

如果調用了AssetBundle.Unload(True),則M將從場景中移除,銷燬並卸載。但是,如果調用AssetBundle.Unload(False),則AB的包頭信息將被卸載,但M將保持在場景中,並且仍然是可用的。調用AssetBundle.Unload(False)破壞了M和AB之間的鏈接。如果AB稍後再次加載,則AB中包含的對象的新副本將會被加載到內存中。

如果AB稍後再次加載,將會重新加載AssetBundle的頭信息的新副本。然而,M不是從這個新的AB拷貝加載的。Unity並沒有在AB和M的新副本之間建立任何聯繫。

如果調用AssetBundle.LoadAsset()來重新加載M,Unity不會將舊的M副本作爲AB中數據的實例。因此,Unity將加載一個新的副本的M,所以此時將會有兩個相同的副本M在現場。

對於大多數項目來說,這樣的結果是不可取的。大多數項目應該使用AssetBundle.Unload(True),並採用一種方法來確保對象不被複制。兩種常見的方法是:
(1)在應用程序的生命週期內定義一個合適的節點,並在此期間卸載不需要的AssetBundle,例如在關卡切換或加載屏幕期間。這是最簡單和最常見的選擇。
(2)維護單個對象的引用計數,並僅當所有組成對象都未使用時才卸載AssetBundles。這允許應用程序在不重複內存的情況下卸載和重新加載單個對象。

如果應用程序必須使用AssetBundle.Unload(False),那麼只能通過兩種方式卸載各個對象:
(1)在場景和代碼中消除對不需要的對象的所有引用。完成後,調用Resources.UnusedAsset。
(2)非附加加載場景。這將銷燬當前場景中的所有對象並調用Resources.UnusedAsset。

如果項目有明確定義的節點,玩家可以等待對象加載和卸載,例如在遊戲模式或關卡之間切換,則可以根據需要,卸載儘可能多的對象和加載新對象。

最簡單的方法是將項目的離散塊打包到場景中,然後將這些場景與所有依賴項一起構建到AssetBundles中。應用程序可以切換到「loading」場景,從而完全卸載包含舊場景的AssetBundles,然後加載包含新場景的AssetBundles。

雖然這是最簡單的流程,但卻需要很複雜的AssetBundles管理。由於每個項目是不同的,Unity尚沒有提供通用的AssetBundles設計模式。

在決定如何將對象分組到AssetBundles時,如果必須同時加載或更新對象,則通常最好將對象捆綁到AssetBundles中。例如,考慮一個角色扮演遊戲。個別的地圖和裁剪場景可以按場景分組成AssetBundles,但有些對象可能會存在於很多其它場景則不能劃分進去。

AssetBundles可以用來提供肖像畫,遊戲中的UI,以及不同的角色模型和紋理。這些稍後需要的Objects和Assets可以分組到第二組AssetBundles中,這些Assets在啓動時加載,並在應用程序的生命週期內保持加載狀態。

另一個問題可能會出現,如果Unity必須在AssetBundle卸載後從AssetBundle中重新加載一個對象。在這種情況下,重新加載將失敗,該對象將以(Missing)對象的形式出現在Unity編輯器的層次結構中。

這主要會發生在Unity失去並試圖恢復對其圖形上下文的控制時,例如當移動應用程序被掛起或用戶鎖定他們的PC時。在這種情況下,Unity必須重新上傳紋理和渲染到GPU。如果這些Asset的源AssetBundle不可用,則應用程序會將場景中的相關對象呈現爲洋紅色。

4.2 發佈

客戶端發佈項目的AssetBundles有兩種基本方法:和項目一起安裝或安裝後再下載它們。

在安裝時還是安裝後交付AssetBundles,取決於項目將運行的平臺的功能和限制。移動項目通常選擇先安裝後下載,以減少初始安裝大小,並保持低於相關平臺下載的大小限制(比如蘋果商店和谷歌商店會對4G模式下最大能下載的包做限制)。控制檯和PC項目通常在初始安裝時附帶AssetBundles。

良好的架構應該允許項目在安裝後再進行資源熱更,而不用管AssetBundles最初是如何發佈的。有關這方面的更多信息,請參見「Unity 手冊」中的「Patching with AssetBundles」一節。

4.2.1 隨項目發佈
與項目一起發佈AssetBundles是最簡單的,因爲它不需要額外的代碼進行下載和管理。項目在安裝時包含AssetBundles,有兩個主要原因:

  • 減少項目構建時間並允許更簡單的迭代開發。如果這些AssetBundles不需要和應用程序本身分開更新,可以通過將AssetBundles存儲在Streaming Assets中,將AssetBundles包含在應用程序中。請參閱下面的Streaming Assets部分。
  • 發佈可更新內容的初始修訂版。這通常是爲了節省終端用戶在最初安裝後的時間,或者作爲以後修補的基礎。Streaming Assets在這種情況下並不理想。但是,如果不選擇編寫自定義下載和緩存系統,則可以從Streaming Assets將可更新內容的初始修訂加載到Unity緩存中(請參閱下面的緩存啓動部分)。

(1)Streaming Assets
在安裝時將任何類型的內容(包括AssetBundles)包含在一個Unity應用程序裏,最簡單的方法是在構建項目之前將內容構建到/Asset/StreamingAsset/文件夾中。構建時StreamingAsset文件夾中包含的任何內容都將複製到最終的應用程序裏。

在運行時,可以通過屬性Application.StreamingAssets 訪問本地存儲上StreamingAsset文件夾的完整路徑。然後,可以通過AssetBundle.LoadFromFile在大多數平臺上加載AssetBundles。

Android開發者:在Android上,StreamingAsset文件夾中的Asset存儲在APK中,如果它們被壓縮,可能需要更多的時間來加載,因爲存儲在APK中的文件可以使用不同的存儲算法。並且所使用的算法還可能因Unity版本而異。

你可以使用一個歸檔程序(如7-zip)來打開APK,以確定這些文件是否被壓縮。如果是,你會看到AssetBundle.LoadFromFile()執行得更慢。

可以使用UnityWebRequest.GetAssetBundle作爲解決方案檢索緩存版本。通過使用UnityWebRequest,AssetBundle將在第一次運行時被解壓縮和緩存,從而使後續執行速度更快。不過要注意,這會佔用更多的存儲空間,因爲AssetBundle會被複制到緩存中。或者,導出Gradle項目,並在構建時向AssetBundles添加一個extension。然後,就可以編輯build.gradle文件並將該extension添加到noCompress部分。完成之後,就能夠直接使用AssetBundle.LoadFromFile(),而不必再消耗解壓縮的性能成本了。

注意:在某些平臺上,StreamingAsset是隻讀的。如果安裝後需要更新項目的AssetBundles,請使用WWW.LoadFromCacheOrDownload或編寫自定義下載程序。

4.2.2 安裝後下載
將AssetBundles傳遞到移動設備的最好方法是在應用程序安裝後再下載它們。這就允許在安裝後再更新遊戲內容,而不必強迫用戶重新下載整個應用程序。在許多平臺上,應用程序二進制文件必須經過昂貴而漫長的重新認證過程。因此,開發一個良好的分離下載系統是至關重要的。

交付AssetBundles最簡單的方法是將它們放在某個Web服務器上,並通過UnityWebRequest發佈。Unity將自動在本地存儲上緩存下載的AssetBundles。如果下載的AssetBundle是LZMA壓縮的,那麼AssetBundle將以未壓縮或重新壓縮的形式存儲在緩存中,就像LZ 4一樣(依賴Caching.compressionEnabled設置),以便將來更快地加載。如果下載的包是LZ 4壓縮的,AssetBundles將被壓縮存儲。如果緩存被填滿,Unity將從緩存中刪除最近使用最少的AssetBundle。

通常建議在允許的情況下使用UnityWebRequest,或者只有在使用Unity 5.2或更老版本時才使用WWW.LoadFromCacheOrDownload。只有當內置API的內存消耗、緩存行爲或性能對於特定項目是不可接受的時候,或者項目必須運行特定於平臺的代碼才能滿足其需求時,才需要對自定義下載系統進行擴展。

可能會阻礙使用UnityWebRequest或WWW.LoadFromCacheOrDownload的情況示例:

  • 當需要對AssetBundle緩存進行顆粒度控制時。
  • 當項目需要實現自定義壓縮策略時。
  • 當項目希望使用特定於平臺的API來滿足某些需求時,例如在不活動時才加載流數據。比如,使用iOS的後臺任務API下載數據。
  • 當AssetBundles必須在不具備SSL支持條件(如PC)的平臺上通過SSL交付時。

4.2.3 建立緩存
Unity有一個內置的AssetBundle緩存系統,可以用來緩存通過UnityWebRequest API下載的AssetBundles,該API的重載會接受一個AssetBundle版本號作爲參數。這個數字不是存儲在AssetBundles裏的,也不是由AssetBundles系統生成的。

緩存系統跟蹤傳遞給UnityWebRequest的最後一個版本號。當使用版本號調用此API時,緩存系統通過比較版本號來檢查是否存在緩存的AssetBundle。如果這些數字匹配,系統將加載緩存的AssetBundle。如果數字不匹配,或者沒有緩存的AssetBundle,那麼Unity將下載一個新的副本。此新副本將與新的版本號相關聯。

緩存系統中的AssetBundle只通過它們的文件名來標識,而不是通過下載它們的完整URL。這意味着具有相同文件名的AssetBundle可以存儲在多個不同的位置,例如CDN。只要文件名相同,緩存系統就會將它們識別爲相同的AssetBundle。

每個應用程序都需要確定將版本號分給AssetBundles的考量,然後將這些編號傳遞給UnityWebRequest。數字可能來自某些唯一標識符,例如crc值。請注意,雖然AssetBundleManifest.GetAssetBundleHash()也可用於此目的,但我們不建議將此函數用於版本控制,因爲它只提供了估計,而不是真正的哈希計算。

有關更多細節,請參見「Unity 手冊」中的「Patching with AssetBundles」部分。

在Unity 2017.1中,允許開發人員從多個緩存中選擇一個活動緩存,緩存API已經被擴展成爲可以提供更細顆粒度的控制了。以前的Unity版本只能修改Caching.expenationDelay和Caching.AvailableDiskSpace來刪除緩存項(這些屬性保留在Cache類的Unity2017.1中)。

expirationDelay是自動刪除AssetBundle之前必須經過的最小秒數。如果在此期間沒有訪問過AssetBundle,則將自動刪除它。

MaximumAvailableDiskSpace指定緩存在開始刪除最近使用的AssetBundles之前,可以使用的本地存儲空間的大小(以字節爲單位)。當達到限制時,Unity將刪除緩存中最近打開的AssetBundle(或通過Caching.MarkAsUsed標記)。Unity會刪除緩存的AssetBundle,直到有足夠的空間完成新的下載爲止。

(1)Cache基礎
因爲AssetBundles是通過它們的文件名來標識的,所以可以使用應用程序附帶的AssetBundles來「初始化」緩存。爲此,將每個AssetBundle的初始或基本版本存儲在/Asset/StreamingAsset/中。該過程與前面「隨項目發佈」的詳細流程相同。

在應用程序第一次運行時,可以通過從Application.streamingAssetsPath 加載AssetBundles來填充緩存。從那時起,應用程序可以正常地調用UnityWebRequest(UnityWebRequest也可以用於最初從StreamingAsset路徑加載AssetBundles)。

4.2.4 自定義下載器
編寫自定義下載器可以讓應用程序完全控制如何下載、解壓縮和存儲AssetBundles。由於所涉及的工程工作並不簡單,我們建議只對較大的團隊採用這種方法。在編寫自定義下載程序時有四個主要注意事項:

  • 下載機制
  • 存儲位置
  • 壓縮類型
  • 補丁

有關Patching AssetBundles的信息,請參閱「Unity手冊」中的「Patching with AssetBundles」一節。

(1)下載
對於大多數應用程序來說,HTTP是下載AssetBundles的最簡單方法。然而,實現基於HTTP的下載機並不是簡單的任務.自定義下載程序必須避免過多的內存分配、過多的線程使用和過多的線程喚醒。由上一節中WWW.LoadFromCacheOrDownload部分詳盡描述的原因來看,Unity的WWW類是不合適的。

在編寫自定義下載程序時,有三個選項:

  • C#的HttpWebRequest和WebClient類
  • 自定義本地插件
  • Asset存儲包

C# 類
如果應用程序不需要HTTPS/SSL支持,C#的WebClient類可以爲下載AssetBundles提供最簡單的機制,它能夠異步地將任何文件直接下載到本地存儲,而不需要過多的託管內存分配。

要用WebClient下載AssetBundle,請分配類的一個實例,並將AssetBundle的URL傳遞給它以下載和目標路徑。如果需要對請求的參數進行更多的控制,則可以使用C#的HttpWebRequest類編寫下載器:

  • 從HttpWebResponse.GetResponseStream獲取一個字節流。
  • 在堆棧上分配一個固定大小的字節緩衝區。
  • 從響應流讀取到緩衝區。
  • 使用C#的File.IOAPI或任何其他流IO系統將緩衝區寫入磁盤。

Asset Store插件
有一些商店的Asset插件提供了基於非託管代碼的實現,通過HTTP、HTTPS和其他協議下載文件。在編寫用於Unity的自定義本機代碼插件之前,建議對可用的AssetStore插件進行評估。

自定義原生插件
編寫自定義的原生插件是最耗時,但是,是Unity下載數據最靈活的方式。由於編程時間要求高,技術風險高,只有當沒有其他方法能夠滿足應用程序的需求時,才推薦這種方法。例如,如果應用程序必須在Unity中沒有C#SSL支持的平臺上使用SSL通信,則可能需要定製原生插件。

(2)存儲
在所有平臺上,Application.PersistentDataPath指向一個可寫的位置,該位置可以用於存儲在應用程序運行之間需要持久化的數據。在編寫自定義下載程序時,強烈建議使用Application.PersistentDataPath的子目錄來存儲下載的數據。

Application.streamingAssetPath位置不可寫,對於AssetBundle緩存來說是一個尷尬的問題。StreamingAssetPath的示例位置包括:

  • OSX:在.app包內;不可寫
  • Windows:安裝目錄(例如程序文件);通常不可寫
  • iOS:在.ipa包內;不可寫
  • Android:在.apk文件中;不可寫

4.3 Asset配置策略

決定如何將項目的Asset劃分爲AssetBundles很複雜。但如果能採用一種簡單化的策略還是很有誘惑力的,比如將所有對象放在自己的AssetBundle中,或者只使用一個AssetBundle,但是這個解決方案有很大的缺點:

  • AssetBundle太少了
  • 增加運行時內存使用
  • 增加加載時間
  • 需要更大的下載量
  • 有太多的AssetBundle組合
  • 增加構建時間
  • 使開發變的複雜
  • 增加下載總時間

所以,最關鍵的就是如何將對象分組到AssetBundles中。主要策略是:

  • 邏輯實體
  • 對象類型
  • 併發內容有關這些分組策略的更多信息可在「手冊」中找到。

4.4 常規的陷阱

本節講幾個在使用AssetBundles的項目中常見的幾個問題。

4.4.1 Asset冗餘
Unity 5的AssetBundle系統會在某個Objects被構建進AssetBundles的時候查找它的所有依賴,此依賴關係信息用於確定將包含在AssetBundles中的Objects集,並且會被打進AssetBundles中。

顯式分配給AssetBundle的Objects將只被構建到該AssetBundle中。當Objects的AssetImporter將其AssetBundleName屬性設置爲非空字符串時,該Objects將被「顯式分配」。這可以通過在Objects的檢查器中選擇AssetBundle來完成,也可以從Editor腳本中進行。

Objects還可以通過將其定義爲AssetBundle構建映射的一部分來分配給AssetBundle,該映射將與重載的BuildPiine.BuildAssetBundles()函數一起使用,該函數接受AssetBundleBuild數組。

在AssetBundle中未顯式分配的Objects,將會包含在其它任何一個或者多個未標記的AssetBundles中。

例如,如果兩個不同的Objects被分配給兩個不同的AssetBundles,但都具有對公共依賴Objects的引用,那麼該依賴Objects會被複制到兩個AssetBundles中。重複的依賴關係也會被實例化,這意味着依賴Objects的兩個副本將被視爲具有不同標識符的不同Objects。這將增加應用程序的AssetBundles的總大小。如果應用程序同時加載了這兩個Objects,就會導被依賴對象加載兩邊,並保存在內存裏。

有幾種方法可以解決這個問題:
(1)確保構建在不同AssetBundles中的Objects不共享依賴關係。任何具有共享依賴關係的Objects都可以放在相同的AssetBundle中,而不用產生依賴項副本。對於具有許多共享依賴項的項目,此方法通常不可行。它可能導致產生大塊的並且少量的AssetBundles,如果要更新,必須頻繁重建或者重新下載。
(2)把AssetBundles分類,這樣就不會同時加載兩個共享依賴項的AssetBundles。這種方法可能適用於某些類型的項目,例如基於關卡的遊戲。但是,它仍然不必要地增加了項目的AssetBundles的大小,並且增加了構建時間和加載時間。
(3)確保所有依賴Assets都內置到自己的Assets中。這完全消除了冗餘資產的風險,但也帶來了複雜性。應用程序必須跟蹤AssetBundles之間的依賴關係,並確保在調用任何AssetBundle.LoadAssetAPI之前加載正確的AssetBundles。

可以通過位於UnityEditor命名空間中的AssetDatabaseAPI跟蹤對象依賴關係。正如名稱空間所暗示的那樣,此API僅在UnityEditor中可用,而在運行時不能使用。GetDependents可用於定位特定對象或資產的所有直接依賴項。請注意,這些依賴項可能有它們自己的依賴項。此外,AssetImportAPI還可以用於查詢分配任何特定Objects的AssetBundle。

通過組合AssetDatabase和AssetImportAPI,可以編寫一個編輯器腳本,以確保將AssetBundle的所有直接或間接依賴項分配給AssetBundles,或者確保沒有兩個AssetBundles共享未分配給AssetBundle的依賴項。由於Asset副本的內存成本,建議所有項目都有這樣的腳本。

4.4.2 Sprite Atlas冗餘
任何自動生成的Sprite圖集都將被分配給AssetBundle,其中包含生成Sprite圖集的Sprite Objects。如果Sprite Objects被分配給多個AssetBundles,那麼Sprite圖集將不會分配給AssetBundle並將產生副本。如果Sprite Objects沒有分配給AssetBundle,那麼Sprite圖集也不會分配給AssetBundle。

爲了確保Sprite圖集不被複制,請檢查所有被標記在同一個Sprite圖集中的Sprite都被分配到同一個AssetBundle中。請注意,在Unity 5.2.2p3和更老的版本中,自動生成的Sprite圖集永遠不會分配給AssetBundle。因此,它們將包括在任何包含其組成精靈的AssetBundle中,也包括引用其組成精靈的任何AssetBundle。由於這個問題,強烈建議所有Unity 5項目使用Unity的Sprite Packer升級到Unity 5.2.2p4,5.3或任何更新版本的Unity。

4.4.3 Android紋理
由於Android生態系統中的設備硬件分化很多,所以通常需要將紋理壓縮成幾種不同的格式。雖然所有Android設備都支持ETC 1,但ETC 1不支持帶有alpha通道的紋理。如果一個應用程序不需要OpenGL ES 2的支持,那麼最乾淨的解決方法就是使用ETC 2,這是所有Android OpenGL ES 3設備所支持的。

很多應用程序需要在不支持ETC 2的舊設備上發佈。解決這個問題的一種方法是使用Unity5的AssetBundle變體(有關其它選項的詳細信息,請參閱Unity的Android優化指南)。

要使用AssetBundle變體,所有不能使用ETC 1進行完全壓縮的紋理必須隔離到只有紋理的AssetBundles中。接下來,使用DXT 5、PVRTC和ATITC等特定供應商的紋理壓縮格式,創建這些AssetBundles的足夠變體,以支持Android生態系統中不具備ETC 2功能的切片。對於每個AssetBundle變體,需要包含的紋理的TextureImporter設置更改爲適合該變體的壓縮格式。

在運行時,可以使用SystemInfo.SupportsTextureFormatAPI檢測到對不同紋理壓縮格式的支持。此信息應用於選擇和加載AssetBundle變體,以支持的對應壓縮紋理格式。

更多關於Android紋理壓縮格式的信息可以在這裏找到。

4.4.4 iOS文件句柄過渡使用
當前版本的Unity已經不受此問題影響了。

在Unity 5.3.2p2之前的版本中,在加載AssetBundle的整個時間裏,Unity都會爲AssetBundle保存一個打開的文件句柄。在大多數平臺上,這不是一個問題。但是,iOS限制進程的文件句柄的數量最多同時打開255。如果加載AssetBundle導致超出此限制,則加載調用將失敗,出現「打開文件句柄太多」錯誤。

對於試圖將其內容劃分爲數百或數千個AssetBundle的項目來說,這是一個常見的問題。

對於無法升級到修補版本的Unity項目,臨時解決方案是:

  • 通過合併相關AssetBundle來減少正在使用的AssetBundle的數量。
  • 使用AssetBundle.Unload(False)關閉AssetBundle的文件句柄,並手動管理加載對象的生命週期。

4.5 AssetBundle變體

AssetBundle系統的一個關鍵特點是引入了AssetBundle變體。變體的目的是允許應用程序調整其內容以更好地適應其運行時環境。變體允許不同AssetBundle文件中的不同UnityEngine.Objects在加載對象和解析實例ID引用時顯示爲「相同」對象。從概念上講,它允許兩個UnityEngine.Objects看起來共享相同的FileGU ID&Local ID,並通過字符串變體ID標識實際的UnityEngine.Object。

該系統有兩個主要用例:
(1)變體簡化了適用於給定平臺的AssetBundle的加載。

  • 例如:構建系統可能會創建一個包含高分辨率紋理和複雜着色器的AssetBundle,適用於獨立的DirectX 11 Windows構建,以及第二個AssetBundle,其內容保真度較低,適用於Android。在運行時,項目的資源加載代碼可以爲其平臺加載適當的AssetBundle變體,傳遞給AssetBundle.Load API的對象名稱不需要更改。

(2)變體允許應用程序在同一個平臺上加載不同的內容,但使用不同的硬件。

  • 這是支持多種移動設備的關鍵。iPhone 4無法在任何real-world的應用程序中保證和最新iPhone相同的內容保真度。
  • 在Android上,AssetBundle變體可以用來解決屏幕縱橫比和設備間DPI的巨大差別。

4.5.1 侷限
AssetBundle變體系統的一個關鍵限制是,它要求從不同的Asset構建變體。即使這些Asset之間的唯一變化是它們的導入設置,這個限制也是合理的。如果在變量A和變體B中構建的紋理之間的唯一區別是在Unity紋理導入器中選擇的特定紋理壓縮算法,則變量A和變體B仍然必須是完全不同的Asset。這意味着變量A和變體B必須是磁盤上的單獨文件。

這一限制使大型項目的管理變得複雜,因爲必須將特定Asset的多個副本保存在源代碼管理中。當開發人員希望更改Asset的內容時,必須更新Asset的所有副本。對於這個問題,沒有非常好的解決辦法。

大多數團隊會實現他們自己的AssetBundle變體。這是通過構建帶有定義良好後綴的AssetBundles文件名來完成的,以便標識給定的AssetBundle表示的特定變體。自定義代碼在構建這些Asset時以編程方式更改Asset的導入設置。一些開發人員已經擴展了他們的自定義系統,使其也能夠更改附加在Prefabs上的組件的參數。

4.6 壓縮還是不壓縮?

是否壓縮AssetBundle需要考慮幾個重要問題,其中包括:

  • 加載時間:當從本地存儲或本地緩存加載時,未壓縮的AssetBundles加載速度比壓縮的AssetBundles快得多。
  • 構建時間:在壓縮文件時,LZMA和LZ 4非常慢,統一編輯器依次處理AssetBundles。擁有大量資產Bundles的項目將花費大量的時間壓縮它們。
  • 應用程序大小:如果AssetBundles是在應用程序中提供的,那麼壓縮它們將減少應用程序的總大小。或者,AssetBundles可以在安裝後下載。
  • 內存使用:在Unity 5.3之前,Unity的所有解壓縮機制都要求在解壓縮之前將整個壓縮的AssetBundles加載到內存中。如果內存使用很重要,請使用未壓縮或LZ 4壓縮AssetBundles。
  • 下載時間:只有當AssetBundles很大時,或者用戶處於帶寬受限的環境(例如在低速或計量連接上下載)時,纔可能需要壓縮。如果只有幾十兆字節的數據通過高速連接傳輸到PC機,那麼就有可能忽略壓縮。

4.6.1. Crunch壓縮
主要由DXT壓縮紋理組成的使用Crunch壓縮算法的AssetBundles應該是算非壓縮的。

4.7 AssetBundles and WebGL

WebGL項目中的所有AssetBundle解壓縮和加載必須發生在主線程上,因爲Unity的WebGL導出選項目前不支持工作線程。使用XMLHttpRequest將AssetBundles的下載委託給瀏覽器。一旦下載,壓縮的AssetBundles將被解壓在Unity的主線程上,因此會根據包的大小而延遲Unity內容的執行。

Unity建議開發人員用小型AssetBundles,以避免出現性能問題。與使用大型AssetBundles相比,這種方法還具有更高的內存效率。UnityWebGL只支持LZ 4-壓縮和未壓縮的AssetBundles,但是,可以將gzip/brotli壓縮應用於由Unity生成的包上。在這種情況下,您需要相應地配置Web服務器,以便在瀏覽器下載時解壓文件。更多細節請戳這裏

如果您使用的是Unity 5.5或更老版本,請考慮避免對AssetBundles使用LZMA,而使用LZ 4進行壓縮,這對按需解壓縮非常有效的。Unity 5.6刪除了LZMA作爲WebGL平臺的壓縮選項。

封面圖來源:AssetBundle Reporter
Unity AssetBundle冗餘檢測與資源分析
https://lab.uwa4d.com/lab/5ba010eb02004fb659bb4610

感謝作者放牛的星星供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)

作者主頁:https://www.zhihu.com/people/niuxingxing,作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞臺有你更精彩!