減少垃圾的產(chǎn)生量
可以使用一些技術(shù)來幫助我們減少代碼中生成的垃圾量
1.緩存
如果我們的代碼重復(fù)調(diào)用產(chǎn)生堆分配的函數(shù)包晰,然后丟棄結(jié)果憨琳,這將產(chǎn)生不必要的垃圾锰蓬。 對(duì)此邻储,我們應(yīng)該存儲(chǔ)對(duì)這些對(duì)象的引用并復(fù)用它們赋咽。 這種技術(shù)被稱為緩存。
下面的函數(shù)每次調(diào)用都會(huì)引起堆分配吨娜,因?yàn)槊看握{(diào)用都會(huì)生成一個(gè)新的數(shù)組脓匿。
void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType<Renderer>();
ExampleFunction(allRenderers);
}
下面的代碼只會(huì)有一次堆分配,因?yàn)閿?shù)組創(chuàng)建賦值后被緩存起來了宦赠。緩存的數(shù)組可以復(fù)用因而不會(huì)產(chǎn)生垃圾陪毡。
private Renderer[] allRenderers;
void Start()
{
allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
2.不要在頻繁調(diào)用的函數(shù)中分配
如果我們需要在MonoBehaviour中分配堆內(nèi)存米母,在頻繁調(diào)用的函數(shù)里分配是最糟糕的。比如 每幀調(diào)用的函數(shù)Update()和LateUpdate()毡琉,在這些地方分配铁瞒,垃圾將非常快的累積桅滋。我們應(yīng)該盡可能在Start() 或 Awake() 里緩存這些對(duì)象的引用慧耍,或者確保分配內(nèi)存的代碼只在需要的時(shí)候被運(yùn)行。
讓我們來看個(gè)簡(jiǎn)單的例子,下面的代碼在每次 Update()調(diào)用時(shí)都會(huì)調(diào)用一個(gè)引起堆分配的函數(shù),會(huì)非呈幔快的產(chǎn)生垃圾
void Update()
{
ExampleGarbageGeneratingFunction(transform.position.x);
}
簡(jiǎn)單修改后,可以確保產(chǎn)生堆分配的函數(shù)只在transform.position.x 的值改變時(shí)才被調(diào)用.這樣只在需要的時(shí)候產(chǎn)生堆分配而不會(huì)每幀都產(chǎn)生.
private float previousTransformPositionX;
void Update()
{
float transformPositionX = transform.position.x;
if (transformPositionX != previousTransformPositionX)
{
ExampleGarbageGeneratingFunction(transformPositionX);
previousTransformPositionX = transformPositionX;
}
}
另一個(gè)在 Update()函數(shù)中減少垃圾內(nèi)存產(chǎn)生量的方法是使用計(jì)時(shí)器.這適用于那些會(huì)產(chǎn)生垃圾內(nèi)存的代碼需要被頻繁調(diào)用又不需要每幀調(diào)用的地方
下面的示例代碼,產(chǎn)生垃圾內(nèi)存的函數(shù)每幀被調(diào)用
void Update()
{
ExampleGarbageGeneratingFunction();
}
下面的代碼,使用一個(gè)計(jì)時(shí)器來保證產(chǎn)生垃圾內(nèi)存的函數(shù)每秒只被調(diào)一次
private float timeSinceLastCalled;
private float delay = 1f;
void Update()
{
timeSinceLastCalled += Time.deltaTime;
if (timeSinceLastCalled > delay)
{
ExampleGarbageGeneratingFunction();
timeSinceLastCalled = 0f;
}
}
像這樣對(duì)頻繁調(diào)用函數(shù)的小改動(dòng),可以顯著的減少垃圾內(nèi)存的產(chǎn)生量
3.清空容器
創(chuàng)建容器類會(huì)引起堆分配,如果在代碼中發(fā)現(xiàn)多次創(chuàng)建同一個(gè)容器變量,則應(yīng)該緩存該容器引用并在重復(fù)創(chuàng)建的地方使用 Clear()操作來替代
下面的示例中每次 *new *操作都會(huì)產(chǎn)生一次堆分配
void Update()
{
List myList = new List();
PopulateList(myList);
}
下面的示例中,只在容器被創(chuàng)建或者擴(kuò)容時(shí)才會(huì)有堆分配,顯著減少了垃圾內(nèi)存的產(chǎn)生量
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
4.對(duì)象池
即使減少了腳本中的堆分配,在運(yùn)行時(shí)大量對(duì)象的創(chuàng)建和銷毀依然會(huì)引起GC問題. 對(duì)象池是一種通過重用對(duì)象而不是重復(fù)創(chuàng)建和銷毀對(duì)象來減少分配和釋放的技術(shù).對(duì)象池在游戲中廣泛使用,最適合于頻繁產(chǎn)生和銷毀類似對(duì)象的情況;,例如,當(dāng)槍射擊子彈時(shí).
引起不必要堆分配的常見原因
我們知道局部的,值類型的變量被分配在棧上,其他的都在堆上分配.但是很多情況下的堆分配可能讓人驚訝.我們來看看一些不必要的堆分配的常見原因,并考慮如何最好地減少這些蜂绎。
1.字符串
在C#中,字符串是引用類型,而不是值類型,盡管它們似乎保持字符串的“值”. 這意味著創(chuàng)建和丟棄字符串會(huì)產(chǎn)生垃圾.由于字符串常用在很多代碼中,所以這些垃圾可能累積笋鄙。
C#中的字符串也是不可變的,這意味著它們的值在第一次創(chuàng)建之后不能再被更改怪瓶。 每次我們操縱一個(gè)字符串(例如萧落,通過使用+運(yùn)算符來連接兩個(gè)字符串),Unity將創(chuàng)建一個(gè)包含更新值的新字符串洗贰,并丟棄舊字符串找岖。 這會(huì)產(chǎn)生垃圾。
我們可以遵循一些簡(jiǎn)單的規(guī)則敛滋,將字符串產(chǎn)生的垃圾減至最少许布。 我們來看看這些規(guī)則,然后看一下應(yīng)用它們的例子绎晃。
- 減少不必要的字符串創(chuàng)建蜜唾。 如果多次使用相同的字符串值,應(yīng)該創(chuàng)建一次該字符串并緩存該值庶艾。
- 減少不必要的字符串操作袁余。 例如,如果有一個(gè)經(jīng)常更新的Text組件咱揍,并且包含一個(gè)連接的字符串颖榜,可以考慮將它分成兩個(gè)Text組件。
- 如果必須在運(yùn)行時(shí)構(gòu)建字符串煤裙,應(yīng)該使用StringBuilder類掩完。 StringBuilder類用于創(chuàng)建沒有堆分配的字符串,并且在連接復(fù)雜字符串時(shí)減少生成的垃圾量硼砰。
- 當(dāng)不在需要調(diào)試時(shí),立即刪除對(duì)Debug.Log()的調(diào)用且蓬。即使沒有輸出任何內(nèi)容,對(duì)Debug.Log()的調(diào)用依然會(huì)被執(zhí)行夺刑。調(diào)用Debug.Log()創(chuàng)建和處理至少一個(gè)字符串缅疟,所以如果我們的游戲包含許多這些調(diào)用分别,垃圾會(huì)累積
來看一個(gè)低效使用字符串而產(chǎn)生不必要垃圾的代碼的例子。 在下面的代碼中存淫,在Update()中創(chuàng)建一個(gè)連接“TIME:”與浮點(diǎn)計(jì)時(shí)器的值的字符串來顯示分?jǐn)?shù)耘斩,這產(chǎn)生了不必要的垃圾。
public Text timerText;
private float timer;
void Update()
{
timer += Time.deltaTime;
timerText.text = "TIME:" + timer.ToString();
}
下面我們做些改進(jìn)桅咆。 我們把單詞“TIME:”放在一個(gè)單獨(dú)的文本組件中括授,并在Start()中設(shè)置它的值。 這樣在Update()中岩饼,我們不再需要連接字符串荚虚。 可以大大減少垃圾的產(chǎn)生。
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
timerHeaderText.text = "TIME:";
}
void Update()
{
timerValueText.text = timer.toString();
}
2.unity函數(shù)調(diào)用
重要的是要注意籍茧,每當(dāng)我們調(diào)用不是自己寫的代碼時(shí)版述,無論是在Unity中還是在插件中,都可能會(huì)產(chǎn)生垃圾寞冯。 調(diào)用一些Unity函數(shù)會(huì)產(chǎn)生堆分配渴析,因此應(yīng)謹(jǐn)慎使用以避免產(chǎn)生不必要的垃圾。
并沒有一個(gè)應(yīng)該避免使用的函數(shù)列表吮龄。 每個(gè)函數(shù)在某些情況下都是有用的俭茧,而在其他情況下則不太有用。所以最好仔細(xì)分析我們的游戲漓帚,確定垃圾的產(chǎn)生位置并仔細(xì)思考如何處理母债。 在某些情況下,可以緩存函數(shù)的結(jié)果; 在某些情況下尝抖,可以降低調(diào)用函數(shù)的頻率; 在其他情況下毡们,最好重構(gòu)代碼以使用不同的函數(shù)。 話雖如此牵署,我們來看幾個(gè)常見的會(huì)導(dǎo)致堆分配 的Unity函數(shù)漏隐,并考慮如何更好地處理它們。
每次訪問返回值為數(shù)組的Unity函數(shù)時(shí)奴迅,都會(huì)創(chuàng)建一個(gè)新的數(shù)組青责,并將其作為返回值傳遞給我們。 這種行為并不總是顯而易見的或可預(yù)期的取具,特別是當(dāng)函數(shù)是訪問器的時(shí)候(例如 Mesh.normals)脖隶。
下面的代碼中,每次循環(huán)迭代都會(huì)生成一個(gè)新的數(shù)組
void ExampleFunction()
{
for (int i = 0; i < myMesh.normals.Length; i++)
{
Vector3 normal = myMesh.normals[i];
}
}
這種情況下很容易減少分配:我們可以簡(jiǎn)單地緩存對(duì)數(shù)組的引用暇检。 這樣可以只創(chuàng)建一個(gè)數(shù)組产阱,并相應(yīng)地減少了產(chǎn)生的垃圾量。
下面的代碼演示了這一點(diǎn)块仆。 在這種情況下构蹬,我們?cè)谘h(huán)之前調(diào)用Mesh.normals并緩存引用王暗,這樣就只創(chuàng)建一個(gè)數(shù)組。
void ExampleFunction()
{
Vector3[] meshNormals = myMesh.normals;
for (int i = 0; i < meshNormals.Length; i++)
{
Vector3 normal = meshNormals[i];
}
}
訪問GameObject.name或GameObject.tag也會(huì)有堆分配庄敛。 這兩個(gè)都是返回新字符串的訪問器俗壹,這意味著調(diào)用這些函數(shù)會(huì)產(chǎn)生垃圾。 緩存該值可能是有用的藻烤,但在這種情況下绷雏,可以使用相關(guān)的Unity函數(shù)。 要檢查一個(gè)GameObject的標(biāo)簽的值而不產(chǎn)生垃圾怖亭,我們可以使用 GameObject.CompareTag()涎显。
下面的示例代碼中,訪問GameObject.tag會(huì)產(chǎn)生垃圾內(nèi)存:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.tag == playerTag;
}
如果使用 GameObject.CompareTag()兴猩,則該函數(shù)不會(huì)產(chǎn)生垃圾:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.CompareTag(playerTag);
}
GameObject.CompareTag并不是唯一的期吓,很多Unity的函數(shù)都有無堆分配的替代版本。比如可以使用Input.GetTouch() 和 Input.touchCount 替換Input.touches峭跳, 或者使用Physics.SphereCastNonAlloc() 替換 Physics.SphereCastAll()膘婶。
3.裝箱
裝箱是指當(dāng)一個(gè)值類型變量被用作一個(gè)引用類型變量時(shí)所執(zhí)行的操作。當(dāng)我們將值類型的變量(如int或float)傳遞給具有object類型參數(shù)的函數(shù)時(shí)蛀醉,通常會(huì)發(fā)生裝箱,如Object.Equals()函數(shù)衅码。
例如拯刁,函數(shù)String.Format()接受一個(gè)string和一個(gè)object參數(shù)。 當(dāng)我們傳遞一個(gè)string和一個(gè)int時(shí)逝段,int就會(huì)被裝箱垛玻。 下面的代碼包含了一個(gè)裝箱的例子:
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format("Price: {0} gold", cost);
}
裝箱會(huì)產(chǎn)生垃圾源于其后臺(tái)操作。當(dāng)一個(gè)值類型變量被裝箱時(shí)奶躯,Unity在堆上創(chuàng)建一個(gè)臨時(shí)的System.Object來包裝值類型變量帚桩。 一個(gè)System.Object是一個(gè)引用類型的變量,所以當(dāng)這個(gè)臨時(shí)對(duì)象被處理掉時(shí)會(huì)產(chǎn)生垃圾嘹黔。
裝箱是不必要的堆分配的常見原因账嚎。 即使我們不在我們的代碼中直接裝箱變量,我們可能也會(huì)使用導(dǎo)致裝箱的插件儡蔓,裝箱也可能發(fā)生在其他函數(shù)的后臺(tái)郭蕉。 最好的做法是盡可能避免裝箱,并刪除導(dǎo)致裝箱的任何函數(shù)調(diào)用喂江。
4.協(xié)程
調(diào)用StartCoroutine()會(huì)產(chǎn)生少量的垃圾召锈,因?yàn)閁nity必須創(chuàng)建一些管理協(xié)程的實(shí)例的類。 所以获询,當(dāng)游戲在交互時(shí)或在性能熱點(diǎn)時(shí)應(yīng)該限制對(duì)StartCoroutine()的調(diào)用涨岁。 為了減少這種方式產(chǎn)生的垃圾拐袜,必須在性能熱點(diǎn)運(yùn)行的協(xié)程應(yīng)該提前啟動(dòng),當(dāng)使用可能包含對(duì)StartCoroutine()的延遲調(diào)用的嵌套協(xié)程時(shí)梢薪,我們應(yīng)特別小心蹬铺。
協(xié)程中的yield語句不會(huì)自己產(chǎn)生堆分配; 然而,我們傳遞給yield語句的值可能會(huì)產(chǎn)生不必要的堆分配沮尿。 例如丛塌,以下代碼會(huì)產(chǎn)生垃圾:
yield return 0;
該代碼產(chǎn)生垃圾,因?yàn)閕nt變量0被裝箱畜疾。 在這種情況下赴邻,如果我們希望只是等待一個(gè)幀而不會(huì)導(dǎo)致任何堆分配,那么最好的方法是使用以下代碼:
yield return null;
協(xié)程的另一個(gè)常見錯(cuò)誤是在多次使用相同的值時(shí)使用了new操作啡捶, 例如姥敛,以下代碼將在循環(huán)迭代時(shí)每次都重復(fù)創(chuàng)建和銷毀一個(gè)WaitForSeconds對(duì)象:
while (!isComplete)
{
yield return new WaitForSeconds(1f);
}
如果緩存和復(fù)用WaitForSeconds對(duì)象,就能減少垃圾的產(chǎn)生量瞎暑,請(qǐng)看以下示例代碼:
WaitForSeconds delay = new WaitForSeconds(1f);
while (!isComplete)
{
yield return delay;
}
如果我們的代碼由于協(xié)程而產(chǎn)生大量垃圾彤敛,我們可能考慮使用除協(xié)程之外的其他東西來重構(gòu)我們的代碼。 重構(gòu)代碼是一個(gè)復(fù)雜的問題了赌,每個(gè)項(xiàng)目都是獨(dú)一無二的墨榄,但是有一些常用的手段或許對(duì)協(xié)程問題有幫助。 例如勿她,如果我們主要使用協(xié)同程序來管理時(shí)間袄秩,我們可以簡(jiǎn)單地在一個(gè)Update()函數(shù)中記錄時(shí)間。 如果我們主要使用協(xié)同程序來控制游戲中發(fā)生的事情的順序逢并,我們可以創(chuàng)建某種消息系統(tǒng)來允許對(duì)象進(jìn)行通信之剧。 一個(gè)方法不能解決所有問題,但是有必要記住砍聊,在代碼中可以有多種方法來實(shí)現(xiàn)相同的事情背稼。
5.foreach循環(huán)
在Unity5.5之前的版本中,使用foreach遍歷數(shù)組之外的所有集合玻蝌,在循環(huán)終止時(shí)都會(huì)產(chǎn)生垃圾蟹肘,這是因?yàn)槠浜笈_(tái)的裝箱操作。當(dāng)循環(huán)開始并且循環(huán)終止時(shí)灶伊,一個(gè)System.Object對(duì)象被分配在堆上疆前。 Unity 5.5中已修復(fù)此問題。
在5.5之前的Unity版本中聘萨,以下代碼中的循環(huán)會(huì)生成垃圾:
void ExampleFunction(List listOfInts)
{
foreach (int currentInt in listOfInts)
{
DoSomething(currentInt);
}
}
如果我們無法升級(jí)我們的Unity版本竹椒,則有一個(gè)簡(jiǎn)單的解決方案來解決這個(gè)問題。 for和while循環(huán)不會(huì)在后臺(tái)引起裝箱米辐,因此不會(huì)產(chǎn)生任何垃圾胸完。 當(dāng)?shù)皇菙?shù)組的集合時(shí)书释,我們應(yīng)該優(yōu)先使用它們。
下面的代碼不會(huì)產(chǎn)生垃圾:
void ExampleFunction(List listOfInts)
{
for (int i = 0; i < listOfInts.Count; i ++)
{
int currentInt = listOfInts[i];
DoSomething(currentInt);
}
}
6.函數(shù)引用
函數(shù)引用赊窥,無論是引用匿名函數(shù)還是命名函數(shù)爆惧,都是Unity中的引用類型變量。 它們將導(dǎo)致堆分配锨能。 將匿名函數(shù)轉(zhuǎn)換為 閉包(匿名函數(shù)可在其創(chuàng)建時(shí)訪問范圍中的變量)顯著增加了內(nèi)存使用量和堆分配數(shù)量扯再。
函數(shù)引用和閉包如何分配內(nèi)存的精確細(xì)節(jié)因平臺(tái)和編譯器設(shè)置而異,但是如果GC是一個(gè)問題址遇,那么最好在游戲過程中盡量減少使用函數(shù)引用和閉包熄阻。 <u><u>這個(gè)Unity性能最佳實(shí)踐指南</u></u> 在這個(gè)主題上有更多的技術(shù)細(xì)節(jié)。
7.LINQ和正則表達(dá)式
LINQ和正則表達(dá)式由于在后臺(tái)會(huì)有裝箱操作而產(chǎn)生垃圾倔约。在有性能要求的時(shí)候最好不使用秃殉。 同樣,<u><u>這個(gè)Unity性能最佳實(shí)踐指南</u></u> 提供了有關(guān)此主題的更多技術(shù)細(xì)節(jié)浸剩。
8.構(gòu)建代碼以最小化GC的影響
代碼的構(gòu)建方式可能會(huì)影響GC钾军。即使代碼中沒有堆分配,也有可能增加GC的負(fù)擔(dān)绢要。
可能增加GC的負(fù)擔(dān)之一是要求它檢查它不應(yīng)該檢查的東西吏恭。Structs是值類型變量,但是如果有一個(gè)包含引用類型變量的struct重罪,那么垃圾收集器必須檢查整個(gè)結(jié)構(gòu)體砸泛。 如果有大量這樣的結(jié)構(gòu)體,那么垃圾回收器將增加大量額外的工作蛆封。
在這個(gè)例子中,下面的struct包含了一個(gè)引用類型的字符串勾栗。 現(xiàn)在在垃圾回收器運(yùn)行時(shí)必須檢查結(jié)構(gòu)體的整個(gè)數(shù)組惨篱。
public struct ItemData
{
public string name;
public int cost;
public Vector3 position;
}
private ItemData[] itemData;
在這個(gè)例子中,我們將數(shù)據(jù)存儲(chǔ)在單獨(dú)的數(shù)組中围俘。 當(dāng)垃圾收集器運(yùn)行時(shí)砸讳,它只需要檢查字符串?dāng)?shù)組,并且可以忽略其他數(shù)組界牡。 這減少了垃圾收集器的工作簿寂。
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
另一個(gè)可能增加GC負(fù)擔(dān)的操作是使用不必要的對(duì)象引用,當(dāng)垃圾收集器搜索對(duì)堆上對(duì)象的引用時(shí)宿亡,它必須檢查代碼中的每個(gè)當(dāng)前對(duì)象引用常遂。 更少的對(duì)象引用意味著更少的工作量,即使我們不減少堆上的對(duì)象總數(shù)挽荠。
在這個(gè)例子中克胳,我們有一個(gè)類填充一個(gè)對(duì)話框平绩。 當(dāng)用戶查看對(duì)話框時(shí),會(huì)顯示另一個(gè)對(duì)話框漠另。 我們的代碼包含對(duì)應(yīng)該顯示的DialogData的下一個(gè)實(shí)例的引用捏雌,這意味著垃圾回收器必須在其操作中檢查此引用:
public class DialogData
{
private DialogData nextDialog;
public DialogData GetNextDialog()
{
return nextDialog;
}
}
這里我們重構(gòu)下代碼,以便它返回一個(gè)用于查找下一個(gè)DialogData實(shí)例的標(biāo)識(shí)符笆搓,而不是實(shí)例本身性湿。 這不是一個(gè)對(duì)象引用,所以它不會(huì)增加垃圾收集器所花費(fèi)的時(shí)間满败。
public class DialogData
{
private int nextDialogID;
public int GetNextDialogID()
{
return nextDialogID;
}
}
這是個(gè)小例子肤频。 然而,如果我們的游戲中有許多包含對(duì)其他對(duì)象引用的對(duì)象葫录,那么我們可以通過以這種方式重構(gòu)代碼來大大降低堆的復(fù)雜性着裹。
定時(shí)GC
1.手動(dòng)強(qiáng)制GC
最后,我們可能希望自己觸發(fā)GC米同。 如果我們知道堆內(nèi)存已被分配但不再使用(例如骇扇,如果我們的代碼在加載資源時(shí)生成垃圾),并且我們知道垃圾收集凍結(jié)不會(huì)影響播放器(例如面粮,當(dāng)加載界面還顯示時(shí))少孝,我們可以使用以下代碼請(qǐng)求GC:
System.GC.Collect();
這將強(qiáng)制運(yùn)行GC,在我們方便的時(shí)候釋放未使用的內(nèi)存熬苍。