參考視頻
Unity官方2019年12月15日UUG北京開發(fā)者活動淺談Unity內(nèi)存管理
Unity技術(shù)講師Arturo Nú?ez優(yōu)化技巧(上)
Unity技術(shù)講師Arturo Nú?ez優(yōu)化技巧(下)
什么是內(nèi)存?
操作系統(tǒng)有物理內(nèi)存和虛擬內(nèi)存兩個概念:
物理內(nèi)存
物理內(nèi)存也就是我們真是的硬件設(shè)備,例如內(nèi)存條。
我們需要知道,CPU訪問內(nèi)存是一個慢速過程。
訪問過程具體為:先訪問Cache,Cache包含L1,L2,L3,也就是一級緩存,二級緩存和三級緩存,若在這些緩存里全沒找到我們要的數(shù)據(jù),再去訪問內(nèi)存,接著會把找到的數(shù)據(jù)存放到Cache中,完成一次操作。
在Cache中沒有找到數(shù)據(jù),我們稱之為Cache Miss。因此過多的Cache Miss就會導(dǎo)致大量的內(nèi)存和Cache的IO交換,浪費大量時間。
因此我們需要盡量減少Cache Miss,來提高訪問速度,Unity為此提出了ECS方案,有興趣的小伙伴可以看看之前有關(guān)ECS介紹的文章,它們可以將存儲在內(nèi)存中的不連續(xù)數(shù)據(jù),變?yōu)檫B續(xù)的數(shù)據(jù),從而降低Cache Miss的概率。
臺式設(shè)備和移動設(shè)備內(nèi)存架構(gòu)的差異
- 首先移動設(shè)備沒有獨立顯卡。
- 移動設(shè)備沒有獨立顯存(顯存的作用是用來存儲顯卡芯片處理過或者即將提取的渲染數(shù)據(jù)),所有在移動端數(shù)據(jù)內(nèi)存和顯存是同一塊內(nèi)存。所以有可能我們游戲占用的內(nèi)存并不大,但是依舊爆內(nèi)存了,其實是因為顯存分配不出來了。這種情況,我們可以去查看一下Log,例如Android會有一個 OpenGL Error:Out Of Memory。
- 移動設(shè)備的CPU面積更小,因此會導(dǎo)致緩存級數(shù)更少,大小也更小,例如一般的臺式機三級緩存可能有8-16M,而移動設(shè)備則只有2M左右。
虛擬內(nèi)存
虛擬內(nèi)存是利用磁盤空間虛擬出的一塊邏輯內(nèi)存,用作虛擬內(nèi)存的磁盤空間被稱為交換空間(Swap Space)。
內(nèi)存交換
操作系統(tǒng)在使用內(nèi)存不夠的情況下,會嘗試把一些不用的內(nèi)存(Dead Memory)交換到硬盤上,從而節(jié)省出更多的物理內(nèi)存。這個操作我們稱之為內(nèi)存交換,它會占用大量的硬盤空間。
然而移動設(shè)備不做該操作,因為移動設(shè)備的IO速度很慢,而且移動設(shè)備的可存儲物(例如sd卡,內(nèi)存芯片等)的可擦寫次數(shù)也比硬盤少很多,會影響使用壽命。
內(nèi)存壓縮
在IOS中(Android沒有)會將不活躍的內(nèi)存壓縮起來存儲到一個特定空間里,來節(jié)省出物理內(nèi)存空間,來給活躍的app使用,這個操作稱之為內(nèi)存壓縮。(可以查看XCode的Virtual Memory)
內(nèi)存尋址范圍
內(nèi)存尋址范圍也稱尋址空間,指的是CPU對于內(nèi)存尋址的能力(最大能查找多大范圍的地址)。數(shù)據(jù)在內(nèi)存中存放是有規(guī)律的,CPU在運算的時候需要把數(shù)據(jù)提取出來就需要知道數(shù)據(jù)在那里,這時候就需要挨家挨戶的找,這就叫做尋址,但如果地址太多超出了CPU的能力范圍,CPU就無法找到數(shù)據(jù)了。
內(nèi)存尋址范圍和Memory Controller(內(nèi)存控制器)有關(guān),和運算位數(shù)(32位或64位)無直接關(guān)系。當(dāng)然一般情況下,64位的CPU尋址范圍更大。
Android內(nèi)存管理
基本單位Page
Android是基于Linux操作系統(tǒng),其內(nèi)存基本單位稱為:Page,默認(rèn)4K為一個page。因此內(nèi)存回收和分配的時候一般已4k進(jìn)行處理,但是并不意味著所有的數(shù)據(jù)都是4k對齊的。
用戶態(tài)和內(nèi)核態(tài)
Android內(nèi)存分用戶態(tài)和內(nèi)核態(tài):
用戶態(tài):只能受限的訪問內(nèi),所有app都是運行在用戶態(tài)上的。
內(nèi)核態(tài):cpu可以訪問內(nèi)存的所有數(shù)據(jù)。
內(nèi)核態(tài)的內(nèi)存,用戶態(tài)是嚴(yán)格不許訪問的,例如一些Error Access,可能是指針飄到內(nèi)核態(tài)上了。
內(nèi)存殺手
Android有一個內(nèi)存管理工具:Low Memory Killer,當(dāng)內(nèi)存不足時,會清理內(nèi)存,在Android上常見的一些后臺app消失,一些手機服務(wù)消失,手機重啟或者是app崩潰閃退等都和它有關(guān)。
Android應(yīng)用分層
首先我們來了解下Android的應(yīng)用分層,這也是殺手的追殺路線(會從最底層往上殺)

