Unity的AssetBundle使用總結


AssetBundle作爲Unity引擎的資源加載管理和熱更新手段,在各大項目內普遍的使用着。AssetBundle使用方式簡單,只要設置AssetBundleName,然後打包出文件,在運行過程中加載就可以使用,但很多細節上的問題,會影響實際使用的效果。
之前我也寫過很多篇文章介紹AssetBundle的使用和依賴拆分策略。這幾年來陸陸續續也做了不少項目,在裏面遇到了一些問題,所以想重新終結一下。
這篇文章會討論三個問題:
1、AssetBundle的壓縮方式對比
2、AssetBundle的依賴拆分策略
3、AssetBundle的卸載策略

一、AssetBundle的壓縮方式對比

Unity默認的打包AssetBundle壓縮方式是LZMA,我們也可以通過打包參數BuildAssetBundleOptions的UncompressedAssetBundle指定不壓縮,或者ChunkBasedCompression來指定使用LZ4方式壓縮。
不壓縮,自然也不需要解壓縮,所以打包和加載的速度會快一些。但不壓縮會讓AssetBundle容量很大很多,這樣會讓安裝包變大,還有用戶下載熱更新時也會變大很多。
下面主要對比的是LZMA和LZ4的使用情況。
這裏拿了一個項目的一部分模型作爲測試,把所有預設都打包成同一個AssetBundle,分別使用了LZMA和LZ4的壓縮方式。
1、容量:
LZMA:160Mb
LZ4:219Mb
可以看出,用LZ4打包出來的AssetBundle,容量會比LZMA的大30%左右,這個和網上看到的結論是相符合的。
但實際上這個容量的大小差別還是很有爭議性的,因爲這裏面的資源全部都是prefab,沒有scene文件。我之前的項目曾經試過LZ4比LZMA大了一倍多的,我暫時沒有很仔細的找出變大的資源是哪些,但那個項目使用的場景比較多,場景都是直接打包成AssetBundle的,估計和這個有點關係。
2、加載速度:
以下通過同樣的加載方式來測試:
1.加載這個包含了所有模型的AssetBundle,使用AssetBundle.LoadFromFile方法加載
2.使用LoadAsset方法讀取AssetBundle內的一個模型,並讓他實例化在場景裏面
首先是LZMA壓縮方式的AssetBundle:
在這裏插入圖片描述
在LoadFromFile方法裏面,消耗了11909毫秒。
然後是LZ4壓縮方式的AssetBundle:
在這裏插入圖片描述
在LoadFromFile方法裏面,消耗了84毫秒。
這兩者之間的差距已經非常明顯了,完全不是一個數量級裏面的。

3、內存:
首先是在完全沒加載任何東西前,unity本身的內存截圖如下:
在這裏插入圖片描述
然後是加載LZMA的AssetBundle之後的內存:
在這裏插入圖片描述
最後是加載LZ4的AssetBundle之後的內存:
在這裏插入圖片描述
可以看出,LZMA的AssetBundle加載完之後,比LZ4的AssetBundle加載完的總內存增加了250Mb左右。但是從單幀採樣來看,卻看不出他們之前的區別,只知道是Unity本身佔用的內存。
根據官方文檔的說法LZMA是一個整體打成壓縮包。LZ4是每個文件單獨再打成壓縮包再放在一起。所以加載LZ4的時候,AssetBundle只是一個引用,可以單獨的提取LZ4壓縮包裏面的個別文件來使用。
而LZMA作爲一個整體,必須一起加載到內存裏面去。LZMA加載完之後,是會解壓縮然後再次打包成LZ4的格式存在於內存裏面,然後再加載的。
如果按照Unity官方的這個說法,那麼LZMA的加載速度慢,還有內存大,就是必然的事情了。
對比可以看出,LZ4無論從加載速度還是內存方面都要比LZMA好很多。唯一的缺點就是容量會大,具體比LZMA大多少,這需要具體的情況去分析。
雖然LZ4很多優點,但有些項目還是不能愉快的使用,因爲對於買量遊戲來說,安裝包的包體大小決定了買量的成本,爲了減少包體容量,研發團隊很有可能寧願犧牲性能來換取容量上的減少。這真是一個可悲的現實。

二、AssetBundle的依賴拆分策略

這是一個一直在討論的問題,想要做到的目的很明確:
1、AssetBundle之間不要有冗餘的資源
2、下載容量小
3、加載速度快
4、內存佔用少

