Featured image of post 2015.06 Unity 项目实践点滴

2015.06 Unity 项目实践点滴

Views

[2015-06-29] 补:有同学问手机上代码横向显示不下的问题。可以向左滑动对应的代码块,就能看到右边的部分。不过的确不如 PC 上方便 :)


这两年,游戏行业的重心逐渐迁移到了手游,引擎开发工具大有被 Unity 席卷之势,其掀起的声浪可谓是横扫了当年曾在国内风光一时的 Unreal / Gamebryo / OGRE 等诸前辈。好了,废话不多说,今天我们简单地聊一下 Unity 项目中的一些具体的实践。

"Unity can only be manifested by the Binary. Unity itself and the idea of Unity are already two."
- Buddha

高精度计时器

在日常开发中,出于测试或优化的需要,我们常常需要使用比毫秒数更精确的计时器。因此在深入之前,我们先了解一下如何在 Unity 平台上获取足够的计时精度。


我们知道,在传统的 Win32 平台上,游戏开发者通常使用 QueryPerformanceFrequency 和 QueryPerformanceCounter 来计时,这两个函数依赖 rdtsc 指令来获取时钟周期数,能得到相对比较精确的计时结果。而在 .Net 平台上,System.Diagnostics.StopWatch 的功能与这两个函数类似,在 MSDN 文档中有提到:

The Stopwatch class assists the manipulation of timing-related performance counters within managed code. Specifically, the Frequency field and GetTimestamp method can be used in place of the unmanaged Win32 APIs QueryPerformanceFrequency and QueryPerformanceCounter.

也就是说 StopWatch 在可能的情况下,会使用与 QueryPerformanceFrequency/QueryPerformanceCounter 类似的机制来达到与其相当的精度。

这里同时还提到 StopWatch 的几个有趣的接口:

  • 可以通过 Stopwatch.IsHighResolution 来判断当前是否为高精度
  • 可以通过 StopWatch.Frequency 来获取当前的时钟频率
  • 可以通过 StopWatch.GetTimestamp() 来获取当前的时间戳

我们在 Unity 中简单验证一下这些接口在 Mono 2.x 上的可用性:

1
2
    UnityEngine.Debug.LogFormat("high-res: {0}, freq: {1}, timestamp: {2}", 
        Stopwatch.IsHighResolution, Stopwatch.Frequency, Stopwatch.GetTimestamp());

输出的结果是

1
    high-res: True, freq: 10000000, timestamp: 63121800066

这里可看出几个有趣的现象:

  1. 高精度计时是开启的,在 Mono 2.x 上是可用的
  2. 频率被归一化为 1e7 了,而非返回实际的 CPU 频率,这个的好处是 ElapsedTicks 从周期数变为了一个有逻辑意义的时间计量,这也就是在说,StopWatch 提供的计时服务__最高精度为 0.1 微秒(也即 100 纳秒)__
  3. 时间戳 GetTimestamp() 可用,而且返回的值是一个有效的 64 bits long (大于 2^32),也就是基本不用担心溢出回绕的问题

我在 StopWatch 上封装了一个简单的 Timer 类,用法如下:

1
2
3
4
5
    using (SSTimer t = new SSTimer("_name_tag_"))
    {
        // test code 
        foo();
    }

输出如下:

1
    '_name_tag_' exec time: 33.332 (ms)

这个工具 SSTimer 的代码在这里。后面的性能相关的对比数据,均由此计时器统计而来。


Mono/C# 代码实践

for / foreach 问题

