Unity 3D中的內存管理

本文歡迎轉載,但煩請保留此行出處信息:http://www.onevcat.com/2012/11/memory-in-unity3d/

Unity3D在內存佔用上一直被人詬病,特別是對於面向移動設備的遊戲開發,動輒內存佔用飆上一兩百兆,導致內存資源耗盡,從而被系統強退造成極差的體驗。類似這種情況並不少見,但是絕大部分都是可以避免的。雖然理論上Unity的內存管理系統應當爲開發者分憂解難,讓大家投身到更有意義的事情中去,但是對於Unity對內存的管理方式,官方文檔中並沒有太多的說明,基本需要依靠自己摸索。最近在接手的項目中存在嚴重的內存問題,在參照文檔和Unity Answer衆多猜測和證實之後,稍微總結了下Unity中的內存的分配和管理的基本方式,在此共享。

雖然Unity標榜自己的內存使用全都是“Managed Memory”,但是事實上你必須正確地使用內存,以保證回收機制正確運行。如果沒有做應當做的事情,那麼場景和代碼很有可能造成很多非必要內存的佔用,這也是很多Unity開發者抱怨內存佔用太大的原因。接下來我會介紹Unity使用內存的種類,以及相應每個種類的優化和使用的技巧。遵循使用原則,可以讓非必要資源儘快得到釋放,從而降低內存佔用。


Unity中的內存種類

實際上Unity遊戲使用的內存一共有三種:程序代碼、託管堆(Managed Heap)以及本機堆(Native Heap)。

程序代碼包括了所有的Unity引擎,使用的庫,以及你所寫的所有的遊戲代碼。在編譯後,得到的運行文件將會被加載到設備中執行,並佔用一定內存。這部分內存實際上是沒有辦法去“管理”的,它們將在內存中從一開始到最後一直存在。一個空的Unity默認場景,什麼代碼都不放,在iOS設備上佔用內存應該在17MB左右,而加上一些自己的代碼很容易就飆到20MB左右。想要減少這部分內存的使用,能做的就是減少使用的庫,稍後再說。

託管堆是被Mono使用的一部分內存。Mono項目一個開源的.net框架的一種實現,對於Unity開發,其實充當了基本類庫的角色。託管堆用來存放類的實例(比如用new生成的列表,實例中的各種聲明的變量等)。“託管”的意思是Mono“應該”自動地改變堆的大小來適應你所需要的內存,並且定時地使用垃圾回收(Garbage Collect)來釋放已經不需要的內存。關鍵在於,有時候你會忘記清除對已經不需要再使用的內存的引用,從而導致Mono認爲這塊內存一直有用,而無法回收。

最後,本機堆是Unity引擎進行申請和操作的地方,比如貼圖,音效,關卡數據等。Unity使用了自己的一套內存管理機制來使這塊內存具有和託管堆類似的功能。基本理念是,如果在這個關卡里需要某個資源,那麼在需要時就加載,之後在沒有任何引用時進行卸載。聽起來很美好也和託管堆一樣,但是由於Unity有一套自動加載和卸載資源的機制,讓兩者變得差別很大。自動加載資源可以爲開發者省不少事兒,但是同時也意味着開發者失去了手動管理所有加載資源的權力,這非常容易導致大量的內存佔用(貼圖什麼的你懂的),也是Unity給人留下“吃內存”印象的罪魁禍首。


優化程序代碼的內存佔用

這部分的優化相對簡單,因爲能做的事情並不多:主要就是減少打包時的引用庫,改一改build設置即可。對於一個新項目來說不會有太大問題,但是如果是已經存在的項目,可能改變會導致原來所需要的庫的缺失(雖說一般來說這種可能性不大),因此有可能無法做到最優。