在討論這個問題之前,先要看遊戲實際的情況。
有些遊戲,根本不需要考慮依賴拆分的問題。比如格鬥遊戲,用dlc來增加角色,每個角色就是一個資源包,角色與角色之間也沒什麼太多的共用資源。這種情況下,每個角色打成一個bundle,簡單快捷。
但這種情況畢竟不多見。正常的項目,美術資源之間重複利用反而是經常出現的。所以說剛纔那幾個問題還是需要考慮。
對於第一個問題,如果想完全沒有冗餘資源,很容易做到,只要把資源的所有依賴都單獨打包AssetBundle,比如一個模型,他使用了一個fbx,一個材質球,一張貼圖,三個動畫文件。那麼我們就把他們全部單獨打包,包括模型預設本身,我們生成7個AssetBundle,就完全不會有冗餘了,就算其他模型用到了相同的網格模型或者貼圖,也不會有問題。
但這樣簡單粗暴的處理方式,將會產生其他的問題。
1、AssetBundle文件本身每個文件都會有一些文件本身的數據在裏面的,我們可以成爲文件頭信息。雖然資源沒有冗餘了,但是由於文件多了,文件頭的信息加起來也會變多,使得總容量變大了。
2、由於文件變多了,導致玩家下載文件的數量也變多。如果有從網上下載過資源都應該知道,下載文件數量多的時候,每個文件需要建立新的連接來下載,甚至很多小文件只有幾Kb或者幾十Kb,這樣是達不到用戶最大網速下載的,除非遊戲開多線程來下載這些文件,不然總的下載速度是比下載單獨一個文件要慢比較多。
3、加載散文件時,我們需要先查依賴列表,然後逐個文件去加載依賴的AssetBundle,也會比單獨加載一個AssetBundle慢。但這個過程耗時比較少,所以一般來說感覺不明顯。
4、在內存佔用方面,實際上差別會比較大。
同樣一個模型加載,我用了2種不同的方式:
第一種是所有依賴的資源打在同一個AssetBundle裏面,序列化文件佔用只有一個,佔用內存99kb
在這裏插入圖片描述
第二種是把依賴全部打散成單獨的AssetBundle裏面,序列化文件佔用有14個,佔用內存0.7mb。
在這裏插入圖片描述
一個模型佔用了0.7mb,如果遊戲裏面使用的資源多起來,AssetBundle多起來了,就會比較恐怖了。這是一個較爲大型的遊戲的內存,可以看到AssetBundle有1688個,佔用了86.3mb。這部分只是單純的AssetBundle佔用,不包括他們本身的資源比如網格模型、材質球、貼圖等。
在這裏插入圖片描述
從上面的結果看,AssetBundle打得多了,實在是沒有好處。
於是我們得出結論,AssetBundle的數量儘量減少一些。但減少到多少合適呢?
如果不需要考慮玩家下載更新,只是單純的把資源變成AssetBundle來加載,用LZ4的壓縮方式來說,整個遊戲的資源打在一起,的確是很好的選擇。這樣加載最快,也不會有任何的冗餘。但從實際情況出發,如果只是爲了加載資源,完全不需要更新的話,也沒有必要用到AssetBundle了,直接用Resource.Load就行了。

我自己在實際項目中使用過三種策略:
1、所有資源都打散爲單獨的AssetBundle
2、一個單獨個體的模型包含依賴打成一個AssetBundle
3、根據依賴引用數量來判斷某個資源是否需要打成AssetBundle
第1種方式,是最簡單快捷的解決了資源冗餘的問題。這是絕對的不可能有冗餘。而且由於都是拆散之後的資源,所以其中某一個資源被修改了,一般都不會影響到別的文件,在版本更新的時候,玩家實際需要下載的文件容量應該是最小化的。但缺點剛纔已經分析過,由於文件多,導致了玩家下載時會慢,必須開多線程同時下載才能達到正常的網速。然後加載時會讓內存變大。
第2種方式,文件少,下載和加載的次數變少了很多。但由於資源之間會有比較多的冗餘,導致了打出來的AssetBundle總體容量會變大不少。然後加載到內存裏面時,冗餘的資源會產生重複的資源內存分配,比如用到同一張貼圖的十個模型,加載到內存裏面,這張相同的貼圖由於是從十個不同的AssetBundle加載的,所以內存裏面會有十張一模一樣的貼圖存在,導致內存佔用變大。然後如果這張共用的貼圖被修改了,那麼下次版本更新,用到這張貼圖的十個模型的AssetBundle都需要玩家重新下載。所以我感覺這種做法有點得不償失,是否能使用,要取決於資源的複用情況多不多,如果多,不建議這樣做。
第3種方式,把直接用於加載的預設全部列出來,逐個預設打成一個AssetBundle,然後檢查這些預設依賴到哪些文件。如果資源文件只被一個預設用到了,就不需要打包AssetBundle,但如果資源文件被2個或2個以上的預設用到了,那麼這個資源就需要單獨打包。這種方式比起全部打散依賴的方式,最終打出來的AssetBundle數量會少很多,也基本可以保證沒有冗餘文件。但也不是完全完美的。假如一個資源一開始只會被一個預設用到,後來變成和另外一個預設共用了,那麼打包的時候,爲了把共用的資源分離出來,原預設和這個資源都需要重新打包。如果經常有這種情況出現,可能熱更新時需要更新的文件會隨着共用資源變多而越來越多。
從總體的優缺點來看,第3種方式應該是比較合理的處理方式。