这个问题之前有一些争论,不过这里 (“Should you avoid foreach loops?” 一节的最后的 [EDIT] 补充部分)这里 (见 @王建飞 和 @权然 的回答) 已经说得很清楚了。我自己也分别看了不同情况下 VS 和 Mono 编译出来的 il 代码,肯定了他们的观点,在这里简单归纳一下结论吧:

  1. 直到 Unity 5.0.1 (说好的 5.x 修复呢?) 为止,如果你的代码用 Unity 自带的 Mono 编译器,无论使用的是标准容器 (自带 struct-enumerator 优化) 还是自定义容器 (手动 struct-enumerator 优化),都无法避免 foreach 展开后经由 GetEnumerator() 所获取出的 struct-enumerator 产生的一个额外的 boxing 动作 (及对应的内存分配)。简单地说,由 Unity 自带编译器编译的代码,建议不要使用 foreach
  2. 然而,如果你的代码使用 VS 的 C# 编译器以目标为 “Unity 3.5 .net Subset Base Class Libraries” 编译出 dll,并把此 dll 放在项目的 Assets 目录下供 Unity 直接使用的话,就可以得到 struct-enumerator 优化所带来的好处,无需担心额外开销。也就是说,以 dll (由 VS 编译) 方式使用的代码,可以放心用 foreach
  3. 再补充一点我实测的,如果是数组的遍历,在这两种方式下都不会产生额外的开销,而且在 il 中不创建 enumerator,也就没有对应的 MoveNext() / Dispose() 调用,这样连 try / finally block 也不再生成,生成的代码短了不少。也就是说,数组使用 foreach 没有任何限制,而且遍历效率较容器要高。(使用上面的高精度计时器测得:遍历百万元素的数组 (int[]),列表 (List) 和字典 (Dictionary<int, int>) 分别耗时 6.263ms / 32.65ms / 32.385ms,后两者耗时是数组的 5 倍多,所以能用数组就尽量用数组吧)

相关的对比测试的代码在这里


lambda 表达式 vs. 闭包 (closure)

我们知道,闭包 (closure) 本质上是包含了"外部"变量状态的 lambda 表达式。从这里 (见 “Should you avoid closures and LINQ?” 一段)可以知道,lambda 表达式能够被编译和替换为对应类的一个静态字段,而 closure 由于储存了额外的修改状态,编译器需要创建一个新类来表示和引用,由于C#有比较丰富的类型信息,不仅创建时开销比前者高,而且也隐含着 100 字节以上的内存开销。

简单地说,跟闭包相比,lambda 表达式要轻量得多,可以放心使用


但是这里才是真正的问题——由于普通的 lambda 表达式和闭包长得非常相似,实践中如果不小心,是非常容易弄混淆的。不信的话,试试判断一下,下面这几个函数中的 func,有几个会被 Unity 认为是闭包?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
    static void Test_lambda_01()
    {
        System.Func<int, int> func = (e) => e * e;
        int result = func(6);
        ++result;
    }

    static int _foo2 = 1;
    static void Test_lambda_02()
    {
        System.Func<int, int> func = (e) => e * e + _foo2;
        int result = func(6);
        ++result;
    }

    static int _foo3 = 1;
    static void Test_lambda_03()
    {
        System.Func<int, int> func = (e) => e * e + (++_foo3);
        int result = func(6);
        ++result;
    }

    static void Test_lambda_04()
    {
        int _foo4 = 1;

        System.Func<int, int> func = (e) => e * e + _foo4;
        int result = func(6);
        ++result;
    }

    static void Test_lambda_05()
    {
        int _foo5 = 1;

        System.Func<int, int> func = (e) => e * e + (++_foo5);
        int result = func(6);
        ++result;
    }

如果你能无需思考地给出答案,那么还请受俺真心的一拜,佩服佩服。

C# 下的闭包与普通的 lambda 表达式难以区分的原因是,C# 的闭包不像 C++ 那样需要显式地指明自己引用的外部变量。C++ 通过所谓的方括号“捕获”语法 ( square brackets capturing ) 来声明一个闭包,非常清晰地指明自己需要的外部环境,比如:

1
2
3
4
    std::vector<int> some_list{ 1, 2, 3, 4, 5 };
    int total = 0;
    auto func = [&total](int x) { total += x; };
    std::for_each(begin(some_list), end(some_list), func);