當使用Unity開發時,默認的Mono包含庫可以說大部分用不上,在Player Setting(Edit->Project Setting->;Player或者Shift+Ctrl(Command)+B裏的Player Setting按鈕)面板裏,將最下方的Optimization欄目中“Api Compatibility Level”選爲.NET 2.0 Subset,表示你只會使用到部分的.NET 2.0 Subset,不需要Unity將全部.NET的Api包含進去。接下來的“Stripping Level”表示從build的庫中剝離的力度,每一個剝離選項都將從打包好的庫中去掉一部分內容。你需要保證你的代碼沒有用到這部分被剝離的功能,選爲“Use micro mscorlib”的話將使用最小的庫(一般來說也沒啥問題,不行的話可以試試之前的兩個)。庫剝離可以極大地降低打包後的程序的尺寸以及程序代碼的內存佔用,唯一的缺點是這個功能只支持Pro版的Unity。

這部分優化的力度需要根據代碼所用到的.NET的功能來進行調整,有可能不能使用Subset或者最大的剝離力度。如果超出了限度,很可能會在需要該功能時因爲找不到相應的庫而crash掉(iOS的話很可能在Xcode編譯時就報錯了)。比較好地解決方案是仍然用最強的剝離,並輔以較小的第三方的類庫來完成所需功能。一個最常見問題是最大剝離時Sysytem.Xml是不被Subset和micro支持的,如果只是爲了xml,完全可以導入一個輕量級的xml庫來解決依賴(Unity官方推薦這個)。

關於每個設定對應支持的庫的詳細列表,可以在這裏找到。關於每個剝離級別到底做了什麼,Unity的文檔也有說明。實際上,在遊戲開發中絕大多數被剝離的功能使用不上的,因此不管如何,庫剝離的優化方法都值得一試。


託管堆優化

Unity有一篇不錯的關於託管堆代碼如何寫比較好的說明,在此基礎上我個人有一些補充。

