1. 程式人生 > >Unity開發實戰探討-資源的載入釋放最佳策略

Unity開發實戰探討-資源的載入釋放最佳策略

注:本文中用到的大部分術語和函式都是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來快速釋放當前空閒資源其實是一招險棋,有欲速則不達的可能:

  1. 首先,如果大部分資源都存在引用,那麼使用該函式徒勞無功。
  2. 其次,如果該資源在UnloadUnusedAssets以後又被起用,那麼資源重新載入的損耗得不償失。
  3. 最後,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體積不大,這種方式也帶來了一定的靈活性。