注:本文中用到的大部分術語和函數都是Unity中比較基本的概念,所以本文只是直接引用,不再詳細解釋各種概念的具體內容,若要深入了解,請查閱相關資料。
Unity的資源陷阱
游戲資源的加載和釋放導致的內存泄漏問題一直是Unity游戲開發的一個黑洞。因此導致游戲拖慢,卡頓甚至閃退問題成為了Unity游戲的一個常見症狀。
究其根源,一方面是因游戲設備尤其是Unity擅長的移動設備運行內存非常有限,另外一方面是因為Unity不太清晰的加載釋放策略和謎一樣的GC(垃圾收集)機制,共同賦予了Unity “內存殺手”“低效引擎”的惡名,但事實上如果能夠深入的了解Unity的資源加載釋放機制,亦步亦趨的根據自身情況管理好內存的使用,那么Unity游戲完全可以跳出內存泄漏的陷阱。
那么下面,我們從資源的加載方式,資源的相關概念,加載釋放的最佳策略三個方面來逐步探討這個Unity的“危險領域”。
資源的加載方式
Unity的資源加載方式分兩大種類:靜態加載和動態加載。
靜態加載
顧名思義,直接通過設置屬性的辦法,把資源直接綁定在場景內的任意對象上,如2D對象的Sprite屬性和3D對象的Materials屬性;另外通過自定義代碼上的Public屬性綁定的任何資源也屬於靜態加載范疇。
靜態加載是最為常見的資源加載方式,其資源的生命周期與其所在的場景完全一致,在場景加載時加載,在場景切換時釋放,所以這種方式的優缺點也是顯而易見的:
優點:可以在場景加載過程中完成自身的加載過程,所以在場景運行期間該資源沒有任何性能隱患;另外在場景切換時會被完全釋放,無須擔心因為釋放不及時不完整而導致內測泄漏問題。
缺點:只支持不變的靜態資源,無法根據游戲的實際需要靈活更換不同資源;所有資源必須和場景同生共死,無法在場景運行過程中提前釋放,如果該資源非常龐大並且只在短時間內需要,則會帶來不小的內存浪費。
動態加載
動態加載一般發生在場景的運行期間,游戲為了一定的需求動態的加載和表現不同的資源而產生的需求:如果游戲根據不同的玩家顯示不同的頭像,根據玩家選擇的不同角色而顯示不同的3D模型。動態加載的優缺點是非常極端的:
優點:根據游戲設計要求,有些資源在場景開始時無法確定,必須動態加載;動態資源可以在場景運行的任何時間加載,也可以在任何時間釋放,開發者具有很強的靈活性和主動性。
缺點:很明顯,動態資源的控制需要開發者親力親為和更高的技巧;而一旦缺乏對其合理的控制,內存陷阱將會遍地開花,游戲的性能問題和內存泄漏將無法避免。
動態加載的常見方式
Resources 本地資源加載:通過引擎內部的Resources類,對項目中所有Resources目錄下的資源進行動態加載。
AssetBundle本地或者遠程資源包加載:通過引擎內部的AssetBundle類,對網絡,內存和本地文件中的AssetBundle資源包進行加載。然后從資源包中獲取資源,在游戲中使用。
Instantiate實例化游戲對象:通過Resources或AssetBundle中的加載的對象,一般不能直接在場景中使用,需要通過Instantiate方法,實例化這些對象,使其成為場景中可用的游戲對象。
AssetDatabase加載資源:通過AssetDatabase的相關函數加載資源,由於僅適用於Editor環境,在這里不加累述。
基本資源加載概念
資源的類型
Unity中常見的資源包括以下幾種:
GameObject(游戲對象)
Shader(着色器)
Mesh(網格)
Material(材質)
Texture/Sprite(貼圖/精靈)
資源內存鏡像的引用和復制
要理解Unity資源的使用,必須先了解以下幾個概念:
內存鏡像:任何游戲資源或對象一旦加載,都會占用設備的一部分內存區域,這個內存區域就是資源或對象的內存鏡像,如果內存鏡像過多達到設備的極限,游戲必然會發生性能問題。
引用和復制:Unity的“黑科技”之一, 也是資源加載和釋放的主要難點。
引用:指對原資源僅僅是引用關系,不再重新復制一份內存鏡像,但引用的關鍵在於,如果原資源被刪除會導致引用關系損壞,使得引用的對象發生資源丟失。
復制:復制原資源的內存鏡像,從而產生兩個不同的內存區域,如果被復制的資源被釋放,不會影響復制的資源。
但不幸的是,Unity中的游戲對象不能簡單的用引用和復制來進行區分,大部分的對象不同部分采用了不同模式甚至混合模式,使得游戲對象的內存分配顯得錯綜復雜。
資源加載時對內存的使用
下面通過一個實例來說明資源加載會使用多少內存,比如一個普通的3D對象,包括了Shader/Mesh/Material/Texture等資源,這些資源需要從AssetBundle加載,如果要將其實例化到場景,那么將會占用如下圖所示的內存空間:
首先,從文件、網絡或者其他內存空間加載AssetBundle以后,會形成AssetBundle內存鏡像(上圖紫色部分)。
其次,從AssetBundle內存鏡像中再加載GameObject以后,該GameObject用到的Shader/Mesh/Material/Texture也同時被加載出來,形成各自不同的內存鏡像(注意:請參考上圖紫色虛線框中的內容,可知這些資源內存鏡像與AssetBundle內存鏡像是不同的)
最后Instantiate實例化GameObject以后,GameObject會再一次復制GameObject資源的內存鏡像到一個新的內存區域,形成全新的對象數據。(上圖上方綠色框中內容)
資源的加載需要理解以下要點
要點1:盡管GameObject是對原有資源內存鏡像的完全復制,但由於Unity對各種資源種類的處理方式不同,導致GameObject中的其他相關資源並不是簡單的復制關系:
Shader:完全的引用,不占用額外內存,如果原Shader資源被釋放會造成資源丟失而損壞對象。
Mesh:復制原資源內存空間的同時,還引用了原資源的數據,也就是說不但占用額外的內存,而且一旦原資源被釋放,也會造成數據丟失而損壞對象。
Material:同Mesh,復制並引用原資源。
Texture:通Shader,完全引用原資源。
要點2:從AssetBundle加載到GameObject實例化,大部分資源實際占用3處內存,那么最終我們要釋放這3處內存才算將該資源完全釋放。
要點3:要特別注意和理解引用關系,這個在后面的資源釋放章節中具有重大意義。
資源加載釋放最佳策略
Resources資源加載
Resources加載是將游戲內部一部分以文件形式存儲的資源加載出來供游戲使用,Resources加載的步驟一般有二步(下面是示例代碼):
Object cubePreb = Resources.Load< GameObject >(cubePath);
GameObject cube = Instantiate(cubePreb) as GameObject;
首先通過Resources.Load函數把對象資源(cubePreb)加載到內存鏡像。
其次通過Instantiate實例化該資源的內存鏡像變成游戲中可用的對象(cube),當然如果是Shader/Mesh/Material/Texture類型資源無須再次實例化,可以直接使用。由此可見Resources加載的資源一般占用2處內存空間:所用資源cubePreb的內存鏡像和實例化對象cube的內存鏡像。
這里順便提下Resources資源加載的一個“黑科技”:OnDemand方式。以上述代碼為例,cubePreb的所需資源在Resources.Load的時候不會加載,而將在第一次Instantiate的時候一起加載,也常常會導致一些比較大的對象在第一次實例化時造成卡頓現象,不過這個性能問題和內測泄漏無關,不在本文的探討范疇。
Resources最佳加載策略:
- 相同對象的Resources.Load只需調用一次,該資源對象可以共享,反復調用雖然不會引起內存鏡像的重復建立,但依然存在性能損耗。
- 一般只對GameObject進行實例化操作,盡量避免對Shader 、Mesh、Material、Texture資源進行實例化從而造成內存浪費。
- 除了明確需要全局共享的資源,盡量避免使用全局靜態變量來引用Resources.Load出的資源對象,因為全局引用的對象存在釋放陷阱。
Resource 資源釋放
單體釋放Reources.UnloadAsset(Object)
主動卸載獨立資源,主要作用在於及時釋放場景的中的資源,減低運行時的內存損耗,提高游戲性能;但這種方式也帶來了不小的風險,由於Unity游戲的資源引用關系錯綜復雜,如果要單獨釋放一個資源,要明確該資源已經在場景中不再被引用,否則輕者造成游戲顯示錯誤,重則造成游戲報錯。
另外,Reources.UnloadAsset(Object)還有一些暗坑,比如釋放Sprite需要先釋放Sprite.Texture否則Texture就會存留在內存,所以在使用這個函數的時候,要清楚釋放的對象有無內部引用資源。
統一釋放Resources.UnloadUnusedAssets
這是一個統一的,一次性的,比較完整的釋放閑置資源的函數,而且是Unity官方非常推薦的一種方式,但這個函數實際的使用效果並沒有想象的那么美好,該函數本身就是Unity資源釋放的一個陷阱。
首先UnloadUnusedAssets對所有需要釋放資源有一個非常重要的前置條件:只有不存在任何引用關系的資源才能被該函數釋放,看起來這是一個明確的要求,但由於Unity資源的相互引用關系比較隱晦繁復,想要明確的判斷某一個資源不存在引用關系是有一定難度的,並且,如果一個我們想釋放的資源存在任何隱性的引用關系,UnloadUnusedAssets將會無視這個資源而無任何反饋,這種情況常常會被開發人員忽略而造成內存的泄漏。
一般情況下,要明確一個資源不再被引用,首先要把所有用到該資源使用GameObject.Destroy函數進行銷毀,然后要把所有引用到該資源的變量顯性的設置為Null,尤其要關注的是類成員和靜態變量的引用,最后調用UnloadUnusedAssets才能有效地釋放這個資源。
根據實戰經驗來看,最佳使用UnloadUnusedAssets的時機還是在場景切換的時候,由於Unity的場景關閉會有效地銷毀所有的對象和所有代碼的引用,那么在場景切換,尤其是在新場景的開頭UnloadUnusedAssets上一個場景的資源處理是比較穩妥的做法;而在場景運行過程中希望不斷調用UnloadUnusedAssets來快速釋放當前空閑資源其實是一招險棋,有欲速則不達的可能:
- 首先,如果大部分資源都存在引用,那么使用該函數徒勞無功。
- 其次,如果該資源在UnloadUnusedAssets以后又被起用,那么資源重新加載的損耗得不償失。
- 最后,UnloadUnusedAssets是一個異步函數,在其執行過程中,一旦資源又被使用將會導致無法預知的后果。實際開發中發現在場景運行中反復調用UnloadUnusedAssets存在閃退的風險。
Resources最佳釋放策略:
- 實例化的對象,在不再使用以后必須立刻Destroy,該清理操作不會引起資源的丟失,風險較小,要充分滿足。
- 對於內存消耗非常巨大,並且在場景運行過程中能夠明確不再使用的資源內存鏡像,可以主動使用Reources.UnloadAsset進行強制釋放。對於消耗不大的,等場景結束后進行統一釋放是更穩妥的選擇。
- 大部分資源建議在場景切換以后,通過Resources.UnloadUnusedAssets方法進行后置釋放,必要時再加上GC.Collect。(在下一個場景的開始甚至在一個獨立的換場場景中調用都是比較穩妥的選擇)
- 全局靜態變量和類成員變量引用的資源,務必先把引用設為Null值,然后再調用Reources.UnloadUnusedAssets才能正確釋放。
AssetBundle資源加載
AssetBundle是Unity提供的另一種資源加載方式,開發者可以把一批資源打包,然后通過網絡下載或者文件加載的方式進行加載。
介於Resources方式的資源必須一起打入游戲包體,AssetBundle方式則提供了一種更為靈活的資源加載方式,AssetBundle無需進入游戲包體,大大減少了游戲文件的體積,另外,AssetBundle允許通過網絡下載,也為游戲資源的獲取和升級提供了更為靈活的選擇。
AssetBundle加載資源一般分3步,(下面是示例代碼):
var bundle= AssetBundle.LoadFromFile(path);
var prefab = myLoadedAssetBundle.LoadAsset.<GameObject>("MyObject");
var obj = Instantiate(prefab);
根據前面提到的資源的內存使用和以上示例代碼所示,可以得知AssetBundle資源加載到最終加入游戲場景,需要存在3個對象:bundle本身,加載的資源prefab,和實例化出來的obj。這3個對象分別對應不同的內存鏡像,在釋放的時候需要分別考慮。
AssetBundle最佳加載策略:
- 相同內容的AssetBundle只Load一次,在其Unload之前反復加載會造成不必要的浪費和風險。
- 相同名稱的資源用LoadAsset也只需加載一次,這個和Resources.Load基本類似。
AssetBundle資源釋放
根據AssetBundle的3級對象,我們分別說下各自的釋放辦法:
實例化的obj:用GameObject.Destroy釋放。
加載的資源prefab:因為是內存鏡像,也可以用Object.Destroy釋放。另外Resources.UnloadUnusedAssets方法對這種資源釋放也是有效的,但條件比較苛刻,prefab的父(bundle)和子(obj)都要已經被釋放的情況下,加上本身引用清空,然后使用UnloadUnusedAssets才有效,所以這種辦法並不十分推薦。
加載的資源包bundle:AssetBundle.Unload方法是唯一的釋放手段。這個方法有2個參數,都有一定的意義:
參數為false的時候,僅僅把資源包內存釋放,但保留任何已經加載的資源和實例化對象,這些資源和對象的釋放有待后續代碼完成。
參數為true的時候,是一次比較徹底的內存釋放,資源包和所有被加載出的資源都會被釋放,當然實例化的obj不會被釋放,但引用關系會被破壞,所以在使用這種方式前必須提前銷毀所有實例化對象。
AssetBundle最佳釋放策略:
- 實例化的對象使用Destroy這個不加累述了。
- 已經加載的資源prefab,如果消耗巨大而且明確不再使用,可以直接使用Object.Destroy釋放。
- 如果AssetBundle能夠一次性加載完成所需資源的,可以使用AssetBundle.Unload(false)將AssetBundle的內存立刻釋放,然后再場景切換以后通過Resources.UnloadUnusedAssets方法釋放所有加載的資源,這種方案的缺陷是不能在AssetBundle.Unload以后再次使用該AssetBundle。
- 如果在場景運行過程中需要不斷從AssetBundle加載資源,在這種情況下無須提前做任何釋放行為,可以在場景切換以后,最終調用AssetBundle.Unload(true) 將全部資源包和資源釋放。這種方式的主要缺陷是,AssetBundle占用的資源會在整個場景過程中一直存在,造成內存浪費,但如果AssetBundle體積不大,這種方式也帶來了一定的靈活性。