若此時我們的手機內(nèi)存不足,殺手會一層層的從下往上殺,直到內(nèi)存足夠為止。同時每殺一層都會造成一定的現(xiàn)象,例如:
- Cached或Previous被殺,會導(dǎo)致再次使用之前應(yīng)用的時候,應(yīng)用重啟。
- Home被殺導(dǎo)致桌面圖標(biāo)重建,或者壁紙不見了。
- Perceptible被殺會導(dǎo)致音樂停止等。
- Foreground被殺導(dǎo)致當(dāng)前應(yīng)用閃退。
- System被殺,就會導(dǎo)致手機重啟。
- Native屬于系統(tǒng)本身,因此是無法殺到的。
因此通過這些現(xiàn)象,我們就可以了解自己的app到底對內(nèi)存的使用到了一個什么程度。例如使用自己app時,再返回上個app時導(dǎo)致上個app重啟,說明殺手已經(jīng)殺到了Previous層。
內(nèi)存指標(biāo)
首先我們要了解在計算app使用了多少內(nèi)存時,系統(tǒng)需要統(tǒng)計共享頁面(shared pages)。App在訪問同一個service或者library的時候會共享內(nèi)存頁面。比如,Google地圖和一個游戲app可能會共享一個定位服務(wù)。
常見的內(nèi)存指標(biāo)有如下三個
一般來說內(nèi)存占用大小有如下規(guī)律:RSS >= PSS >= USS
注:可能你的USS很低,但是由于調(diào)用了Google Play Services,導(dǎo)致PSS很高。
我們可以通過procrank指令來查看各種內(nèi)存指標(biāo)
可以幫助我們分析應(yīng)用內(nèi)存使用,一般我們要做USS的優(yōu)化,以及避免在PSS上造成更大的壓力。
Unity內(nèi)存管理
Unity是一個C++引擎
Unity是一個C++引擎,并不是C#引擎,底層代碼全部是由c++寫的,除了一些Editor里面的Services可能會用到NodeJS這些網(wǎng)絡(luò)的語言,Runtime里面用到的每一行Unity底層代碼全是C++的。
Unity實際上分為三層:
- 最底層是我們的Runtime,全是Native C++代碼。
- 最上層是我們的C#,Unity自己有一些C#,例如Unity的Editor是用C#寫的,還有些Package也是C#寫的。
- 中間還有一層我們叫Binding,可以看見很多的.bindings.cs文件(基于C#的binding語言,一開始是Unity自定義的一種語言),這些文件的作用就是把C++和C#聯(lián)系在一起,為我的C#層提供所有的API。
因此我們平時使用Unity時看見的C# API,都是在Binding層中自定義的。這些文件底層運行的時候還是C++,只是個Wrapper(封裝)。
最早我們的用戶代碼是運行在C#上,是MonoRuntime。但是現(xiàn)在可以通過IL2CPP將其轉(zhuǎn)成C++代碼,所有現(xiàn)在幾乎沒有純正的C#在運行了。
Unity的VM(虛擬機:Virtual Machine)依舊還是存在,主要用于跨平臺,有了一層VM抽象后,跨平臺的工作會容易很多,IL2CPP本身也是個VM。
內(nèi)存管理簡介
Unity內(nèi)存按照分配方式分為:Native Memory(原生內(nèi)存)和Managed Memory(托管內(nèi)存)。Native Memory并不會被系統(tǒng)自動管理,需要我們手動去釋放。而Managed Memory的內(nèi)存管理是自動的,會通過GC來釋放。
此外Unity在Editor和Runtime下,內(nèi)存的管理方式是不同的,除了內(nèi)存大小不同,內(nèi)存的分配時機以及分配方式也可能不同。
例如Asset,在Runtime時,只有我們Load的時候才會進(jìn)內(nèi)存。而Editor模式下,只要打開Unity就會進(jìn)內(nèi)存(所以打開很慢)。因此后續(xù)有推出Asset Pipeline 2.0,它會一開始導(dǎo)入一些基本的Asset,剩下的Asset只有你使用的時候才會導(dǎo)入。
Unity按照內(nèi)存管理方式分為:引擎管理內(nèi)存和用戶管理內(nèi)存。引擎管理內(nèi)存即引擎運行的時候自己要分配一些內(nèi)存,例如很多的Manager和Singleton,這些內(nèi)存開發(fā)者一般是碰觸不到的。用戶管理內(nèi)存也就是我們開發(fā)者開發(fā)時使用到的內(nèi)存,需要我們重點注意。
Untiy檢測不到的內(nèi)存
即Unity Profilter無法檢查到的內(nèi)存,例如用戶分配的Native內(nèi)存。比如自己寫的Native插件(C++插件)導(dǎo)入Unity,這部分Unity是檢測不到的,因為Unity沒法分析已編譯的C++是如何分配和使用內(nèi)存的。還有就是Lua,它完全自己管理的,Unity也沒法統(tǒng)計到它內(nèi)部的情況。
Native Memory介紹
Allocator與Memory Lable
Unity在里面重載了C++的所有分配內(nèi)存的操作符,例如alloc,new等。每個操作符在被使用的時候要求有一個額外的參數(shù)就是Memory Lable,Profilter中查看Memory Detailed里的Name很多就是Memory Label。它指的就是當(dāng)前的這一塊內(nèi)存內(nèi)存要分配到哪個類型池里。
GetRuntimeMemory
Unity在底層會用Allocator,使用重載過的分配符分配內(nèi)存的時候,會根據(jù)Memory Lable分配到不同的Allocator池里面。每個Allocator池,單獨做自己的跟蹤。當(dāng)我們要在Runtime去Get一個Memory Lable下面池的時候,可以從對應(yīng)的Allocator中取,可以從中知道有什么東西,有多少兆。
NewAsRoot
前面提到的Allocator的生成是使用NewAsRoot,生成一個所謂的Memory Island,它下面會有很多的子內(nèi)存。例如一個Shader,當(dāng)我們加載一個shader進(jìn)內(nèi)存的時候,首先會生成一個shader的Root,也就是Memory Island。然后Shader底下的數(shù)據(jù),例如Subshader,Pass,Properties等,會作為該Root底下的成員,依次的分配。所以我們最后統(tǒng)計Runtime的內(nèi)存時,統(tǒng)計這些Root即可。
會及時返還給系統(tǒng)
因為是C++的,所以當(dāng)我們?nèi)elete或free一個內(nèi)存的時候,會立刻返回給系統(tǒng)。這和托管內(nèi)存堆不一樣,需要GC后才返回。
Managed Memory介紹
- VM 內(nèi)存池
- mono 虛擬機的內(nèi)存池
- VM 會返還內(nèi)存給 OS 嗎?
- 會
- 返還條件是什么?
- GC 不會把內(nèi)存返還給系統(tǒng)
- 內(nèi)存也是以 Block 來管理的。當(dāng)一個 Block 連續(xù)六次 GC 沒有被訪問到,這塊內(nèi)存才會被返還到系統(tǒng)。(mono runtime 基本看不到,IL2cpp runtime 可能會看到多一點)
- 不會頻繁地分配內(nèi)存,而是一次分配一大塊。
- GC 機制(BOEHM Non-generational 不分代的)
-
GC 機制考量
-
BOEHM
-
Non-generational(不分代的)
- 分代是指:大塊內(nèi)存、小內(nèi)存、超小內(nèi)存是分在不同內(nèi)存區(qū)域來進(jìn)行管理的。還有長久內(nèi)存,當(dāng)有一個內(nèi)存很久沒動的時候會移到長久內(nèi)存區(qū)域中,從而省出內(nèi)存給更頻繁分配的內(nèi)存。
-
Non-compacting(非壓縮式)
- 當(dāng)有內(nèi)存被回收的時候,壓縮內(nèi)存會把上圖空的地方重新排布。
- 但 Unity 的 BOEHM 不會!它是非壓縮式的??罩涂罩?,下次要用了再填進(jìn)去。
- 歷史原因:Unity 和 Mono 合作上,Mono 并不是一直開源免費的,因此 Unity 選擇不升級 Mono,與實際 Mono 版本有差距。
- 下一代 GC
-
Incremental GC(漸進(jìn)式GC)
- 現(xiàn)在如果我們要進(jìn)行一次 GC,主線程被迫要停下來,遍歷所有 GC Memory “island”(沒聽清),來決定哪些 GC 可以回收。
- Incremental GC 把暫停主線程的事分幀做了。一點一點分析,主線程不會有峰值??傮w GC 時間不變,但會改善 GC 對主線程的卡頓影響。
- SGen 或者升級 Boehm?
- SGen 是分代的,能避免內(nèi)存碎片化問題,調(diào)動策略,速度較快
- IL2CPP
- 現(xiàn)在 IL2CPP 的 GC 機制是 Unity 自己重新寫的,是升級版的 Boehm
-
Incremental GC(漸進(jìn)式GC)
-
Non-generational(不分代的)
-
Memory fragmentation 內(nèi)存碎片化
- 為什么內(nèi)存下降了,但總體內(nèi)存池還是上升了?
- 因為內(nèi)存太大了,內(nèi)存池沒地方放它,雖然有很多內(nèi)存可用。(內(nèi)存已被嚴(yán)重碎片化)
- 當(dāng)開發(fā)者大量加載小內(nèi)存,使用釋放*N,例如配置表、巨大數(shù)組,GC 會漲一大截。
- 建議先操作大內(nèi)存,再操作小內(nèi)存,以保證內(nèi)存以最大效率被重復(fù)利用。
- 為什么內(nèi)存下降了,但總體內(nèi)存池還是上升了?
-
Zombie Memory(僵尸內(nèi)存)
- 內(nèi)存泄露說法是不對的,內(nèi)存只是沒有任何人能夠管理到,但實際上內(nèi)存沒有被泄露,一直在內(nèi)存池中,被 zombie 掉了,這種叫 Zombie 內(nèi)存。
- 無用內(nèi)容
- Coding 時候或者團(tuán)隊配合的時候有問題,加載了一個東西進(jìn)來,結(jié)果從頭到尾只用了一次。
- 有些開發(fā)者寫了隊列調(diào)度策略,但是策略寫的不好,導(dǎo)致一些他覺得會被釋放的東西,沒有被釋放掉。
- 找是否有活躍度實際上并不高的內(nèi)存。
- 沒有釋放
- 通過代碼管理和性能工具分析
-
GC 機制考量
優(yōu)化 Managed Memory
Destroy與null
用Destroy,別用null,顯示的調(diào)用Destroy才能真正的銷毀掉。Class和Struct
根據(jù)具體使用情況選擇Class或Struct。減少裝箱拆箱操作
例如LINQ和常量表達(dá)式以裝箱的方式實現(xiàn),String.Format()也常常會產(chǎn)生裝箱操作等。對象池
雖然VM自己有內(nèi)存池,但是我們還是需要自己使用內(nèi)存池來管理。
在游戲程序中,創(chuàng)建和銷毀對象事很常見的操作,通常會通過 Instantiate 和 Destroy 方法來實現(xiàn),如果頻繁的進(jìn)行這些操作,GC的時候會導(dǎo)致負(fù)載很重,因為會有大量的已摧毀對象的存在,不僅會造成CPU的負(fù)載峰值,還可能導(dǎo)致堆積碎片化。因此我們可以使用對象池來處理這類問題。
使用對象池時需要注意,要決定對象池的大小,以及一開始要產(chǎn)生多少數(shù)量的對象在池中。因為如果你需要的對象數(shù)量多過池中現(xiàn)有的,就必須將對象池變大,擴的太大可能造成浪費,擴的小可能又造成頻繁的添加。閉包和匿名函數(shù)
所有的匿名函數(shù)和閉包在c#編IL代碼時都會被new成一個Class(匿名class),所以在里面所有函數(shù),變量以及new的東西,都是要占內(nèi)存的。協(xié)程
協(xié)程屬于閉包和匿名函數(shù)的特例,游戲開始啟動一個協(xié)程直到游戲結(jié)束才釋放,錯誤的做法。因為協(xié)程只要沒被釋放,里面的所有變量,即使是局部變量(包括值類型),也都會在內(nèi)存里。建議用的時候才生產(chǎn)一個協(xié)程,不用的時候就丟掉。配置表
一個游戲,策劃往往會通過excel配置很多的配置表,然后我會在游戲中加載這些excel來讀取其中的數(shù)據(jù)。但是如果excel數(shù)量非常的龐大,我們最好不要一下子全丟到內(nèi)存里,建議分關(guān)加載等。單例
慎用單例,且不要什么都往里放,因為里面的變量會一直占用內(nèi)存。Scriptable Objects
假設(shè)我們有一個控制敵人的組件,名叫Enemy,代碼如下
public class Enemy : MonoBehaviour
{
public float maxSpeed;
public float attackRadius;
}
//這個組件掛載在每個敵人身上,但是其中這兩個浮點數(shù)(maxSpeed 和 attachRadius)的數(shù)值都是不變的。那么當(dāng)場景中存在很多的敵人時,每次生成敵人的時候,這些數(shù)據(jù)就會重復(fù)一份。
//所以即使所有數(shù)據(jù)都一樣,這兩個浮點數(shù)還是重復(fù)的出現(xiàn)在有此腳本的對象上。所以建議改用Scriptable Objects,這樣就只會耗費一組這樣數(shù)據(jù)的內(nèi)存,代碼如下:
public class EnemyConfiguration : ScriptableObject
{
public float maxSpeed;
public float attackRadius;
}
public class Enemy : MonoBehaviour
{
public EnemyConfiguration enemyConfiguration;
}
- 變量or屬性
通常我們?yōu)榱朔庋b安全性,開發(fā)時會選擇使用屬性(getter/setter),而屬性本質(zhì)上是函數(shù)的調(diào)用,前面提到調(diào)用函數(shù)時,會在堆棧上分配內(nèi)存,因此調(diào)用屬性也是如此。當(dāng)調(diào)用多次時,花費在堆棧中的時間就會增加。當(dāng)然了,一般來說問題不大,但是如果在使用頻繁的循環(huán)體中使用屬性,可能就需要針對性的優(yōu)化。
我們可以通過宏命令進(jìn)行處理,例如在開發(fā)時使用屬性,發(fā)布版本時使用變量,如下:
#if DELELOPMENT_BUILD
int m_health;
public int health { get => m_health; }
#else
public int health;
#endif
- 緩存一些Hash值
在我們想要在運行時修改動畫或者材質(zhì)的時候,可以使用下面方法來實現(xiàn)
animator.SetTrigger("Idle");
material.SetColor("Color", Color.white);
//這類方法往往也可以通過索引來作為參數(shù),使用字符串只是能顯示的更加直觀,但是當(dāng)我們傳遞字符串時,程序內(nèi)部會進(jìn)行一些處理,頻繁調(diào)用的話可能就會造成性能的消耗。因此我們可以先找到對應(yīng)的索引,并將其緩存起來,供后續(xù)使用,如下:
int idleHash = Animator.StringToHash("Idle");
animator.SetTrigger(idleHash);
int colorId = Shader.PropertyToID("Color");
material.SetColor(colorId, Color.white);
- 緩存引用對象
例如我們常常會在游戲運行的時候去查找一些對象,GameObject.Find與其他所有關(guān)聯(lián)的方法,需要遍歷所有內(nèi)存中的游戲?qū)ο笠约敖M件,因此在復(fù)雜場景中,效率會很低。GameObject.GetComponent,會查詢所有附加到GameObject上的組件,組件越多,GetComponent的成本就越高。若使用的是GetComponentInChildren,隨著查詢變復(fù)雜,成本會更高。
因此不要多次查詢相同的對象或組件,而且查詢一次后將其緩存起來,方便后續(xù)的使用。
堆棧(Stack)和堆積(Heap)
我們看下Unity內(nèi)存中重要的兩部分,堆棧和堆積,因為只有了解了它們,我們才能知道應(yīng)該如何優(yōu)化內(nèi)存,提高性能。
堆棧:
堆棧是內(nèi)存中存儲函數(shù)和值類型的地方。
例如我們調(diào)用一個函數(shù)A,會將這個函數(shù)體與函數(shù)收到的參數(shù)放入到堆棧中,若在函數(shù)A中調(diào)用函數(shù)B,同樣會把函數(shù)B存放到堆棧中。當(dāng)函數(shù)B運行結(jié)束,會將其從堆棧中移除,然后當(dāng)A運行結(jié)束,把A從堆棧中移除。
因此我們在看Debug信息的時候,就會發(fā)現(xiàn)Log里面能夠做到一層層的方法回溯,方便我們查看整體的調(diào)用過程,這也就是堆?;厮?/strong>。
由于是堆棧的結(jié)構(gòu),因此不會遇到碎片化或是垃圾收集(GC)的問題。但是可能會碰見堆棧溢出的問題,比如調(diào)用了太多的函數(shù)導(dǎo)致一直push東西進(jìn)堆棧,占據(jù)越來越多的內(nèi)存空間,導(dǎo)致堆棧溢出。
堆積:
堆積是內(nèi)存中另一個區(qū)域,要比堆棧大,我們將所有的引用類型存放在這。通常我們每創(chuàng)建一個新的對象,會在堆積中找到下一個足夠存放的空位置,將其存儲。但是當(dāng)我們銷毀對象后,內(nèi)存空間不會馬上釋放出來,而是標(biāo)記成未使用,之后垃圾收集器會釋放這部分空間。
對象實例化和摧毀的過程其實很慢,所以我們要盡可能地避免在堆積中配置內(nèi)存的行為。如果我們需要的內(nèi)存比之前已經(jīng)配置好的還多,在放不下的情況下,堆積會膨脹,并且每次都增長兩倍,且不會再縮回去,過大的堆積就會影響到我們游戲的性能。當(dāng)我們在堆積中釋放了一些占用空間小的對象,而后添加一些占用空間大的對象時,由于前面釋放的空間不足以存放下,就會導(dǎo)致這些空間空出來,使得內(nèi)存的使用情況就變得斷斷續(xù)續(xù)起來,這也就是內(nèi)存的碎片化,同樣降低我們的游戲性能。
而我們前面所提到的GC就是在堆積上進(jìn)行的,每一次GC,都會遍歷堆積上所有的對象,找到需要釋放的東西,也就是沒有被引用的對象,然后將其釋放。但是有時候我們的一些錯誤引用,導(dǎo)致一些我們希望釋放掉的對象沒有被GC掉,那么就會造成內(nèi)存泄漏。
假如游戲玩到一半,GC必須要釋放數(shù)十或數(shù)百個游戲?qū)ο蟮膬?nèi)存,那么這會對你的游戲過程造成一個負(fù)載峰值,我們要避免這樣的負(fù)載峰值。
優(yōu)化 Native Memory
Scene
導(dǎo)致Native Memory增長的原因,最常見的就是Scene。因為是c++引擎,所有的實體最終都會反映在c++上,而不會反映在托管堆上。所以當(dāng)我們構(gòu)建一個GameObject的時候,實際上在Unity的底層會構(gòu)建一個或多個object來存儲這一個GameObject的信息(Component信息等)。所以當(dāng)一個Scene里面有過多的GameObject存在的時候,Native Memory就會顯著的上升,甚至可能導(dǎo)致內(nèi)存溢出。
注:當(dāng)我們發(fā)現(xiàn)Native Memory大量上升時,可以先著重檢查我們的Scene。
Audio
DSP Buffer:DSP Buffer,是指一個聲音的緩沖,當(dāng)一個聲音要播放的時候,需要向CPU去發(fā)送指令。如果聲音的數(shù)據(jù)量非常的小,會造成頻繁的向CPU發(fā)指令,造成IO壓力。在Unity的FMOD聲音引擎里面,一般會有一個Buffer,當(dāng)Buffer填充滿了才會去向CPU發(fā)送一次播放聲音的指令。
DSP Buffer大小的設(shè)置一般會導(dǎo)致兩種問題:
- 設(shè)置的值過大會導(dǎo)致聲音的延遲,因為填充滿需要很多的聲音數(shù)據(jù),當(dāng)我們聲音數(shù)據(jù)不大的時候,就會產(chǎn)生延時。
-
設(shè)置的值太小會導(dǎo)致CPU負(fù)擔(dān)上升,因為會頻繁的發(fā)送。
Force To Mono:這個選項作用是強制單聲道,很多聲音為了追求質(zhì)量會設(shè)置成雙聲道,導(dǎo)致聲音在包體和內(nèi)存中,占用的空間加倍,但是95%以上的聲音,兩個聲道是完全一樣的數(shù)據(jù)。因此對聲音不是很敏感的項目建議勾選此項,來降低內(nèi)存的占用。

