1. 程式人生 > >2018騰訊移動遊戲實踐經驗——Unity的C#效能

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 優化