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

猜你喜欢

转载自blog.csdn.net/NRatel/article/details/83656160