1.提高代码性能
1.1每X帧运行一次代码
update每帧都会执行,降低update内非每帧必要函数的执行频率可以有效提高效率。
1 2 3 4 5 |
private int interval = 3; void Update() { if(Time.frameCount % interval == 0) ExampleExpensiveFunction(); } |
1.2使用缓存
在update中,因为每帧都要执行,所以任何函数在每帧执行的情况下都可能带来累计的开销导致性能降低。
例如下面这段代码可以将renderer缓存下来,而不是在update内每帧进行获取。
1 2 3 4 5 |
void Update() { Renderer myRenderer = GetComponent<Renderer>(); ExampleFunction(myRenderer); } |
1.3避免使用昂贵的Unity API
- SendMessage()
这个函数使用反射进行调用,考虑使用事件或委托代替他们 - Find()
这个函数需要Unity遍历内存中每个GameObject和Component,随着项目复杂性增加,这个函数会变得越来越耗。 - Transform
设置Transform的位置或者旋转会导致内部事件OnTransformChanged传递到这个Transform的所有子级。所以太过频繁的修改Transform的位置信息会带来更高的开销(尤其是在update中每帧更新)。我们可以将所有计算放在Vector3中计算,最后将Vector3设置到Transform上从而减少对Transform的修改频率。
此外,Transform.position是一个访问器,调用时会在后台进行计算(本地坐标转换成世界坐标)。而localPosition存储在Transform上,Transform.localPosition只返回该值。如果我们频繁调用Transform.position也将带来一定的性能开销,尝试使用localPosition代替position。如果一定要使用position,那么考虑是否可以缓存该值,而不是每次都重新获取。 - Update()
Update()和LateUpdate()这些函数每次调用时都有引擎代码和托管代码之间的通信,除此之外执行前还会对GameObject进行检查,是否有效是否未被销毁等。
即时Update()函数为空,Unity仍会进行通信与检查,当使用的实例很多时,空的Update()函数也会带来一定的开销,所以这些函数如果不用的话就把他删掉吧~
此外,使用Manager调用自定义的Update和每个模块自己调用Update的开销也有很大差异,详情可以查看:Update的详细解析 - Vector2 和 Vector3
某些操作会导致比其他操作使用更多CPU指令(因为我们编写的是C#,实际打包出去的是CIL中间语言,最后被其他平台通过AOT或JIT翻译成机器码才能使用。在C#很简单的代码可能其实会被翻译成很复杂的机器码)。比如计算平方根比计算乘法要慢,magnitude内部使用了平方根计算,Distance内部使用magnitude进行距离计算,所以在使用时可以考虑替换成sqrMagnitude进行计算,可以节省掉平方根的开销。也许修改单个代码只有很小的影响,但是如果在一个比较大的尺度上来说(例如在update每帧调用),这也可以对性能有很大提升。 - Camera.main
这个API内部调用了Find(),所以频繁使用会有和Find()相同的问题,我们可以缓存结果防止每次重新调用。
1.4高频函数的降频
例如在Update内调用的函数或是一帧内可能会调用多次的函数中,对一些操作进行降频,只在需要改变的时候进行改变。比如一个巡逻的小怪,当他在相机外时,可以只执行移动逻辑,而不更新动画。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private Renderer myRenderer; void Start() { myRenderer = GetComponent<Renderer>(); } void Update() { UpdateTransformPosition(); if (myRenderer.isVisible) { UpateAnimations(); } } |
2.Unity中的垃圾处理GC
2.1.Unity的内存管理
- Unity可以访问两个内存池:栈和堆。栈用于短期存储小块数据,堆用于长期存储和较大块数据。
- 创建变量时,Unity从栈或堆中请求一块内存。
- 只要变量在生命周期内,分配给它的内存就一直在使用,我们称这块内存为已经被分配的内存。处于栈内存中的变量为栈中的对象,堆内存中的变量为堆中的对象。
- 当变量离开生命周期时,就可以将变量的内存返回给对应的池中(栈或堆)。当内存返回池中时,我们称内存已经被释放。对于栈来说,当变量离开生命周期后,内存就会被释放。然而堆内存不会立刻被释放,即时变量已经超出了生命周期,内存仍保持被分配状态。
- 垃圾收集器(GC)会识别并释放未使用的堆内存。垃圾收集器会定期运行以清理堆内存。
2.2.创建堆变量时,会发生什么?
- Unity必须检查堆中是否有足够的空闲内存。
- 如果堆中有足够的空闲内存,则为变量分配内存。
- 如果堆中没有足够的空闲内存,Unity会触发垃圾收集器尝试释放未使用的堆内存。这个操作可能十分耗时。
- 如果垃圾回收后堆中仍然没有足够的空闲内存,Unity会增加堆中的内存量。这个操作可能十分耗时。然后为变量分配内存。
2.3.垃圾收集器是怎么工作的?
- 垃圾收集器检查堆上的每个对象。
- 垃圾收集器搜索所有当前对象引用以确定堆上的对象是否仍在范围内。
- 任何不再在范围内的对象都被标记为删除。
- 标记的对象被删除,分配给它们的内存返回到堆中。
2.4.垃圾收集器什么时候执行
- 每当请求的堆分配无法使用堆中的空闲内存完成时,垃圾收集器就会运行。
- 垃圾收集器会不时自动运行(尽管频率因平台而异)。
- 垃圾收集器可以强制手动运行。(System.GC.Collect();)
2.5.如何减少垃圾收集器的影响
- 我们可以减少垃圾收集器运行的时间。我们可以减少堆分配和对象引用。堆上更少的对象和更少的要检查的引用意味着执行当垃圾收集时使用的时间更少。
- 我们可以降低垃圾收集器运行的频率。我们可以减少堆分配和释放的频率,尤其是在性能关键时刻。更少的分配和释放意味着触发垃圾收集的机会更少。这也降低了堆碎片的风险。
- 我们可以故意触发垃圾收集器,使其在对性能不重要的时候运行,例如在加载屏幕期间。
2.6.几个减少垃圾的技巧
- 使用缓存。
如果我们的代码重复调用导致堆分配的函数然后丢弃结果,这会产生不必要的垃圾。相反,我们应该存储对这些对象的引用并重用它们。这种技术称为缓存。
对于需要频繁使用的对象或是数据集合,我们可以使用缓存进行内存分配后一直持有内存,后续使用时反复使用这段内存,就不会产生额外的GC。(必要时还可以使用对象池)
在下面的示例中,代码每次调用时都会导致堆分配。这是因为创建了一个新数组。
12345void OnTriggerEnter(Collider other){Renderer[] allRenderers = FindObjectsOfType<Renderer>();ExampleFunction(allRenderers);} - 字符串
在 C# 中,字符串是引用类型,而不是值类型,即使它们似乎包含字符串的“值”。这意味着创建和丢弃字符串会产生垃圾。由于字符串在很多代码中都很常用,所以这些垃圾会慢慢累积起来。
C# 中的字符串也是不可变的,这意味着它们的值在首次创建后无法更改。每次我们操作一个字符串(例如,通过使用 + 运算符连接两个字符串)时,Unity 都会使用更新的值创建一个新字符串并丢弃旧字符串。这会产生垃圾。- 我们应该减少不必要的字符串创建。如果我们多次使用相同的字符串值,我们应该创建一次字符串并缓存该值。
- 我们应该减少不必要的字符串操作。例如,如果我们有一个经常更新的 Text 组件并包含一个连接的字符串,我们可以考虑将它分成两个 Text 组件。
- 如果我们必须在运行时构建字符串,我们应该使用StringBuilder 类。StringBuilder 类设计用于构建没有分配的字符串,并将节省我们在连接复杂字符串时产生的垃圾量。
- 如果你在使用string.Format,那么恭喜你,StringBuilder也救不了你了。Format底层会使用StringBuilder格式化字符串,可惜的是FormatHelper函数内部还是有很高的GC开销(10次3KB左右)。所以要么放弃Format,要么来试试zstring或者Zstring吧!
- 一旦不再需要用于调试目的,我们就应该删除对 Debug.Log() 的调用。对 Debug.Log() 的调用仍然在我们游戏的所有构建中执行,即使它们没有输出到任何东西。对 Debug.Log() 的调用会创建并处理至少一个字符串,因此如果我们的游戏包含许多此类调用,则垃圾会累积起来。
- Unity函数的调用
每次我们访问一个返回数组的 Unity 函数时,都会创建一个新数组并作为返回值传递给我们。这种行为并不总是明显或预期的,尤其是当函数是访问器时(例如,Mesh.normals)。
堆分配的另一个意外原因可以在函数 GameObject.name 或 GameObject.tag 中找到。这两个都是返回新字符串的访问器,这意味着调用这些函数会产生垃圾。缓存值可能很有用,但在这种情况下,我们可以使用相关的 Unity 函数。要根据值检查 GameObject 的标签而不产生垃圾,我们可以使用GameObject.CompareTag()。
在以下代码中,为循环的每次迭代创建一个新数组。
1234567void ExampleFunction(){for (int i = 0; i < myMesh.normals.Length; i++){Vector3 normal = myMesh.normals[i];}} - 装箱
当值类型变量被装箱时,Unity 在堆上创建一个临时 System.Object 来包装值类型变量。System.Object 是一个引用类型的变量,所以当这个临时对象被释放时,这会产生垃圾。 - 协程
协程中的 yield 语句不会自行创建堆分配;然而,我们通过 yield 语句传递的值可能会造成不必要的堆分配。例如,以下代码会创建垃圾:yield return 0;
。因为值为 0 的 int 已装箱。在这种情况下,如果我们希望简单地等待一个帧而不引起任何堆分配,那么最好的方法是使用以下代码:yield return null;
原文