这是摘自Unity官方文档有关优化的部分,原文链接:https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity.html
总共分为如下系列:
- 采样分析
- 内存部分
- 协程
- Asset审查
- 理解托管堆 【推荐阅读】
5.1 上篇:原理,临时分配内存,集合和数组
5.2 下篇:闭包,装箱,数组 - 字符串和文本
- 资源目录
- 通用的优化方案
- 一些特殊的优化方案
理解托管堆下篇,上篇请参见
闭包和匿名方法
当使用闭包和匿名方法的时候需要注意两点:
首先,C#中所有方法都是引用类型,都会在堆中进行分配。当把方法引用作为参数进行传递的时候,就会产生临时分配的内存。不管是在匿名方法或者定义好的方法,只要传递方法类型的参数,就会分配内存。
其次,如果将匿名方法转换成闭包,将闭包传递给接收的方法,这样会消耗更多的内存。
考虑如下代码:
List<float> listOfNumbers = createListOfRandomNumbers();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/2))
);
其次,如果将匿名方法转换成闭包,将闭包传递给接收的方法,这样会消耗更多的内存。
List<float> listOfNumbers = createListOfRandomNumbers();
int desiredDivisor = getDesiredDivisor();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/desiredDivisor))
);
这样改动之后,匿名方法现在就要能够获取方法域之外的变量状态,这样就成了闭包。变量desiredDivisor需要传入到闭包之内才能被闭包中的代码使用。
为了达到这个目的,C#实现了一个匿名类,这样才能持有外部域的变量。当闭包传递给Sort方法的时候,这个匿名类的副本就会被创建,并且使用传入的desiredDivisor进行初始化操作。
因为执行闭包需要生成匿名类的副本,而且C#中的所有类都是引用类型,所以执行闭包需要在托管堆中分配额外的对象空间。
通常情况下,最好避免在C#中使用闭包。在对性能很敏感的代码里,匿名方法和方法引用应该最小化,尤其是需要每帧执行的基础性代码。
IL2CPP下的匿名方法
目前情况下,检查IL2CPP生成的代码可以发现对System.Function类型的声明和赋值会分配新对象。不论变量是显式声明(在方法中或者类中声明)或者隐式声明(作为参数传递给其他的方法)。
如此,IL2CPP脚本后端下使用匿名方法都会在托管堆中分配内存。在Mono下面则不会出现这种情况。
更进一步来讲,对于方法参数定义的类型不同,IL2CPP也会有不同等级的托管内存分配方案。比如闭包每次分配的内存非常耗费。
很不直观的是,即使方法提前定义好,在IL2CPP中作为参数传递的时候,分配的内存和闭包差不多。匿名方法会在堆上产生暂时的垃圾,按照大小排列。
因此,如果工程使用了IL2CPP,推荐如下三点:
- 尽量选择不使用将方法作为参数进行传递的代码风格
- 当不可避免的时候,尽量选择匿名方法而不是定义好的方法
- 避免闭包,不管是否使用了IL2CPP
装箱
Unity工程中最常见的临时内存分配在于装箱操作。当值类型对象需要作为引用类型对象被使用的的时候,装箱就不可避免,例如当把值类型变量(如int和float)作为参数传递给使用引用类型的方法的时候。
最简单的例子如下,当整型x传递给object.Equals方法的时候就需要被装箱,因为object的Equals方法要求传入的参数是object。
int x = 1;
object y = new object();
y.Equals(x);
C#的IDE和编译器通常不会对装箱操作发出警告,虽然装箱操作会产生没必要的内存分配。这是因为C#语言的开发者认为,对于分代式的垃圾回收器和对分配大小非常敏感的内存池而言,很小的临时分配没什么问题。
如何识别装箱
在CPU的日志中,装箱是对某些函数的调用,具体的函数则取决于使用的脚本后台。不过通常都是如下的形式,<some class>
是某些类或者结构的名称,...
是一些变量名称。
- <some class>::Box(...)
- Box(...)
- <some class>_Box(...)
也可以通过反编译代码或者IL查看工具定位到。ReSharper内置的IL查看器和dotPeek反编译工具都可以查看。IL指令是“box”。
字典和枚举
引起装箱操作一个很常见的原因是将枚举类型作为字典的key。声明枚举会创建值类型对象,其实就是创建了会在编译过程中会确保类型安全的整型数据。
默认情况下,调用Dictionary.add(key, value)会导致对Object.getHashCode(object)的调用,后者是为字典中的key创建合适的哈希值,以便在Dictionary.tryGetValue和Dictionary.remove这些方法中使用。
Object.getHashCode的参数是引用类型,而枚举变量则是值类型数据。所以如果将枚举变量作为字典的key值的话,调用的每次方法都会至少产生一次装箱操作。
下面的代码片段展示了装箱问题的一个简单例子:
enum MyEnum { a, b, c };
var myDictionary = new Dictionary<MyEnum, object>();
myDictionary.Add(MyEnum.a, new object());
如果想要解决这个问题,很有必要写一个类实现IEqualityComparer接口的方法,并且将这个类的实例作为字典的比较方法。
【注意。这个对象通常没有主权,所以可以在多个字典实例中被反复使用来节省内存。】
下面的代码片段是针对上面的例子实现IEqualityComparer的改进版本:
public class MyEnumComparer : IEqualityComparer<MyEnum> {
public bool Equals(MyEnum x, MyEnum y) {
return x == y;
}
public int GetHashCode(MyEnum x) {
return (int)x;
}
}
上面类的某个实例可以作为比较器传入到字典的构造方法中。
foreach循环
在Unity中Mono C#的编译器,在处理foreach循环的时候在每次循环结束的时候,都会强制Unity对一个值类型对象进行装箱操作。【注意,只是在循环结束执行的时候才会执行装箱操作,而不是每次循环都会产生装箱操作。所以无论循环执行2次或者200次,消耗内存都是一样的】。这是因为Unity的C#编译器构建了一个值类型的枚举器用来对值集合进行迭代。
枚举器实现了IDisposable接口,当结束循环的时候一定会被调用。然而对于值类型的对象调用这个接口方法一定要求先对值类型(如结构或者枚举变量)进行装箱才行。
考虑如下的代码:
int accum = 0;
foreach(int x in myList) {
accum += x;
}
上面的代码在Unity的C#编译器编译之后的IL代码如下:
.method private hidebysig instance void
ILForeach() cil managed
{
.maxstack 8
.locals init (
[0] int32 num,
[1] int32 current,
[2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_2
)
// [67 5 - 67 16]
IL_0000: ldc.i4.0
IL_0001: stloc.0 // num
// [68 5 - 68 74]
IL_0002: ldarg.0 // this
IL_0003: ldfld class [mscorlib]System.Collections.Generic.List`1<int32> test::myList
IL_0008: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0/*int32*/> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
IL_000d: stloc.2 // V_2
.try
{
IL_000e: br IL_001f
// [72 9 - 72 41]
IL_0013: ldloca.s V_2
IL_0015: call instance !0/*int32*/ valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
IL_001a: stloc.1 // current
// [73 9 - 73 23]
IL_001b: ldloc.0 // num
IL_001c: ldloc.1 // current
IL_001d: add
IL_001e: stloc.0 // num
// [70 7 - 70 36]
IL_001f: ldloca.s V_2
IL_0021: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
IL_0026: brtrue IL_0013
IL_002b: leave IL_003c
} // end of .try
finally
{
IL_0030: ldloc.2 // V_2
IL_0031: box valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
IL_0036: callvirt instance void [mscorlib]System.IDisposable::Dispose()
IL_003b: endfinally
} // end of finally
IL_003c: ret
} // end of method test::ILForeach
} // end of class test
关键部分的代码在于finally {...} 部分。callvirt指令找到IDisposable.Dispose方法的内存位置,在调用这个方法之前,进行了一次装箱box操作。
通常来讲,foreach方法应该尽可能避免在Unity中使用。不仅是因为装箱操作,还有通过枚举器对集合类进行迭代相比for或者while方法更慢。
注意Unity5.5版本之后的C#编译器做了绝大的提升,对IL代码的生成有了很大的优化。额外的装箱操作已经被移除了,减少了foreach循环的内存开销。但是CPU的方法调用消耗并没有得到改善。
基于数组(Array)的Unity API
另外一个对性能有害但是很少被发现的问题是反复调用返回数组的Unity API导致的内存分配。每次当这些API被调用的时候,都会创建新的数组。在不必要的时候,尽量减少对返回数组类型的Unity API的调用。
下面的代码在每次迭代的时候都会创建四个vertices的副本。当.vertices的属性被访问的时候,都会发生内存分配。
for(int i = 0; i < mesh.vertices.Length; i++)
{
float x, y, z;
x = mesh.vertices[i].x;
y = mesh.vertices[i].y;
z = mesh.vertices[i].z;
// ...
DoSomething(x, y, z);
}
将对Mesh中的vertices属性的访问移动到循环之外进行访问,就可以减少内存分配:
var vertices = mesh.vertices;
for(int i = 0; i < vertices.Length; i++)
{
float x, y, z;
x = vertices[i].x;
y = vertices[i].y;
z = vertices[i].z;
// ...
DoSomething(x, y, z);
}
尽管单次属性访问不会消耗很多CPU开销,但是循环中重复访问属性也会产生性能问题。而且,重复访问也会造成堆内存没必要的开销。
这个问题在移动设备上更常见,因为Input.touches API就是返回数组。在工程代码中经常会看到如下的代码片段,在循环内部每次都去获取.touches属性。
for ( int i = 0; i < Input.touches.Length; i++ )
{
Touch touch = Input.touches[i];
// …
}
同样道理,将对.touches的访问移动到循环体之外,性能能够得到改善。
Touch[] touches = Input.touches;
for ( int i = 0; i < touches.Length; i++ )
{
Touch touch = touches[i];
// …
}
现在更新的Unity提供了不会产生内存分配的API。
int touchCount = Input.touchCount;
for ( int i = 0; i < touchCount; i++ )
{
Touch touch = Input.GetTouch(i);
// …
}
上面的API的转化很容易完成:
上面的例子将.touchCount
的访问也移动到了循环体之外是为了减少调用get方法引起的CPU消耗。
空数组重用
有一些开发组喜欢用空数组代替null值,当需要返回一个空对象的时候。这种代码风格在许多托管语言很常见,尤其是C#和Java。
通常来讲,当方法需要返回一个空数组的时候,可以考虑提前定义好一个空数组的单个实例,这样就可以避免重复创建空数组。【如果这个数组被返回,不能进行改变,如果被改变,应该抛出异常】