Compression Format:不同的平臺有不同的聲音格式的支持,IOS對MP3有硬件支持,Android暫時沒有硬件支持。建議IOS適合使用ADPCM和MP3格式,Android適合使用Vorbis格式。

Load Type:決定聲音在內(nèi)存中的存在形態(tài):
| Decompress On Load | 當(dāng)audio clip被加載時,解壓聲音數(shù)據(jù) | 適用于小型音頻文件(< 200kb) |
| Compressed In Memory | 聲音數(shù)據(jù)將以壓縮的形式保存在內(nèi)存當(dāng)中 | 適用于中型音頻文件(>= 200kb) |
| Streaming | 從磁盤讀取聲音數(shù)據(jù) | 適用于大型音頻文件,例如背景音 |
注:例如Decompress On Load,要求文件必須小于200kb,因為內(nèi)部內(nèi)存管理的問題,如果是大于200kb的文件,那么也還是只會被分配到不足200kb的內(nèi)存。

Bitrate:我們可以對音頻文件本身進(jìn)行壓縮,降低文件的比特率(bitrate),前提音頻品質(zhì)不會被破壞太嚴(yán)重。

靜音處理相關(guān):一般游戲中都會有靜音的設(shè)置,我們往往我們只是把AudioSource或Mixer的音量設(shè)置為0,這樣還是會造成不必要的內(nèi)存和CPU占用,因為關(guān)音量并不會釋放音頻的內(nèi)存。因此建議在內(nèi)存中卸載音頻相關(guān)的來源或是內(nèi)存中的音頻文件,將AudioSource組件Disable,同時有個上層管理系統(tǒng)負(fù)責(zé)過濾和音頻相關(guān)的API調(diào)用。當(dāng)然卸載和重新載入音頻的成本也很高,要是玩家頻繁的開啟和關(guān)閉靜音的話,就不適用了,當(dāng)然了一般情況下玩家不會這么操作。
Code Size
代碼也是占內(nèi)存的,需要加載進(jìn)內(nèi)存執(zhí)行。模板泛型的濫用,會影響到Code Size以及打包速度(IL2CPP編譯速度,單一一個cpp文件編譯的話沒辦法并行的)。例如一個模板函數(shù)有四五個不同的泛型參數(shù)(float,int,double等),最后展開一個cpp文件可能會很大。因為實際上c++編譯的時候我們用的所有的Class,所有的Template最終都會被展開成靜態(tài)類型。因此當(dāng)模板函數(shù)有很多排列組合時,最后編譯會得到所有的排列組合代碼,導(dǎo)致文件很大??梢詤⒖?Memory Management in Unity
AssetBundle
TypeTree:Unity前后有很多的版本,不同的版本中很多的類型可能會有數(shù)據(jù)結(jié)構(gòu)的改變,為了做數(shù)據(jù)結(jié)構(gòu)的兼容,會在生成數(shù)據(jù)類型序列化的時候,順便生成一個叫TypeTree的東西。就是當(dāng)前這個版本用到了哪些變量,它們對應(yīng)的數(shù)據(jù)類型是什么,當(dāng)進(jìn)行反序列化的時候,根據(jù)TypeTree去做反序列化。如果上一個版本的類型在這個版本沒有,那TypeTree里就沒有它,所以不會去碰到它。如果有新的的TypeTree,但是在當(dāng)前版本不存在的話,那要用它的默認(rèn)值來序列化。從而保證了在不同版本之間不會序列化出錯。
在Build AssetBundle的時候,有開關(guān)可以關(guān)掉TypeTree。
BuildAssetBundleOptions.DisableWriteTypeTree
當(dāng)我們當(dāng)前AssetBundle的使用,和Build它的Unity的版本是一模一樣的時候,就可以關(guān)閉。這樣,一可以減少內(nèi)存,二AssetBundle包大小會減少,三build和運行時會變快,因為不會去序列化和反序列化TypeTree。
壓縮方式(Lz4和Lzma):現(xiàn)在Unity主推Lz4(也就是ChunkBased,BuildAssetBundleOptions.ChunkBasedCompression),Lz4非???,大概是Lzma的十倍左右,但是平均壓縮比例會比Lzma差30%左右,即包體可能會更大些。Lz4的算法開源。
Lzma基本可以不用了,因為Lzma解壓和讀取速度都會非常慢,并且占大量的內(nèi)存,因為不是ChunkBased,而是Stream,也就是一次全解壓出來。而ChunkBased可以一塊一塊解壓,每次解壓可以重用之前的內(nèi)存,減少內(nèi)存的峰值。
大小和數(shù)量:AssetBundle分兩部分,一部分是頭(用于索引),一部分是實際的打包的數(shù)據(jù)部分。如果每個Asset都打成一個AssetBundle,那么可能頭的部分比數(shù)據(jù)還大。
官方建議一個AssetBundle,在1-2M,但是現(xiàn)在進(jìn)入5g時代的話,可以適當(dāng)加大,因為網(wǎng)絡(luò)帶寬更大了。
Resource
Resource文件夾里的內(nèi)容被打進(jìn)包的時候會做一個紅黑樹(R-B Tree)用做索引,即檢索資源到底在什么位置。所以Resource越大,紅黑樹越大,它不可卸載,并在剛剛加載游戲的時候就會被一直加在內(nèi)存里,極大的拖慢游戲的啟動時間,因為紅黑樹沒有分析和加載完,游戲是不會啟動的,并造成持續(xù)的內(nèi)存壓力。所以建議不要使用Resource,使用AssetBundle。
Texture
例如下圖中使用的都是相同的貼圖,但是最終所占的磁盤大小卻差了很多,就是因為一些設(shè)置導(dǎo)致的。

