2018騰訊移動遊戲實踐經驗——Unity的C#效能
本文將對原文重新組織。對其中的示例程式碼做調整和適當重寫, 但保持要講述的原意不變。
零、客戶端效能評審內容:
條目 | 詳細 |
---|---|
1、記憶體消耗 | 針對不同檔次客戶端配置,實際佔用的實體記憶體消耗合理; |
2、客戶端幀率 | 在預設畫質配置下,核心遊戲從場景FPS無明顯波動,穩定再一定數值以上; |
3、CPU佔用 | 不同遊戲場景下,CPU佔用合理; |
4、流量消耗 | 對於分局的遊戲場景,以單局消耗流量作為判斷標準;對於不分局遊戲場景 或 流量與局時有關的場景,以一定時間內的流量消耗為判斷標準; |
5、APK大小 | 安裝包過大建議使用CDN資源; |
一、GC 與記憶體
0、簡介
效率包括程式碼的 GC大小、記憶體大小、執行速度(暫不討論)等。
測試環境:Unity 5.0.2f1 Personal、MonoDevelop 4.0.1。
先了解一下 中間語言(IL)。
1、Foreach真相
此節將在簡介中的測試環境中,對比測試for、while、foreach語句對相同List的遍歷,研究記憶體與GC問題。
結果:只有 foreach 有 GC 產生(這個是Mono的一個 bug)。
測試程式碼如下:
List<int > mData = new List<int>();
//foreach原始程式碼:
private void TestForeach()
{
foreach(var e in mData) { }
}
//反編譯後:
private void TestForeach()
{
using (List<int>.Enumerator enumerator = this.mData.GetEnumerator())
{
while (enumerator.MoveNext())
{
int current = enumerator.Current;
}
}
}
可見, foreach語句最終在編譯時被改變。並且, GetEnumerator() 返回值型別在 using 中發生了裝箱。
結論:
在目前專案中,foreach還是有GC的。(不管是List還是ArrayList都會有GC)。專案中,建議採用 for語句和while語句處理迴圈。尤其是在update或LateUpdate中(程式碼執行頻繁的地方)。
對於 Dictionary 遍歷,可以這樣(直接在while中迭代):
var enumerator = this.mDictionary.GetEnumerator();
while (enumerator.MoveNext())
{
var current = enumerator.Current;
var key = current.Key;
var value = current.Value;
}
2、字串拼接真相
此節將在簡介中的測試環境中,對比測試 string +方式 和 StringBuilder Append方式 的字串拼接,研究記憶體與GC問題。
結果:+ 號在幾次連線中沒有產生 GC(編譯器做了優化)。而在大量的連線過程中,產生大量的 GC。StringBuilder Append方式產生了GC(原因是分配了一個用於儲存字串結果的Buffer)。
對比C#原始程式碼和反編譯後的C#程式碼,如下:
//+號的幾次連線,原始程式碼:
string test = "abc" + "efg" + "h";
//反編譯後(可見被編譯器直接優化為一個字串):
string test = "abcefgh";
//+號的多次連線,原始程式碼(反編譯後,未見優化和改變):
string test = "abcefgh";
for (int i = 0; i < 300; i++)
{
test += "testStringBuilder_plus_testStringBuilder_plus_More";
}
//StringBuilder的多次連線,原始程式碼(反編譯後,未見優化和改變)。
StringBuilder testData = new StringBuilder();
for (int i = 0; i < 300; i++)
{
testData.Append("testStringBuilder_plus_testStringBuilder_plus_More");
}
string test = testData.ToString();
結論:
當字串連線次數只有幾次(10 以內)時,應該直接用 + 號連線,不產生 GC(編譯器優化),否則應使用StringBuilder Append方式進行連線。NRatel ps:此試驗不嚴謹,對該結論持懷疑態度。因為此試驗 對 +號的多次連線,放在了for迴圈中,有可能正是因為編譯器不便改變for迴圈結構,所以才不能對其做優化,而不是因為要連線的次數多。待自行研究…
3、Struct 與 Class 真相之一
參考文章: 《Effective C#》之減少裝箱和拆箱
此節將基於簡介中的測試環境,探討如何架構資料操作模組,才能充分利用值型別和引用型別,使其效率最高(即減少拆裝箱,從而減少堆記憶體的分配)。
對比結構體和類的例項化和引用屬性(通過改變列表中的值,檢視原值是否改變),如下:
using UnityEngine;
using System.Collections.Generic;
public class Test : MonoBehaviour
{
public struct PathStruct { public string path; }
public class PathClass { public string path; }
List<PathStruct> mPathStructList = new List<PathStruct>();
List<PathClass> mPathClassList = new List<PathClass>();
void Start()
{
//結構體例項化
PathStruct mPathStruct = new PathStruct(); //結構體存在於棧中,所以例項化時不會分配堆記憶體
//類例項化
PathClass mPathClass = new PathClass(); //類存在於堆中,所以例項化時會分配堆記憶體
//分別賦值並放入列表
mPathStruct.path = "123";
mPathClass.path = "123";
mPathStructList.Add(mPathStruct);
mPathClassList.Add(mPathClass);
//修改列表中的值
//mPathStructList[0].path = "456"; //無法這樣修改結構體的值,因為它是一個“值”,而不是變數。
PathStruct mps = mPathStructList[0];
mps.path = "456";
mPathClassList[0].path = "456";
//檢視原值
Debug.Log(mPathStruct.path); //結果為123,原值未變,可見放入列表的是一份拷貝。
Debug.Log(mPathClass.path); //結果為456,原值改變,可見放入列表的是原值的引用。
}
}
測試結論:
結構體是值型別,存在於棧中,例項化時不會分配堆記憶體,傳遞時傳遞的是原值的深拷貝。
類是引用型別,存在於堆中,例項化時會分配堆記憶體,因此產生GC,傳遞時傳遞的是原值的引用。
結構體和類的選擇:
棧中操作效率較高,但空間有限。堆中操作可以避免拷貝,節省空間。因此,
類,適用於較大的、邏輯較多的、表現抽象和多級別的、重量的物件。
結構體,適用於表示一些資料的、輕量的物件,適合處理大量短暫的物件。
4、Struct 與 Class 真相之二
此節將基於簡介中的測試環境,探討結構體在使用時,如何避免裝箱。
對比普通結構體和“繼承自介面的結構體”與ArrayList的結合。如下:
using UnityEngine;
using System.Collections.Generic;
public class Test : MonoBehaviour
{
//普通結構體
public struct NormalPathStruct{public string path;}
public interface IPathStruct{string path { get; set; } }
//繼承自介面的結構體
public struct InheritedIPathStruct : IPathStruct
{
private string _path;
public string path
{
get { return _path; }
set { _path = value; }
}
}
ArrayList PathStructList = new ArrayList();
// Use this for initialization
void Start()
{
NormalPathStruct ps1 = new NormalPathStruct(); //普通結構體物件
InheritedIPathStruct ps2 = new InheritedIPathStruct(); //使用結構體接收“繼承自介面的結構體”的物件
IPathStruct ps3 = new InheritedIPathStruct(); //使用介面接收“繼承自介面的結構體”的物件
//賦值
ps1.path = "123";
ps2.path = "123";
ps3.path = "123";
//結構體物件加入ArrayList
PathStructList.Add(ps1); //ps1是值型別,在加入ArrayList時,將發生裝箱
PathStructList.Add(ps2); //ps2是值型別,在加入ArrayList時,將發生裝箱
PathStructList.Add(ps3); //ps3是引用型別,在加入ArrayList時,不發生裝箱
//修改列表中的“繼承自介面的結構體”的物件的值
((IPathStruct)PathStructList[2]).path = "456";
//檢視原值
Debug.Log(ps3.path); //輸出 "456"。證明了 使用介面接收“繼承自介面的結構體”的物件時,該物件確實是引用型別。
}
}
結論:
結構體直接與ArrayList結合時,和其他值型別一樣,會發生裝箱,此時,可採用 “結構體繼承介面、並用介面接收物件” 的方式,使結構體物件變成引用型別的物件,避免裝箱。
繼承介面的結構體,當用結構體接收物件(作為物件的型別)時,屬於值型別;當用介面接收物件時,屬於引用型別。
NRatel ps:最好還是不要用ArrayList,而是直接用List
附,陣列(Array)、ArrayList、List的區別:
陣列,在記憶體中是連續儲存的,索引速度非常快,賦值與修改元素也很簡單,但動態擴充套件、無法增刪元素(必須在宣告時指定大小),不易移動元素(需要同時移動該操作位置後面的所有元素)。
ArrayList,解決了陣列的上述問題,使增刪查改都變得容易。但其結點型別是 Object,不是型別安全的,可能發生
裝箱(值型別 => Object)和拆箱(Object => 值型別 )操作,帶來很大的效能耗損。
List, 是ArrayList的泛型實現,在編譯期就決定了元素的型別,所以避免了ArrayList的拆裝箱問題。
更多C#的資料結構 ,可參考我的另一篇部落格 C#的集合類(資料結構)
5、Enum 真相
此節將基於簡介中的測試環境,研究Enum 使用不當情況下的 GC。
如下:
using UnityEngine;
using System.Collections.Generic;
public class Test : MonoBehaviour
{
public enum TimeOfDay
{
Moning = 0,
Afternoon = 1,
Evening = 2,
};
void Update()
{
TestDic();
TestStr();
}
//有GC
private void TestDic()
{
Dictionary<TimeOfDay, int> dicData = new Dictionary<TimeOfDay, int>();
dicData.Add(TimeOfDay.Evening, 1);
}
//有GC
private void TestStr()
{
string mm = TimeOfDay.Evening.ToString();
}
}
結論:
不要將列舉當作 Tkey 使用,此操作有裝箱發生,產生GC。原因待探究。
不要對列舉進行 .ToString()操作,此操作有裝箱發生,產生GC。原因待探究。
6、閉包真相
概念瞭解:委託和事件
**閉包概念:**定義在一個函式內部的函式,這個內部函式可訪問外層函式的區域性變數(一般叫做外部區域性變數或上下文環境)。內部函式將與它的外部區域性變數封閉地包在一起。閉包會維持它的外部區域性變數,即使該外層函式已執行完。
(NRatel:在C#中,函式內部無法像js、lua那樣直接宣告函式(C#7.0才開始支援),所以,閉包通常就是定義在函式中的委託型別的物件。“委託”是C#的一種型別,委託最終會被編譯為一個類,閉包的外部區域性變數會被編譯為類的成員變數。)
研究閉包產生的 GC,如下:
using UnityEngine;
using System;
public class Test : MonoBehaviour
{
void Update()
{
int x = 3;
//action 是一個閉包(在Update中被定義, 可訪問它外部的Update的區域性變數 x),
Action action = () =>
{
int y = 2;
int z = x + y;
};
action();
}
}
二、其他
1、U3D 物件MonoBehaviour
2、Component 相關優化
3、GameObject 相關優化
4、NGUI 優化