首先需要明確,託管堆中存儲的是你在你的代碼中申請的內存(不論是用js,C#還是Boo寫的)。一般來說,無非是new或者Instantiate兩種生成object的方法(事實上Instantiate中也是調用了new)。在接收到alloc請求後,託管堆在其上爲要新生成的對象實例以及其實例變量分配內存,如果可用空間不足,則向系統申請更多空間。

當你使用完一個實例對象之後,通常來說在腳本中就不會再有對該對象的引用了(這包括將變量設置爲null或其他引用,超出了變量的作用域,或者對Unity對象發送Destory())。在每隔一段時間,Mono的垃圾回收機制將檢測內存,將沒有再被引用的內存釋放回收。總的來說,你要做的就是在儘可能早的時間將不需要的引用去除掉,這樣回收機制才能正確地把不需要的內存清理出來。但是需要注意在內存清理時有可能造成遊戲的短時間卡頓,這將會很影響遊戲體驗,因此如果有大量的內存回收工作要進行的話,需要儘量選擇合適的時間。

如果在你的遊戲裏,有特別多的類似實例,並需要對它們經常發送Destroy()的話,遊戲性能上會相當難看。比如小熊推金幣中的金幣實例,按理說每枚金幣落下臺子後都需要對其Destory(),然後新的金幣進入臺子時又需要Instantiate,這對性能是極大的浪費。一種通常的做法是在不需要時,不摧毀這個GameObject,而只是隱藏它,並將其放入一個重用數組中。之後需要時,再從重用數組中找到可用的實例並顯示。這將極大地改善遊戲的性能,相應的代價是消耗部分內存,一般來說這是可以接受的。關於對象重用,可以參考Unity關於內存方面的文檔中Reusable Object Pools部分,或者Prime31有一個是用Linq來建立重用池的視頻教程(Youtube,需要翻牆,上半部分下半部分)。

如果不是必要,應該在遊戲進行的過程中儘量減少對GameObject的Instantiate()和Destroy()調用,因爲對計算資源會有很大消耗。在便攜設備上短時間大量生成和摧毀物體的話,很容易造成瞬時卡頓。如果內存沒有問題的話,儘量選擇先將他們收集起來,然後在合適的時候(比如按暫停鍵或者是關卡切換),將它們批量地銷燬並且回收內存。Mono的內存回收會在後臺自動進行,系統會選擇合適的時間進行垃圾回收。在合適的時候,也可以手動地調用System.GC.Collect()來建議系統進行一次垃圾回收。要注意的是這裏的調用真的僅僅只是建議,可能系統會在一段時間後在進行回收,也可能完全不理會這條請求,不過在大部分時間裏,這個調用還是靠譜的。


本機堆的優化

當你加載完成一個Unity的scene的時候,scene中的所有用到的asset(包括Hierarchy中所有GameObject上以及腳本中賦值了的的材質,貼圖,動畫,聲音等素材),都會被自動加載(這正是Unity的智能之處)。也就是說,當關卡呈現在用戶面前的時候,所有Unity編輯器能認識的本關卡的資源都已經被預先加入內存了,這樣在本關卡中,用戶將有良好的體驗,不論是更換貼圖,聲音,還是播放動畫時,都不會有額外的加載,這樣的代價是內存佔用將變多。Unity最初的設計目的還是面向臺式機,幾乎無限的內存和虛擬內存使得這樣的佔用似乎不是問題,但是這樣的內存策略在之後移動平臺的興起和大量移動設備遊戲的製作中出現了弊端,因爲移動設備能使用的資源始終非常有限。因此在面向移動設備遊戲的製作時,儘量減少在Hierarchy對資源的直接引用,而是使用Resource.Load的方法,在需要的時候從硬盤中讀取資源,在使用後用Resource.UnloadAsset()和Resources.UnloadUnusedAssets()儘快將其卸載掉。總之,這裏是一個處理時間和佔用內存空間的trade off,如何達到最好的效果沒有標準答案,需要自己權衡。

在關卡結束的時候,這個關卡中所使用的所有資源將會被卸載掉(除非被標記了DontDestroyOnLoad)的資源。注意不僅是DontDestroyOnLoad的資源本身,其相關的所有資源在關卡切換時都不會被卸載。DontDestroyOnLoad一般被用來在關卡之間保存一些玩家的狀態,比如分數,級別等偏向文本的信息。如果DontDestroyOnLoad了一個包含很多資源(比如大量貼圖或者聲音等大內存佔用的東西)的話,這部分資源在場景切換時無法卸載,將一直佔用內存,這種情況應該儘量避免。

另外一種需要注意的情況是腳本中對資源的引用。大部分腳本將在場景轉換時隨之失效並被回收,但是,在場景之間被保持的腳本不在此列(通常情況是被附着在DontDestroyOnLoad的GameObject上了)。而這些腳本很可能含有對其他物體的Component或者資源的引用,這樣相關的資源就都得不到釋放,這絕對是不想要的情況。另外,static的單例(singleton)在場景切換時也不會被摧毀,同樣地,如果這種單例含有大量的對資源的引用,也會成爲大問題。因此,儘量減少代碼的耦合和對其他腳本的依賴是十分有必要的。如果確實無法避免這種情況,那應當手動地對這些不再使用的引用對象調用Destroy()或者將其設置爲null。這樣在垃圾回收的時候,這些內存將被認爲已經無用而被回收。

需要注意的是,Unity在一個場景開始時,根據場景構成和引用關係所自動讀取的資源,只有在讀取一個新的場景或者reset當前場景時,纔會得到清理。因此這部分內存佔用是不可避免的。在小內存環境中,這部分初始內存的佔用十分重要,因爲它決定了你的關卡是否能夠被正常加載。因此在計算資源充足或是關卡開始之後還有機會進行加載時,儘量減少Hierarchy中的引用,變爲手動用Resource.Load,將大大減少內存佔用。在Resource.UnloadAsset()和Resources.UnloadUnusedAssets()時,只有那些真正沒有任何引用指向的資源會被回收,因此請確保在資源不再使用時,將所有對該資源的引用設置爲null或者Destroy。同樣需要注意,這兩個Unload方法僅僅對Resource.Load拿到的資源有效,而不能回收任何場景開始時自動加載的資源。與此類似的還有AssetBundle的Load和Unload方法,靈活使用這些手動自願加載和卸載的方法,是優化Unity內存佔用的不二法則~

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