这里的方括号显式地以引用方式“捕获”了 total 作为闭包的一部分,而 C# 把这个简化成由编译器推导了。这里俺觉得还是 “When in Doubt, Be Explicit” 好一些。

嗯,刚才的练习俺就卖个关子,不给出答案了。代码在这里,感兴趣的同学运行这些代码所在的测试 Unity 工程,一试便知。


关于 lambda 表达式和闭包,这次先讲这么多吧。要点是__保持短小__即可。对于这种嵌套的逻辑,随着代码量的增加,可读性会急剧下降,就会损失局部定义的好处。


枚举项的 ToString() vs. Enum.GetName()

简单说一下吧。有种说法是说不要使用枚举的 ToString() 来获取单个枚举项对应的字符串,应该使用 Enum.GetName(typeof(Foo), Foo.Bar) 据说后者的速度是前者的两倍。

我实测了一下,一个含有 10 个枚举项的枚举 FooTypes 在遍历并转换每一项 10000 次的结果是:

  • ToString() 耗时 553.817ms
  • Enum.GetName() 耗时 437.2ms

测试中的差距并不十分显著,所以结论是__直接使用枚举项的 ToString() 并无不妥,可读性亦较 Enum.GetName 更佳__。但有一点要注意的是,这种字符串化的绝对开销不低,算下来转 1000 次会花 5ms 左右,这个开销感觉对游戏来说已经比较高了。


具体的测试代码在这里


使用 “as” 转型 vs. 使用 C-Style Cast

同上。按照这里的说法

“When casting a variable use the post fix “as type” instead of pre fixing with (type) as this is faster.”

直接说测试结果吧,实测百万次的有效转型的结果是:

  • as 方式耗时 5.287ms
  • C-Style 方式耗时 4.640ms

同样是差距并不显著,而 as 方式甚至更慢一点。这是为什么呢?查阅 MSDN 可以在这里看到,

1
    expression as type

实际上等价于

1
    expression is type ? (type)expression : (type)null

也就是说,对于有效转型,“as” 的开销 = “is” 的开销 + “c-style cast” 的开销。这样就解释了前面的测试结果。

结论,在 使用 “as” 还是 C-Style Cast 这个问题上,不要考虑性能影响,应按照它们各自提供的功能去选择。进一步说,如果你需要自动地把错误的转型以 null 为结果返回的话,使用 “as” 关键字;如果你认为这个转型足够重要,不希望别人由于忘记检查返回值导致没有处理该关键错误,那么就需要使用 C-Style Cast,这种方式会在转型失败时抛出 InvalidCastException 异常。多说一句,在 Unity 的框架内即使不处理,这个异常也会被 Unity 吞掉,然而我个人对这种不管三七二十一都一网打尽的方式持保留意见。


具体的测试代码在这里


矩阵优化 (I) - 矩阵乘法

UnityEngine.dllstruct Matrix4x4 定义 (网页版在这里) 中,我们可以看到 Unity 的标准矩阵相乘函数的定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    // Summary:
    //     A standard 4x4 transformation matrix.
    public struct Matrix4x4
    {
        // (前略) ...

        public static Matrix4x4 operator *(Matrix4x4 lhs, Matrix4x4 rhs);

        // (后略) ...
    }

那么它的性能具体如何呢?我简单地测试了一下,在 AMD 的 3.5G 八核处理器上,百万次矩阵相乘的时间为 339.681 (ms)

operator * 是 Unity 引擎提供给用户的标准矩阵相乘的接口。对于一个典型的 3D 游戏来说,这个操作可能会被频繁地用到,因此对其进行适当的优化还是很有必要的。下面我们一起来看看,对矩阵相乘操作可以做哪些优化尝试,具体效果又能达到什么程度。

为了方便,operator * 的版本,下面我们简单地称为__官方版__。


