Unity3D代码优化技巧

其实我经常在工作场合里面听到有人跟我说:”这个代码能用就行了,能跑通就OK了“。其实我觉得这种想法对于个人长远发展来说是不对的,在我看来,好的程序员的素质除了会懂的给自己节省时间做更有意义的事情之外(其实就是偷懒),另外一个体现素质的地方,就是他对于代码的要求很高,体现一个程序员素质的地方不止于他懂的很多,同时也需要他写得一手好代码。
那么一个代码的好坏取决于什么点呢?在我看来应该是以下几个:

  • 需要花费的空间以及时间效率
  • 可扩展性
  • 可维护性
  • 可读性
  • 健壮性

这篇文章主要针对空间,时间效率针对讨论,而至于后面几点,为什么不在放这里讨论呢?可扩展性以及可维护性,是要根据每个项目的架构而言的,在对于目前Unity3D各个游戏的架构并不是统一的前提下,针对这一点的讨论不具备通用性。不过我认为,对于游戏来说,MVC架构是一个很好的模式。可读性这一点,我相信去阅读一些优秀的开源项目,对于提升代码可读性是有帮助的。而健壮性,需要体现在各种细节上,而这一点我也是个野路子,还需要学习,假如各位有更好的相关文章推荐,不妨分享出来,彼此学习。
那么怎么提升Unity3D开发中,代码的空间、时间效率呢?在讨论这个点之前,有额外的事情是值得我们讨论的,那就是Unity3D的GC机制。

Mono 2.6.5 GC

Unity3D使用的是2.6.5版本的Mono,这个版本的mono使用的是更老版本的GC——Boehm GC,在我看来Boehm GC在内存分配与管理上面主要存在2点不足:

  • Boehm GC无论是申请内存与释放内存空间,都会在一个帧数里面把事情做完,这样GC在申请内存或者释放内存的时候,我们能够感受到明显的掉帧现象,使得游戏的卡顿感明显,对于游戏的体验造成了很大的影响。
  • Boehm GC不是基于多线程的,每次Boehm GC在申请与释放空间的时候,都会在主线程上进行操作,这就导致了Boehm GC在申请与释放内存时会阻塞主线程,导致了游戏的卡顿感更加的明显。
  • Boehm GC查询垃圾的时间间隔相对来说比较长,这种机制导致了这个GC其实不适合适用于游戏开发上,因为游戏的高度实时性导致了游戏需要经常的申请内存与释放内存。

最新版本的Mono使用了SGen GC,SGen GC相对于Boehm GC有他的优点以及缺点,这里就不详细描述了,有兴趣的读者可以查看这里来了解两者间的差异。
所以从上述的描述我们可以知道,通过GC来进行内存管理的时候,申请与释放的份额大小会对游戏体验造成影响,申请与释放内存的份额越大,造成的掉帧现象越明显。所以我们需要尽量最优化的使用GC,争取在调用GC申请与释放内存的同时,在越小的内存空间里面实现我们的功能。那么接下来有一个新的问题,在Unity3D中,什么样的行为会导致执行GC的操作呢?

堆(Heap)与栈(Stack)

现代操作系统一般会把内存分为两个部分,一个是,一个是,栈由操作系统自动分配存放,而堆一般由程序员或者GC分配释放,若程序员或者GC不释放,则由操作系统在程序结束时回收。C#中分为值类型以及引用类型,其中值类型由栈进行管理,引用类型由堆进行管理.在C#中,以下的类型分划分为值类型:

  • 一些简单的算数类型,如int、float、bool等
  • Struct
  • Enumeration

以下的类型被划分为引用类型:

  • string、dynamic、object
  • class
  • interface
  • delegate

.Net中新线程会设置一个固定的内存大小给栈,通常会很小,例如在.Net的windows平台上,这个大小一般是1MB。这些内存主要用于加载线程的主函数,局部变量和接下来被当前线程调用需要加载以及卸载的函数(包括了他们的局部变量)。他们当中有一些获取会命中CPU的cache(高速缓存)从而获得访问速度的提升。只要你的调用深度并不是很深,或者你没有创建大量的局部变量,你是不用担心栈溢出的。
当你创建的对象过多,超过了栈的承载能力,或者对象的生存周期超过了函数的范围内,那么这些对象将会由堆接管。在cpp以及c语言中,在堆中创建的对象,是需要程序员自己手动创建以及手动删除的,一旦忘记了删除对象,则会造成内存泄露。为了避免这个问题,我们需要一个可以创建内存,追踪所有堆的对象,并且自动释放的内存管理器,而garbage collection (GC)就是用来解决这个问题的。上文提到,Unity3D使用的是2.6.5版本的mono的是Boehm GC,针对Boehm GC的弊端,官方提出了一些建议,其中每30帧调用一次System.GC.Collect()可以解决Boehm GC查询垃圾间隔时间过长的弊病。然而在游戏开发中,使用GC是无法避免的,接下来我们需要的是如何实现同等功能的效果下,尽量的少申请GC以达到同样的效果。

代码中,会额外申请GC的操作

在Unity3D工程下写代码时,不要使用foreach操作

1
2
3
4
5
/// <summary>
/// 迭代循环someList
/// </summary>
foreach (SomeType s in someList)
s.DoSomething();