三、AssetBundle的卸載策略

對於AssetBundle的卸載,一般都是出於對內存控制的考慮。
想達到這樣的目的:
1、AssetBundle儘量佔用內存少
2、在不需要使用該AssetBundle裏面的資源時,及時的釋放資源
爲了讓AssetBundle儘量少佔用內存, LZ4的壓縮方式明顯比LZMA要適合很多。但加載進來之後,什麼時候該卸載資源,這是一個比較複雜的問題。
在寫這篇文章之前,我看了網上一些文章的分享。有些文章建議,在加載完AssetBundle之後,加載資源本身,然後立刻把AssetBundle釋放,使用Unload(false)的方式。這樣就可以沒有AssetBundle的內存佔用,當資源不再使用的時候,可以用Resources.UnloadUnusedAssets( )方法來釋放沒用的資源,從而達到內存釋放的目的。
這樣做其實會存在幾個問題:
1、假如資源存在資源依賴的重用,比如一張貼圖被多個模型資源使用,那麼在分別加載這些模型時,由於前一個資源的AssetBundle已經被釋放了,加載下一個資源的時候,重複利用的資源比如貼圖,就會需要再次加載AssetBundle然後生成。
對於Unity來說,一個AssetBundle被釋放然後再重新加載,雖然還是同一個AssetBundle同一份資源,但他們之間的資源是不會有任何關聯的,所以加載到的圖片雖然是同一張,但在內存裏面會有兩份獨立的圖片資源。這樣就會導致內存冗餘,如果資源一直重複加載並且得不到合理的釋放,就會變成內存泄露導致崩潰。
這個我感覺Unity的底層如果可以再進一步的優化,讓加載到的相同資源在內存裏面只會有一份,就能解決這個問題。但那畢竟是Unity內部的事情,我們不能把希望寄託在他能解決上。
2、假如資源被代碼引用着,比如把LoadAsset出來的Object保存起來,方便下一次實例化之類。那麼就算場景裏面用到該資源的GameObject都已經被刪除了,但還是不能通過UnloadUnusedAssets來釋放,如果沒有額外的資源管理策略,這些資源將會一直佔用着內存而且不會釋放。
3、如果AssetBundle加載完之後就立刻釋放,一些被依賴得比較多的資源,將會反覆的加載和卸載,導致了無謂的性能消耗。

基於這些問題,我並不建議加載完資源之後立刻卸載AssetBundle。
我的思路是這樣的:
1、每一個加載的AssetBundle都受到管理,記錄每個AssetBundle依賴了哪些AssetBundle,並且記錄從單個AssetBundle裏面LoadAsset過的所有資源。
2、每一個加載出來的資源都可以判斷得到當前是否有被使用
3、找合適的時機檢查資源使用情況,如果一個AssetBundle裏面加載過的資源都沒有被使用,這個AssetBundle本身又沒有被其他資源依賴着,那麼這個AssetBundle就可以釋放。