优化版本 1 - brute-force (耗时 1596.76%)

首先,我们实现一个拿衣服版本的矩阵相乘 Mul_v1_naive:

1
2
3
4
5
6
7
8
9
    public static Matrix4x4 Mul_v1_naive(Matrix4x4 m1, Matrix4x4 m2)
    {
        Matrix4x4 result = Matrix4x4.zero;
        for (int i = 0; i < 4; i++)
            for (int j = 0; j < 4; j++)
                for (int k = 0; k < 4; k++)
                    result[i, j] += m1[i, k] * m2[k, j];
        return result;
    }

这个是很直接的教科书式的矩阵相乘运算。这个运算告诉了我们如下的信息:

  • 把拿衣服版本的运算结果与官方版做值比较,得到了一致的结果——这说明了官方版的行为与我们的期望完全一致。
  • 把运算的开销与官方版比较,不出所料,拿衣服版本够慢的,百万次耗时为 5413.076 (ms) 左右,是官方版的 15 倍。

优化版本 2 - 循环展开 (耗时 100.54%)

拿衣服的 v1 版本有三重循环嵌套,光是肉眼看起来就很慢。那么我们把循环展开一下,写个 v2 测一下看看有什么变化。

这是 Mul_v2_naive_expanded:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    public static Matrix4x4 Mul_v2_naive_expanded(Matrix4x4 m1, Matrix4x4 m2)
    {
        // Matrix4x4 is a struct so 'new' would still leave it on stack 
        return new Matrix4x4
        {
            m00 = m1.m00 * m2.m00 + m1.m01 * m2.m10 + m1.m02 * m2.m20 + m1.m03 * m2.m30,
            m01 = m1.m00 * m2.m01 + m1.m01 * m2.m11 + m1.m02 * m2.m21 + m1.m03 * m2.m31,
            m02 = m1.m00 * m2.m02 + m1.m01 * m2.m12 + m1.m02 * m2.m22 + m1.m03 * m2.m32,
            m03 = m1.m00 * m2.m03 + m1.m01 * m2.m13 + m1.m02 * m2.m23 + m1.m03 * m2.m33,

            m10 = m1.m10 * m2.m00 + m1.m11 * m2.m10 + m1.m12 * m2.m20 + m1.m13 * m2.m30,
            m11 = m1.m10 * m2.m01 + m1.m11 * m2.m11 + m1.m12 * m2.m21 + m1.m13 * m2.m31,
            m12 = m1.m10 * m2.m02 + m1.m11 * m2.m12 + m1.m12 * m2.m22 + m1.m13 * m2.m32,
            m13 = m1.m10 * m2.m03 + m1.m11 * m2.m13 + m1.m12 * m2.m23 + m1.m13 * m2.m33,

            m20 = m1.m20 * m2.m00 + m1.m21 * m2.m10 + m1.m22 * m2.m20 + m1.m23 * m2.m30,
            m21 = m1.m20 * m2.m01 + m1.m21 * m2.m11 + m1.m22 * m2.m21 + m1.m23 * m2.m31,
            m22 = m1.m20 * m2.m02 + m1.m21 * m2.m12 + m1.m22 * m2.m22 + m1.m23 * m2.m32,
            m23 = m1.m20 * m2.m03 + m1.m21 * m2.m13 + m1.m22 * m2.m23 + m1.m23 * m2.m33,

            m30 = m1.m30 * m2.m00 + m1.m31 * m2.m10 + m1.m32 * m2.m20 + m1.m33 * m2.m30,
            m31 = m1.m30 * m2.m01 + m1.m31 * m2.m11 + m1.m32 * m2.m21 + m1.m33 * m2.m31,
            m32 = m1.m30 * m2.m02 + m1.m31 * m2.m12 + m1.m32 * m2.m22 + m1.m33 * m2.m32,
            m33 = m1.m30 * m2.m03 + m1.m31 * m2.m13 + m1.m32 * m2.m23 + m1.m33 * m2.m33,
        };
    }