Upload Buffer:在Unity 的 Quality 里設(shè)置如圖,和聲音的Buffer類似,填滿后向GPU push 一次。
Read/Write:沒必要的話就關(guān)閉,正常情況,Texture讀進(jìn)內(nèi)存解析完了擱到Upload Buffer里之后,內(nèi)存里那部分就會delete掉。除非開了Read/Write,那就不會delete了,會在顯存和內(nèi)存里各一份。前面說過手機內(nèi)存顯存通用的,所以內(nèi)存里會有兩份。

Mip Maps:例如UI元素這類相對于相機Z軸的值不會有任何變化的紋理,關(guān)閉該選項。

Format:選擇合適的Format,可減少占用的空間。

alpha:對于不透明紋理,關(guān)閉其alpha通道。

Max Size:根據(jù)平臺不同,紋理的Max Size設(shè)成該平臺最小值。
POT:紋理的大小盡量為2的冪次方(POT),因為有些壓縮格式可能不支持非2的冪次方的。
合并:盡量將多張紋理合并成為大圖。
壓縮:
Android設(shè)備運行平臺要求支持OpenGL ES 3.0的使用ETC2,RGB壓縮為RGB Compressed ETC2 4bits,RGBA壓縮為RGBA Compressed ETC2 8bits。需要兼容OpenGL ES 2.0的使用ETC,RGB壓縮為RGB Compressed ETC 4bits,RGBA壓縮為RGBA 16bits。(壓縮大小不能接受的情況下,壓縮為2張RGB Compressed ETC 4bits)
IOS設(shè)備運行平臺要求支持OpenGL ES 3.0的使用ASTC,RGB壓縮為RGB CompressedASTC 6x6 block,RGBA壓縮為RGBA Compressed ASTC 4x4 block。對于法線貼圖的壓縮精度較高可以選擇RGB CompressedASTC 5x5 block。需要兼容OpenGLES 2.0的使用PVRTC,RGB壓縮為PVRTC 4bits,RGBA壓縮為RGBA 16bits。(壓縮大小不能接受的情況下,壓縮為2張RGB Compressed PVRTC 4bits)