在旧版本Mono,也就是当前Unity3D使用的Mono版本,上面这段代码跟下面的操作一样

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 迭代循环someList
/// </summary>
using (SomeType.Enumerator e = someList.GetEnumerator())
{
while (e.MoveNext())
{
SomeType s = (SomeType)e.Current;
s.DoSomething();
}
}

从上面我们可以看出,每个foreach循环创建了一个enumerator对象,enumerator的对象会创建在栈上还是堆上?假如开发者使用的是.Net,那么答案是两者都有可能!因为在System.Collections.Generic命名空间中的几乎所有容器,是可以通过他们自己实现的GetEnumerator接口自动返回struct,也就是在栈上分配,这些操作不会带来额外的GC,但是其他的则未必。可惜的是Unity3D中使用的Mono老版本存在一个BUG,当编译器判定GetEnumerator接口是应该返回struct还是class时,Mono在对strcut-enumerator执行的操作是boxing操作,这意味着GetEnumerator的时候,Mono创建了引用类型导致了GC的分配。
下面是我写的foreach与for的比较,其中test.Update中分别对同一个长度为1000的List进行遍历操作。
foreach和for操作的比较
综上所述,不要在Unity3D中使用foreach操作,用for替代。

谨慎使用闭包(closures)功能

C#中提供了匿名函数以及lambda表达式,这使得闭包的实现变得更加容易。
国内有不少blog有解析闭包的概念,有兴趣深入的读者可以看这里
然而闭包的使用可能会导致内存泄露。而是否泄露的标准取决于你的代码以及编辑器的实现,这意味着当你实现闭包的功能时,你必须小心注意自己的代码是否会导致内存泄露。

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 闭包测试
/// </summary>
int result = 0;
void Update()
{

for(int i = 0; i < 100; i++)
{
System.Func<int, int> func = (p) => p * p;
result += func(i);
}
}

在这个代码片段里面,for循环看似每个帧数创建了100次的托管函数func,然而在我的电脑上,只有在第一次运行Update的时候花费了104Bytes的GC,在接下来的帧循环里面再也没有申请额外的堆进行操作。这是因为delegator一旦分配了一次之后就被缓存了起来。接下来就不用再创建新的delegator,而直接使用被缓存的就可以了。
那么接下来我们做一些小小的改变。

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 闭包测试
/// </summary>
int result = 0;
void Update()
{

for(int i = 0; i < 100; )
{
System.Func<int, int> func = (p) => p * i++;
result += func(i);
}
}

关键在于:

1
System.Func<int, int> func = (p) => p * i++;

这里我把原来的”p”替换成了”i++”,在这里我们给闭包传递了一个变量i,在func中,”p”是一个局部变量,但是”i”不是。它的空间域属于Update()方法,编辑器为了让func可以访问、修改非局部变量(对于func来说),会创建一个新的func函数,而不是缓存起来(因为参数的环境发生了变化,每次执行func的参数环境都是不一样的)。这导致了一个很严重的问题,就是每次Update()执行,都会造成内存泄露
所以,在实现闭包的原则时候,我们一定要注意,因为你的操作很有可能会导致很严重的后果。

string相关的字符串拼接,格式化操作等

1
string test = "string first" + "string midle" + "string last";

上面这个操作进行了三个字符串拼接,但是在实际的运行过程中,底层额外分配了至少一个string对象用于做临时的计算结果存储。而string的分配是会产生额外的GC的,为了避免产生这种情况,我的建议是使用System.Text.StringBuilder,但是StringBuilder本身也是会产生GC的,所以怎么使用StringBuilder会根据情境的不同有所变化:

  • 如果你需要频繁的在项目里面进行字符串拼接运算,以及这些字符串拼接运算并不复杂,那么最好是分配一个全局的StringBuilder,一旦StringBuilder分配好之后,他的内存就会一直常驻在堆里面,直到GC认为他没有被引用回收垃圾为止。
  • 对于复杂的字符串拼接运算,那么可以分配一个局部的StringBuilder对象,虽然StringBuilder对象的创建会产生GC,但是这对比起用string有了极大的性价比,所以这是值得的。在这种情况下,我不推荐使用全局的StringBuilder对象做运算,因为一旦使用了全局对象,为了做这个复杂的字符串拼接需要使用的内存就会一直常驻在程序中,造成内存的浪费——我们应该在内存使用与运行效率中取得一个平衡点。

下面我们再来看一段代码

1
string formatTest = string.Format("{0} test for formation {1}", 1, 1.0f);

这里的Format操作等同于

1
public static string Format(string format, params Object[] args)

从这两段代码我们可以知道,这里的参数列表使用的是Object对象,而在进行Format操作的时候,我们的值类型(分别是1以及1.0f)被boxing成了Object对象,从上面的内容我们可以得知,boxing是会产生GC的,因为这是在堆上产生的内存分配。

Coroutines协程的使用

当你选择调用StartCoroutine开始运行协程的时候,会产生一个协程相关的句柄,这个句柄会根据不同的系统产生额外的GC开销,其中涵盖了Coroutine类对象,以及Enumerator对象。在协程的运行过程中(也就是执行yield的执行操作过程中)不会分配额外的开销。所以要在使用StartCoroutine的时候要防止出现内存泄露的情况,以及不要在短时间内调用大量的协程操作,这会导致频繁的GC分配。