这个版本跑下来已经跟官方版很接近了,上文提到官方版百万次是 339.681 (ms),而 Mul_v2_naive_expanded 的开销是 341.534 (ms),是前者耗时的 100.54%。有理由推断,官方版的 operator * 或多或少就是如此实现的。

优化版本 3 - 使用 ref 处理参数和返回值 (耗时 25.52%)

我们注意到,由于 Matrix4x4 是一个 struct,参数传递时是传值而不是传引用,这意味着每次调用会产生三次矩阵的复制,算下来是 16 * 3 个 float 也就是 192 bytes 的复制。我们改为 ref 来消除这些无谓的复制,就得到了 v3 的实现。

这是 Mul_v3_ref:

1
2
3
4
5
6
7
8
    public static void Mul_v3_ref(ref Matrix4x4 result, ref Matrix4x4 m1, ref Matrix4x4 m2)
    {
        result.m00 = m1.m00 * m2.m00 + m1.m01 * m2.m10 + m1.m02 * m2.m20 + m1.m03 * m2.m30;

        // (中略) ...

        result.m33 = m1.m30 * m2.m03 + m1.m31 * m2.m13 + m1.m32 * m2.m23 + m1.m33 * m2.m33;
    }

这个版本的耗时为 86.703 (ms),缩短到了官方版的 1/4 左右。