Mesh
Read/Write:同Texture,若開啟,Unity會存儲兩份Mesh,導(dǎo)致運行時的內(nèi)存用量變成兩倍。
Compression:Mesh Compression是使用壓縮算法,將Mesh數(shù)據(jù)進(jìn)行壓縮,結(jié)果是會減少占用硬盤的空間,但是在Runtime的時候會被解壓為原始精度的數(shù)據(jù),因此內(nèi)存占用并不會減少。
需要注意的是有些版本開了,實際解壓之后內(nèi)存占用大小會更嚴(yán)重。

Rig:如果沒有使用動畫,請關(guān)閉Rig,例如房子,石頭這些。

Blendshapes:如果沒有用到Blendshapes,也關(guān)閉。

Material設(shè)置:如果Material沒有用到法向量和切線信息,關(guān)閉可以減少額外信息。
Assets

圖像(Graphics)的一些優(yōu)化建議
基本上當(dāng)Unity渲染游戲圖像時,會調(diào)用 draw call 來對GPU下指令,讓場景能成功渲染。對象,材質(zhì)和紋理越多,處理起來需要的時間也越多。所以過多的drawcall就會影響游戲的優(yōu)化,這對于瓶頸在GPU上的游戲影響特別大,也就是我們的游戲已經(jīng)給GPU太大的壓力了。
使用批處理:
我們可以使用批處理來盡量減少drawcall,使用批處理需要滿足一些情況,例如,要批處理的對象必須引用一樣的材質(zhì),并使用相同的紋理(紋理合并在這就很重要),但是使用的模型可以不一樣。
動態(tài)批處理:可以減少對于移動對象的drawcall。只能用于少于900個頂點信息的情況,包含坐標(biāo)、法線、uv0、uv1、切線。動態(tài)批處理每幀評估一次,由CPU負(fù)責(zé)。
靜態(tài)批處理:即對開啟 **static **標(biāo)記的對象做批處理,在構(gòu)建期完成。適用于絕大部分的靜態(tài)Mesh,因此任何不會動的對象都應(yīng)標(biāo)記為靜態(tài)的。如果我們在運行時要添加靜態(tài)對象,可以看一下 StaticBatchUtility.Combine() 的API
Cast Shadows
默認(rèn)情況下,MeshRenderder組件的Cast Shadows是開啟的。
陰影的渲染可以讓游戲的光線增加真實度和深度感,但是某些情況下可能并不需要。在復(fù)雜場景中,可能會造成多余的陰影計算,陰影效果最后也看不見。
因此若場景有的對象是否有陰影對整體效果沒有影響的話,就關(guān)閉這個選項。不計算陰影可以省下CPU時間。(具體渲染步驟可以在 Frame Debugger的Shadows.Draw中查看)
Light Culling Mask
在復(fù)雜場景中,許多光線緊靠彼此,你可能覺得光線不能影響特定對象。根據(jù)渲染流程的設(shè)置,場景中越多的光照,性能可能就會越差。因此我們要確保光照只影響特定的對象層(例如專門給角色打光的光源,設(shè)置成只影響角色),尤其是多光源和多對象彼此緊靠的時候。
避免使用手機原生分辨率
現(xiàn)在的手機分辨率非常的高,在手機呈現(xiàn)高分辨率可能會影響性能和手機過熱的問題。因為會有大量的計算需求,如后期處理。如果游戲本身很耗GPU,高分辨率會惡化這些問題。建議使用 Screen.SetResolution 來降低游戲預(yù)設(shè)的解析設(shè)置(根據(jù)不同的設(shè)備來找到一些合適的值),來提高性能。
UI的一些優(yōu)化建議
顯示與隱藏
UI的隱藏我們可以使用將其移到Canvas外的方法,而不是利用SetActive(false)的方法來隱藏。
UI的批處理
如果UI元素會改變數(shù)值或是位置,會影響批處理,導(dǎo)致向GPU發(fā)送更多的drawcall。因此建議:
- 將更新頻率不同的UI放在不同的Canvas上。
- 相同Canvas中的UI元素的Z值要相同,這樣才不會打斷批處理。
- 相同Canvas中的UI元素要使用相同的材質(zhì)和紋理,材質(zhì)或著色器可以有動態(tài)變換(例如一些特效),這不會影響批處理。
- 相同Canvas中的UI元素要使用相同裁剪矩陣。
Graphic Raycaster