要記錄AssetBundle及其依賴的加載情況,是非常容易的事情,因爲這都是我們主動去做的事情。比如加載一個AssetBundle,我們肯定知道,用到了哪些依賴,我們也是從AssetBundleManifest.GetAllDependencies裏面查找然後加載的。這時候,我們要記錄加載過的AssetBundle,然後被依賴的AssetBundle做計數器,是很簡單的事情。
然後對於單個Asset的加載,也是通過LoadAsset方法主動加載的,所以加載過的資源也是可以逐個AssetBundle做一個記錄的。
這個思路里面,最難的一點,是怎樣去判斷每一個加載出來的資源是否正在被使用。
被使用的情況有3種:
1.資源被實例化爲GameObject放在場景裏面使用。
2.資源被賦予在某個Component的屬性裏面,比如圖片Sprite被指定在Image組件上。
3.資源被存在代碼裏面,比如把Object存在某個類裏面方便實例化時使用。
這3種情況,如果都不存在的時候,使用Resources.UnloadUnusedAssets( )是可以把某個已加載到內存裏面的Asset釋放掉的。但釋放的過程並沒有結果返回,所以我們不知道釋放了哪一些資源。
這時候,我們可以使用C#的弱引用(System.WeakReference)機制來判斷一個資源是否被釋放。弱引用的原理大概是這樣的:一般的對象存在某個代碼裏面時,不管是UnloadUnusedAssets還是System.GC.Collect,都是不能釋放的,但弱引用的對象,是可以被上面兩種手段清空的。所以我們可以把資源存在弱引用裏面,當資源真的被釋放掉之後,我們就可以通過判斷弱引用裏面的對象是否已經爲空,來判斷這個對象是否有其他地方用到。
具體的步驟如下:
1、每個AssetBundle加載完之後,存在一個計數器裏面。然後給每個AssetBundle建立一個管理的對象,裏面單獨管理自己加載過的Asset。
2、對某個AssetBundle進行LoadAsset時,用AssetName作爲key,把加載出來的Object對象創建一個弱引用的對象,存到字典裏面。代碼大概如下。
在這裏插入圖片描述
3、找一個合適的時機,比如切換場景之類本身卡頓不會很明顯的時候,調用UnloadUnusedAssets和System.GC.Collect。
4、隔一段時間,檢查一下剛纔保存的弱引用裏面,資源是否還存活。代碼大概如下:
在這裏插入圖片描述
5、當確定某個AssetBundle裏面加載過的資源都已經沒有在使用時,我們還要判斷一下,當前AssetBundle有沒有被其他AssetBundle依賴。這時候第一步的計數器就起作用了,如果被依賴數爲0,那就是可以釋放了。釋放的時候,看當前的AssetBundle還有沒有依賴到別的AssetBundle。如果有,把所有依賴的計數器減一。然後再看看被減一的計數器有沒有已經爲0的,繼續釋放。
6、釋放的時候,最好不要立刻就Unload(true)。建議是先把可以釋放的AssetBundle放到一個隊列裏面,等1分鐘之後再釋放。這是怕有些時候邏輯會在釋放完某些資源之後,又立刻去申請使用,這時候其實AssetBundle是不需要釋放的,釋放了還需要重新加載回來,有無謂的消耗。

這個流程下來,一般就能比較好的管理AssetBundle和Asset的使用和卸載了。但需要注意幾點潛規則:
1、在調用UnloadUnusedAssets和System.GC.Collect之後,如果立刻檢查弱引用,很有可能並沒有立刻判斷到對象已經被釋放。因爲UnloadUnusedAssets本身是一個AsyncOperation,是異步的。所以需要隔一段時間判斷一次。
2、注意一個GameObject被Destroy之後,並不一定真正的被釋放掉了。假如這個GameObject是被某個類的變量引用着,就算GameObject被Destroy了,但實際上只要引用他的對象一直不清空,當前的這段內存是不會釋放的。
這個問題其實很常發生,因爲Destroy了某個Unity的GameObject之後,用代碼判斷某個引用這GameObject的變量時,的確可以判斷得到對象爲空了。所以很多人在Unity裏面寫代碼的時候,都不習慣清空保存的變量。但其實可以做一個實驗,我們可以寫一個public的變量,然後把一個GameObject賦予給他,最後把GameObject刪掉,回頭在Inspector面板裏面看看,你會發現這個變量會變成Missing,而不是None。Missing的意思是,這裏引用的guid不爲空,但引用的具體值可能找不到了。
3、如果決定要使用弱引用來判斷資源,就不要再用其他代碼把資源存起來了,比如很多人會另外建立一個管理類,先存起一個資源,等別人再次請求的時候,直接返回這個資源用於實例化之類。而是應該用弱引用的對象來做這個事情。因爲如果用其他代碼來存,那麼只要代碼不釋放,弱引用也就永遠判斷不到他有沒有真的沒被使用。而且弱引用本身就把這個資源存在target參數裏面,只要先判斷一下target是否爲空,如果不爲空就返回使用就可以了。如果弱引用裏面的資源被釋放了,就再在AssetBundle裏面加載出來。
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章