可见 “复制 192 字节” 所花的时间至少是 “64 次乘法 + 48 次加法” 的三倍 (“至少"二字是考虑到函数调用的开销),所以减少无谓的复制还是蛮重要的,呵呵。

优化版本 4 - 利用 3D 变换矩阵的特点 (耗时 20.97%)

我们知道,在 3D 矩阵的变换中,最后一行是 (0, 0, 0, 1) 不会变化,如下图:

matrix43

所以可以省掉这一部分冗余运算,得到 Mul_v4_for_3d_trans:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    public static void Mul_v4_for_3d_trans(ref Matrix4x4 result, ref Matrix4x4 m1, ref Matrix4x4 m2)
    {
        // (前略,同上) ...

        result.m23 = m1.m20 * m2.m03 + m1.m21 * m2.m13 + m1.m22 * m2.m23 + m1.m23 * m2.m33;

        result.m30 = 0;
        result.m31 = 0;
        result.m32 = 0;
        result.m33 = 1;
    }

这个版本的耗时为 71.062 (ms),约是官方版的 1/5 。


这里拿矩阵相乘做优化实际上是举个栗子,旨在说明在实践中__避免不必要的拷贝__的重要性。 至于何时使用 struct,何时使用 class,何时需要使用 ref 加持的 struct,相信大家能够根据具体的情况去更好地判断。


这四个版本的实现代码和对应的测试代码可以在这里这里看到。


矩阵优化 (II) - 变换矩阵的缓存

这里我们还是拿矩阵来举例子吧。下面是一个典型的变换到摄像机空间的操作 (为简化讨论仍使用官方版的乘法):

1
    Matrix4x4 ret = Camera.main.worldToCameraMatrix * _inputMat;

这里的运算本身并无不妥,但通常我们做这种变换的时候,是对一系列输入矩阵做变换,如下:

1
2
3
4
5
    public static void ApplyTransform(Matrix4x4[] outputMatrices, Matrix4x4[] inputMatrices)
    {
        for (int i = 0; i < inputMatrices.Length; i++)
            outputMatrices[i] = Camera.main.worldToCameraMatrix * inputMatrices[i];
    }

这时问题就来了,Camera.main.worldToCameraMatrix 看起来只是获取对象的只读属性,但实际上是对一系列复杂表达式的求值,有时甚至还涉及到 safe/unsafe 的切换。

那么反复对这个表达式求值,就值得被从循环中提取出来,如下所示:

1
2
3
4
5
6
    public static void ApplyTransform(Matrix4x4[] outputMatrices, Matrix4x4[] inputMatrices)
    {
        Matrix4x4 trans = Camera.main.worldToCameraMatrix;
        for (int i = 0; i < inputMatrices.Length; i++)
            outputMatrices[i] = trans * inputMatrices[i];
    }

经过测试,提取前后,百万次变换的开销分别为 977.077 (ms) / 338.626 (ms)。可以看出,后者本质上就是百万次的矩阵相乘操作,与上边官方版的矩阵相乘的运算开销 (339.681 (ms)) 非常接近。而前者花了__近3倍__的运算时间,其中一大半都在对 Camera.main.worldToCameraMatrix 表达式求值。


这个例子告诉我们,C# 的属性 (Property) 是非常有欺骗性的,可能内部隐藏了使用者难以预计的运算开销。这种欺骗性较 C++ 中的赋值和拷贝构造内的隐含逻辑更甚,因为后者毕竟有该类对应对象的尺寸作为参考,如果对象的尺寸偏大,我们理所当然地认为它的赋值会更费。而 C# 中的 Property 如果看不到代码,唯一可资参考的就是该 Property 的类型了,可是“某个属性的类型是什么”,与“它是怎么被计算出来的”,本质上是没啥关系的,所以欺骗性要强得多。

补充一句,当我们使用没有代码的实现时,更要加倍留意这种隐藏的陷阱。


这个测试的代码可以在这里看到。


其他的一些零碎常识

还有一些常识,简单归纳一下:

  • 利用 string 的 immutable 特性,在内存中单一实例 (Interning) 的特性
  • 利用 string 的比较性能好 (当引用方式为 object 时进行地址比较) 的特性
  • 在需要时使用 StringBuilder
  • 利用好容器的 Capacity 来优化内存访问
  • 利用 ref 和 struct 来把堆 (heap) 上的访问往栈 (stack) 上挪
  • 避免使用 LINQ 来降低零碎的内存分配

这些常识稍有经验的 C# 程序员应该都很熟悉,就不一一赘述了。


内存相关的实践

Mono 2.6.5 的 GC 特性和应对方案

直到目前我手头上的 Unity 5.0.1 为止,其内含 Mono 始终停留在 “2.6.5.0” 上 (特定 Unity 版本可查看 “Editor\Data\Mono\lib\mono\2.0\mscorlib.dll” 内的 Consts.MonoVersion 得知)。这个版本的 Mono 使用的 GC 仍是较老的 Boehm garbage collector


这里先简单说一下 Boehm GC 实现的一些特点:

  • 基于 Mark/Sweep,无分代/并行
  • 执行时所有线程阻塞 (Stop-The-World)
  • 堆越接近满的状态,执行得越频繁
  • 每次标记都会扫描访问到所有可到达的对象
  • 标记阶段 (Mark Phase) 的性能数据 (仅作为参考)
    • 在小对象的情况下,1.4GHz Itanium 能达到 500MB/Sec 的速度
    • 每个对象 90 个时钟周期左右 (大量时间是 cache-missing 所致)
    • 算下来每秒 15M 数目的对象,也就是__每毫秒标记 15000 个__左右
  • 清除本身开销很小,但 Finalization 较耗时 (取决于对象的 finalizer)

具体到 Mono 的实现中,有以下这些需要注意的地方:

  • (Mono 实现) 无法精确地读取寄存器和栈,且无法区分一个给定值是指针还是标量,这会造成大块的内存无法正常回收,而且难以压缩空闲列表
  • (Mono 实现) 碎片化会导致直接的新堆分配,即使空间仍充足(也就是说 Mono 没有做 Copy Optimization 相关的 Defragmentation)
  • (Mono 实现) Mono 的 Finalizer 运行在独立的线程上,因此 GC.Collect() 和 obj.Dispose() 是需要线程同步的。
  • (Mono 实现) 由于第一条,GC.Collect() 不会处理栈,寄存器,静态变量(这些东东被称为所谓的 “Roots”
  • (Mono 实现) GC 的开销与堆的尺寸是__正相关__的 (分配得越多,堆尺寸越大,新的分配和回收就会越慢)

这里的信息来自The Boehm-Demers-Weiser Conservative Garbage Collector (Hans-J. Boehm, HP Labs)GitHub (ivmai/bdwgc)Transparent Programmer-Directed Garbage Collection for C++ (2007)SGen: Mono’s Generational Garbage Collector

实践中的 GC 控制手法

从实践上看,与 GC 相关的控制手法主要是以下这些:

  1. 避免无谓的反复分配,尤其是__隐含的每帧分配__ 典型的例子是在 Update() 函数里面拼接字符串
  2. 在可能的时刻__主动触发 GC__,这些时刻包括:
    • “刚刚进入某张地图时”
    • “刚刚打开某个(静态)界面时”
    • “结束掉某一段剧情/新手引导时”
  3. 使用对象池__策略性地重用对象__
    • 把对象的引用归还到对象池,主动有计划地持有引用,而非交给 GC
    • 做好平衡和取舍(最小化分配/释放的行为,同时妥善考虑内存占用量的调整)
  4. 在 GC.Collect() 之前,确保__置空所有能被清理的对象__,以最大化 GC.Collect() 运行一次的性价比

  • 2 和 3 的意义在于,对于每次 GC 而言,如果没有需要释放的对象,速度会非常快。
  • 可以连续触发多帧的 GC ,就能在 Profiler 中看到,时间消耗的峰值就是第一次 GC。
  • 所以尽量手动 GC 的好处就是,会降低 GC 发生在你不期望的时间的几率,也能降低万一发生时的时间开销。
  • 考虑到很多 Unity 程序员之前有过丰富的 C++ 经验,对象池就不再展开细说了。

内存布局的效率改善 (以对象为单位 vs. 以类型为单位)

除了对 GC 的行为有一定的理解,并加以适当的控制以外,内存方面还需要注意布局方面的因素:

看下面的例子:

1
2
3
4
5
6
7
8
    struct Stuff
    {
        int a;
        float b;
        bool c;
        string leString;
    }
    Stuff[] arrayOfStuff;  

这是典型的__以对象为单位__组织数据。

1
2
3
4
    int[] As;
    float[] Bs;
    bool[] Cs;
    string[] leStrings;

这是典型的__以类型为单位__组织数据。


按照 Unity 程序员 Marco Trivellato 同学的说法,后者对 GC 比前者友好,因为按照后者的方式组织,不同类型的数据被 GC 回收时仅需扫描自己那块;而前者(按照对象组织)被 GC 的时候,所有的数据都需要被扫描到,会花费更多的时间。

导致内存碎片化的各种常见点

  • 前面已经提到的,这里汇总一下
    • foreach
    • FindObject()
    • LINQ
    • ToString()
  • Unity 接口中一些导致零碎内存分配的常见点
    • .tag
    • GetComponents<T> (这个据说还要调到 native code 里面去)
    • Vector3[] Mesh.vertices
    • Camera[] Camera.allCameras

美术资源相关的运行时控制

UnloadUnusedAssets() 和 UnloadAsset()

Resources.UnloadUnusedAssets() 的特点:

  • 会扫描所有的未引用资源
  • 发现时就会触发回收操作
  • 是一个异步操作
  • 在加载一个关卡后自动调用

Resources.UnloadAsset() 的特点:

  • 由程序员主动调用
  • Unity 扫描开销比前者低很多 (只考虑相关的依赖关系)

结论:如有可能,尽可能地使用后者手动释放。

资源控制常识

还有一些常识,可能不是很系统,这里也简单提一下:

  • 绝大部分 Mesh 是不需要 CPU 端的读写的,可以把 Read/Write 关掉 (少一份 copy)
  • 不要对 Mesh 做非标准的缩放 (少一份 copy)
  • Instantiate() 内做了下面这些事
    • 克隆整个对象树 (GameObject Hierarchy)
    • 克隆它们的组件 (Components)
    • 复制它们的属性 (Properties)
    • Awake()
      • 清除各种状态
      • 内部状态缓存
      • 预计算
    • 需要的话应用变换 (Apply Transform)

慢慢地引申到了图形方面,这方面实践中也有一些内容,考虑到篇幅,这一次就不展开了。代码相关的实践,这一次就先讨论这么多吧。


工程相关的实践

下面我们来简单聊一下工程方面的实践,这些实践我基本上都只是简单地提一下思路,仅供参考。

耗电发热问题改善

常见的手机发热问题根源有这些:

  • 后台运行多个任务导致CPU超载;
  • 系统I/O处理遇到瓶颈和阻塞;
  • 手机充电时导致过热;
  • 后台多个应用消耗一定的电量;
  • 手机硬件连接网络时电量损耗最多;

降低发热可以做的事有以下这些:

  • 在特定的界面控制帧率,降低 CPU/GPU 的使用率
  • 检测后台应用并提示关闭,提示关闭 GPS 和 蓝牙
    • 或者提供一键关闭,游戏关闭或退到后台时再自动恢复)
  • 亮度动态调整
    • (甚至可考虑当前地图的光照风格)
  • 提示关闭背景数据和关闭自动同步,退出时再自动恢复
    • (但将无法及时接收到邮件)
  • IO 异步化,串行化,可等待化,可丢弃化,Throttling (流速控制)

新手引导防卡死

正如我在 “一个有趣的交互 bug ——兼谈游戏的引导系统” 一文中提到的新手引导问题那样,卡新手是一类常见问题。对于这一类问题,除了把可能有冲突的系统及时修复以外,我们还应当采取一些防御性的设计,当玩家陷入卡死状态时,能借助这些机制跳出当前的卡死状态。

由于现在的新手引导普遍傻瓜化,如果玩家停留在某个步骤超过 5 秒钟,我们就可以假设该玩家遇到状况了。这时我们可以检测玩家是否在连续 tap 屏幕,如果连续 tap 三次以上,可以弹出信息提示:“请长按屏幕 x 秒钟退出当前的引导” 如果玩家按提示操作,就 break 出当前的引导,视情况跳过或重新开始。

错误处理和异常捕获时机

这一节只需讲一句话:关键逻辑路径上不要裸调关键逻辑路径上不要裸调关键逻辑路径上不要裸调。(是谁说重要的话要讲三遍来着?)

不要依赖 Unity 对未捕获异常的宽容性。

在游戏的关键逻辑路径上,如果裸调一个可能抛出异常的函数,就会冒着部分关键业务逻辑被跳过的风险。这种风险除了会造成可能的内部状态错误和运行不稳定以外,更有可能被破解者或熟练用户利用,达成各种你非常非常不希望见到的目的。

宕机信息的采集,处理,统计和反馈

这里必须宣传一下,我司(西山居)质量中心部门近期出品了一个服务 Crasheye,可以帮助开发人员采集,分类和梳理各种宕机记录,给出项目稳定情况的分类趋势统计,并借助符号文件把堆栈转换为程序员可读的样式。第一次看到这个工具就我伙呆了(这个词过时得好快)。

这里可以看到一个使用此产品来捕获宕机信息和统计的演示,简直华丽得不能直视。

好了废话不多说了,我要跑去质量中心那边,请他们收下我的膝盖了。


[2015-06-28] 补:

本文中的代码都在这里,如有错误请不吝指出,更新和扩展都会出现在那里。

(全文完)


comments powered by Disqus
Built with Hugo