該組件是用來處理輸入事件,默認(rèn)掛載在每個Canvas上。有時不能互動的對象仍是canvas中的一部分,并附帶了該組件,所以當(dāng)每次鼠標(biāo)或觸控點擊時,系統(tǒng)就要遍歷所有可能接受輸入事件的UI元素,就會造成多次的 “點落在矩形中” 的檢查,來判斷對象是否該作出反應(yīng)。在UI很復(fù)雜的情況下,這個運算成本就會很高。因此建議確保只有可互動的Canvas才有該組件,節(jié)省CPU運行時間。
全屏UI的處理
游戲中可能會有些全屏UI(例如一些設(shè)置界面),會遮擋住場景物體或其他UI元素。然而它們即使被遮擋看不見,CPU和GPU還是會有消耗,因此建議:
- 3D場景完全被遮擋的話,關(guān)閉渲染3D場景的攝像機。
- 被遮蔽的UI,Disable這些Canvas,注意不是SetActive(false)。
- 盡可能的降低幀率,因為這些UI一般不需要刷新那么頻繁。
其他一些優(yōu)化
GameObject的層次結(jié)構(gòu)
某些情況下,場景中的物體可能有很深的嵌套結(jié)構(gòu),當(dāng)我們對父節(jié)點的GameObject進(jìn)行坐標(biāo)轉(zhuǎn)換時,就會產(chǎn)生OnTransformChanged事件,這消息會傳遞給該GameObject下所有子對象,即使這些對象沒有任何渲染組件(也就是我們看不見任何變化),造成一些不必要的轉(zhuǎn)換運算,包括平移,旋轉(zhuǎn)和縮放。
此外,較深的結(jié)構(gòu)也會導(dǎo)致在GC時,花費更多的時間在層級結(jié)構(gòu)間遍歷。
避免在Awake和Start中添加大量的邏輯
這對游戲啟動很重要,Unity會在Awake和Start方法執(zhí)行后渲染第一個畫面,某些情況可能會導(dǎo)致啟動畫面或是載入畫面需要花更長的時間渲染,因為你必須等每個游戲?qū)ο蠖纪瓿葾wake和Start的執(zhí)行。同時若游戲啟動時,黑屏太久,提包時可能會被退審。
刪除空的Unity事件
Monobehaviour中的Start,Update這些方法即使是空的,也會帶來些微的性能消耗,因此若為空,就刪除它們。
Accelerometer Frequency
這個設(shè)置在Project Settings->Player->IOS->Other Settings中,這個功能定義Unity從設(shè)備讀取加速度儀信息的頻率,在不需要加速儀的游戲中,將它啟動或設(shè)置了高于需求的頻率,會影響性能表現(xiàn)。因為讀取硬件設(shè)備信息,會增加CPU的處理時間。
移動物體
Unity中有許多移動游戲?qū)ο蟮姆椒ǎ?transform.Translate,如果對象需要碰撞判定,我們則會添加剛體和碰撞體,如果還是使用 transform.Translate 方法,會造成PhysX物理引擎整體重新計算,對于復(fù)雜的場景,成本可能很高。因此若要移動帶有剛體的對象,使用rigidBody.MovePosition,并且要在FixedUpdate方法中執(zhí)行。
建議使用transform.Translate就在Update中執(zhí)行,使用rigidBody.MovePosition或AddForce方法在FixedUpdate中執(zhí)行。
添加組件
在運行時調(diào)用AddComponent其實很沒效率,尤其在一幀中多次啟用這類調(diào)用。
當(dāng)我們添加一個組件的時候,Unity會做下列操作:
- 先看組件有沒有DisallowMultipleComponent的設(shè)置,如果有,就要去檢查是否有同類型的組件已加入
- 然后檢查RequireComponent設(shè)置是否存在,如果設(shè)置了,就代表這個組件需要別的組件同步加入(重復(fù)做添加組件的操作)
- 最后調(diào)用所有被加入的MonoBehaviour的Awake方法
上述這些步驟都發(fā)生在堆積上,所以可能會影響性能和增加GC的處理時間。
數(shù)據(jù)結(jié)構(gòu)
也就是Array,List和Dictionary等,例如在Array或List中使用索引的成本很低,那么就適合要經(jīng)常通過索引讀取的情況。而要頻繁增加和移除對象時,使用Dictionary是最合適的。




