Unity性能优化基础篇——代码篇
在代码造成的性能瓶颈中,大部分是由于GC引起,GC会导致帧率变低。既然要避免GC,我们首先要明白GC的原理。首先Unity的内存模块分为栈内存和堆内存。栈用来存储短期的和小块的数据,堆用来存储长期的和大块的数据。
当一个变量创建时,Unity在栈或堆上申请一块内存。
只要变量在作用域内(仍然能够被我们的代码访问),分配给它的内存表示在使用中。我们称这块内存已经被分配。
当变量不在作用域了,内存不再被需要了,就可以被返回到它被申请的内存池。当内存被返回到内存池时,我们称之为内存释放。当变量不在作用域内时,栈上的内存会立刻被释放。而当堆上的变量不在作用域时,在堆上的内存并不会在这一刻马上被释放,并且此时内存状态仍然是已被分配状态。
而GC就会识别并且释放无用的堆内存。当堆内存过高时Unity会定期的去自动调用GC
栈内存的分配和释放
栈上分配和释放内存很快并且很简单。这是因为栈上只是用来存储小数据且很短的时间。分配和释放内存总是按照预期的顺序和预期的大小。
栈工作像栈数据类型一样:它是一个一些元素的简单集合,在这里是一些内存块,元素智能按照严格的顺序添加或者移除。因为这种简洁和严格,所以很快:当一个变量存储在栈上时,内存简单的在栈的“末尾”被分配,当栈上的变量不在作用域时,存储它的内存马上被返还回栈以便重用。
堆上内存分配和释放
堆上分配内存比栈上要复杂很多。因为堆上会存储长期和短期的数据,并且数据有很多不同的类型和大小。堆上内存的分配和释放并不总是有预期的顺序,并且可能需要不同大小的内存块。
当一个堆变量被创建,会发生以下步骤:
首先Unity必须先检测堆上是否有足够的空闲内存。如果堆上空闲内存足够,那么为变量分配内存。
敲重点:
如果堆上内存不足,Unity触发GC尝试释放堆上无用的内存。这个操作可能会比较慢。如果现在空闲内存足够了,那么为变量分配内存。
如果执行GC后,堆上空闲内存仍然不足,Unity会增加堆内存容量。这操作可能会比较慢。这样就可以为变量分配内存了。堆上分配内存可能会很慢,尤其是需要GC和扩展堆内存时。
GC都做了些什么事情
当堆变量不在作用域后,存储它的内存不会马上被释放。只有执行垃圾回收时,堆上的无用内存才会被释放。
每次执行GC时,会发生如下步骤:
-垃圾回收器检查堆上的每个对象。
-垃圾回收器查找所有当前对象的引用,确认堆上对象是否还在作用域。
-任何不在作用域的对象被标记为待删除。
-被标记的对象被删除掉,且把为他们分配的内存返还到堆中。
-堆上的对象越多,它需要做的工作就越多,我们代码里对象的引用越多,它需要做的工作就越多,游戏的帧率也会越低。
GC什么时候发生
三件事情可能会触发垃圾回收:
-当堆上分配内存时,且空闲内存不足,会触发垃圾回收。
-GC随着时间自动触发(虽然频率由平台决定)。
-我们可以手动强制执行GC。
GC可能会很频繁。因为当堆上分配内存时,且空闲堆内存不足,会触发GC,这意味着频繁的分配释放堆内存可能会导致频繁的GC。
如果我们知道了是垃圾回收引起的性能问题,那么我们需要知道是我们代码的哪部分生成了垃圾。当堆变量不在作用域了,会产生垃圾,我们要先知道是什么引起了变量分配在堆上。
下面代码是栈上分配的例子,变量localInt是局部变量和值类型的。为这个变量分配的内存,会在这个函数执行完毕后立刻释放。
void ExampleFunction()
{
int localInt = 5;
}
下面代码是堆内存分配的例子,变量localList是局部的引用类型。为它分配的内存会在垃圾回收执行时才被释放。
void ExampleFunction()
{
List localList = new List();
}
如何减少GC的调用
缓存
如果代码重复的调用造成堆内存分配的方法,然后丢弃结果,将造成不必要的垃圾。作为代替,我们应该应该保存结果的引用并复用他们。这项技术成为缓存。
下面例子中,函数每次被调用时都会造成堆内存分配,因为有新的数组创建。
void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType<Renderer>();
ExampleFunction(allRenderers);
}
下面代码只有一次堆内存分配,因为数组创建和填充一次,然后被缓存了。缓存数组可以复用而不用生成更多垃圾。
private Renderer[] allRenderers;
void Start()
{
allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
不要在频繁调用的函数中分配堆内存
如果我们必须在MonoBehaviour中分配堆内存,那么最坏的地方是在哪些频繁调用的函数中。例如Update() 和 LateUpdate(),每帧调用一次,所以如果在这里生成垃圾,垃圾将会增加的很快。我们应该考虑在Start() 或 Awake()方法中缓存引用。或者确保引起的堆分配的代码只有在需要的时候才运行。
让我们看一个简单的例子,调整代码,使得只有数据改变时才执行。下面的代码中,每次调用Update()方法都会分配堆内存,频繁的生成垃圾。
void Update()
{
ExampleGarbageGeneratingFunction(transform.position.x);
}
通过简单的修改,现在我们确保只有transform.position.x改变时,才调用生成垃圾的代码。现在我们只有在必要的时候才会进行堆内存分配,比原来每帧都进行要好很多。
private float previousTransformPositionX;
void Update()
{
float transformPositionX = transform.position.x;
if (transformPositionX != previousTransformPositionX)
{
ExampleGarbageGeneratingFunction(transformPositionX);
previousTransformPositionX = transformPositionX;
}/span>
<span class="p">}
另一个在Update()中降低垃圾生成的技术是使用计时器。这种情况适用于造成堆内存分配的代码必须定期运行,但是不需要每帧都运行时。
下面的例子代码中,每帧生成垃圾:
void Update()
{
ExampleGarbageGeneratingFunction();
}
下面的例子中,我们使用计时器,每秒生成垃圾:
private float timeSinceLastCalled;
private float delay = 1f;
void Update()
{
timeSinceLastCalled += Time.deltaTime;
if (timeSinceLastCalled > delay)
{
ExampleGarbageGeneratingFunction();
timeSinceLastCalled = 0f;
}
}
清除集合
创建新集合会在堆上分配内存。如果我们发现代码中不止一次的创建新集合,那么我们应该缓存集合的引用,并使用Clear()方法清空内容来替代重复的创建新集合。
下面例子中,每次使用New方法,都会在堆上分配内存。
void Update()
{
List myList = new List();
PopulateList(myList);
}
在下面的例子中,只有在集合创建或者在底层集合必须调整大小的时候才会分配堆内存,极大的降低了垃圾的生成数量。
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
对象池
即使我们在脚本中降低了堆内存分配,我们可能还是有垃圾回收问题,如果我们在运行时创建和摧毁很多对象。对象池技术可以降低堆内存的分配和释放,通过复用对象来替代重复的创建和摧毁对象。对象池技术在游戏中应用广泛,特别适合当我们需要频繁的生成和摧毁相似的对象时。例如射击的子弹
字符串
在C#中,字符串是引用类型的,但是使用时候他们看起来像值类型那样用。这意味着,创建和丢弃字符串会产生垃圾。由于字符串在我们代码中很常用,这些垃圾可能会积少成多。
C#中的字符串是不可变的,这意味着她们的值在他们创建后不能被改变。我们每次操作字符串(例如,使用+去连接两个字符串),Unity都创建一个新的字符串并保存结果值,然后丢弃旧的字符串,这会产生垃圾。
我们可以遵循下面一些简单的规则使得从字符串生成的垃圾最小化。让我们考虑这些规则,并看看应用例子。
-我们应该减少没必要的字符串的创建。如果我们使用一样的字符串多过一次,我们应该只创建一次字符串然后缓存它。
-我们应该减少没必要的字符串操作。例如,如果我们需要频繁的更新一个文本组件的值,并且其中包含一个连接字符串操作,我们应该考虑把它分成两个独立的文本组件。
-如果我们必须在运行时组建字符串,我们应该使用StringBuilder类。这个类是设计来做字符串组建的,并且不产生堆内存分配,在我们连接复杂字符串时,这将减少很多垃圾的产生。
-我们应该移除Debug.Log(),当不需要进行调试后。在我们游戏的正式版本中他虽然没有输出任何东西,但是仍然会被执行。调用一次Debug.Log()至少创建和释放一次字符串,所以如果我们的游戏包含了很多调用,垃圾会积少成多。
让我们看看下面的例子,它低效的使用字符串,产生了没必要的垃圾。我们创建了字符串,并且在Update()方法中合并字符串,产生了很多没必要的垃圾。
public Text timerText;
private float timer;
void Update()
{
timer += Time.deltaTime;
timerText.text = &#34;TIME:&#34; + timer.ToString();
}
在下面的例子中,我们把字符串拆分成独立的两部分,在Update()中不再需要合并字符串了,减少了垃圾的产生。
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
timerHeaderText.text = &#34;TIME:&#34;;
}
void Update()
{
timerValueText.text = timer.toString();
}
Unity函数调用
意识到我们调用任何不是我们自己写的代码,无论是Unity自身还是插件,都会产生垃圾是十分重要的。一些Unity函数会造成堆内存分配,所以我们应该小心的使用,避免产生没必要的垃圾。
这里并没有我们应该避免调用的清单。每个函数都是在一些情况下有用,在另外一些情况下没什么作用。一如既往,我们最好使用Profiler仔细的分析我们的游戏,确认垃圾在哪产生的,并且仔细思考如何处理他。有些情况下,可能缓存函数调用的结果是很明智的。另外一些情况下,也许不要调用函数那么频繁。有时可能最好去重构我们的代码,并且使用不同的函数。说了这么多,让我们看看几个例子,一些常用的Unity函数在堆上分配内存,我们如何去处理好他们。
每次我们调用一个返回数组的Unity函数,一个新的数组被创建,并且作为返回值返回给我们。这个行为并不总是很明显或者像预期一样。特别是当函数是存取器时(例如Mesh.normals)。
下面代码,每次循环都会创建新的数组。
void ExampleFunction()
{
for (int i = 0; i < myMesh.normals.Length; i++)
{
Vector3 normal = myMesh.normals;
}
}
我们可以很简单的解决问题,我们可以缓存返回数组的引用,这样做后,我们只创建一次数组,产生的垃圾也因此降低了。
void ExampleFunction()
{
Vector3[] meshNormals = myMesh.normals;
for (int i = 0; i < meshNormals.Length; i++)
{
Vector3 normal = meshNormals;
}
}
另外一个出乎人们预料的引起堆内存分配的函数是GameObject.name 或 GameObject.tag。他们都是返回新字符串的存取器。这意味着调用他们会产生垃圾。缓存可能会有效果,在这个例子中,我们可以用相关的Unity方法替代他们。当检查游戏对象的Tag是否相等时,使用GameObject.CompareTag()不会产生垃圾。
装箱
当一个值类型变量,用在一个需要引用类型变量的位置时,会发生装箱操作。装箱操作,通常发生在我们把值类型的变量如int或者float,传递给需要object参数的函数时如Object.Equals()。
例如,函数String.Format()接收一个字符串和一个object参数。当我们传参数一个字符串和一个int时,int必须被装箱,下面是例子代码
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format(&#34;Price: {0} gold&#34;, cost);
}
装箱会产生垃圾是因为底层发生的行为。当一个值类型变量被装箱时,Unity创建一个临时的System.Object在堆上,去包装值类型变量。System.Object是引用类型的变量,所以当这个临时的对象被创建和销毁时产生了垃圾。
协程
调用StartCoroutine()会产生少量的垃圾,因为Unity必须创建管理协程的类实例。考虑到这一点,当我们关注游戏的交互和性能时,应该有限制的使用协程。为了减少协程产生垃圾的影响,我们不建议在性能临界的时候使用协程。我们还应该特别小心套嵌的协程,如包含延迟调用的协程。
协程中的yield语句自身不会产生堆内存分配;尽管如此,我们通过yield传递的值可能会产生不必要的堆内存分配。例如下面的代码就会产生垃圾。
yield return 0;
这会产生垃圾是因为发生了装箱,如果我们只是想要等待一帧,而不产生垃圾,最好是使用下面的代码:
yield return null;
另一个使用协程的常见错误是在yield返回相同的值得时候,多次使用new。例如下面代码中,每次循环都会创建和释放WaitForSeconds对象:
while (!isComplete)
{
yield return new WaitForSeconds(1f);
}
如果缓存了WaitForSeconds,可以减少很多垃圾:
WaitForSeconds delay = new WaitForSeconds(1f);
while (!isComplete)
{
yield return delay;
}
函数引用
函数引用,不论是匿名方法还是命名的方法,在Uniyt中都是引用类型的变量。他们会引起堆内存分配。把匿名方法转换为闭包会显著的增加内存占用和堆内存分配的大小。
函数引用和闭包具体怎么明确的分配内存,取决于不同的平台和编译设置,但是考虑到垃圾回收,我们最好少使用函数引用和闭包。
LINQ和正则表达式
他们都会产生垃圾,因为需要装箱操作,如果需要考虑性能问题,那么最好不要使用他们
组织我们的代码以便最小化垃圾回收的影响
我们代码的组织方式可以影响垃圾回收。甚至我们的代码没有产生堆内存分配,也可以增加垃圾回收器的负载。
我们不必要的增加垃圾回收器的负载的一种情况是,我们要求他去检查原本不必要检查的东西。结构体是值类型,但是如果我们在结构体中包含了引用类型的变量,那么垃圾回收器就需要检查整个结构体。如果我们有一个由大量这种结构体组成的数组,那么将使得垃圾回收器做了很多额外的工作。
下面的例子中,结构体包含了字符串,垃圾回收器必须要检查整个数组。
public struct ItemData
{
public string name;
public int cost;
public Vector3 position;
}
private ItemData[] itemData;
在这个例子中,我们可以用三个独立的数组存储信息,这样垃圾回收器就只需要处理字符串数组了。
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
安排垃圾回收的时间
手工强制执行垃圾回收
最终,我们也许希望我们自己触发垃圾回收。如果我们知道堆内存被分配,但是已经不再使用了(例如,我们的代码在加载资源时产生的垃圾)并且我们知道垃圾回收在此时不会影响玩家体验(例如在显示加载界面的时候),我们可以用下面的代码强制执行垃圾回收:
System.GC.Collect();
这将会强制执行垃圾回收,释放未被使用的堆内存,我们可以在方便的时机调用。总结
我们已经学习了垃圾回收在Unity中是怎样工作的,为什么他会引起性能问题,以及怎样去最小化他对我们游戏的影响。使用这些知识和性能分析工具,我们可以解决垃圾回收相关的性能问题并且组织我们的代码使得他们更有效的管理内存。
下面一些资源提供了相关主题的更多信息。
扩展阅读
Unity中的内存管理和垃圾回收
Unity Manual: Understanding Automatic Memory Management
Gamasutra: C# Memory Management for Unity Developers by Wendelin Reich
Gamasutra: C# memory and performance tips for Unity by Robert Zubek
Gamasutra: Reducing memory allocations to avoid Garbage Collection on Unity by Grhyll JDD
Gamasutra: Unity Garbage Collection Tips and Tricks by Megan Hughes
装箱
MSDN: Boxing and Unboxing (C# Programming Guide)
对象池
Unity Learn: Object Pooling Tutorial
Wikipedia: Object Pool Pattern
字符串
Best Practices for Comparing Strings in .NET
参考资料:
页:
[1]