减少垃圾的产生量
可以使用一些技术来帮助我们减少代码中生成的垃圾量
1.缓存
如果我们的代码重复调用产生堆分配的函数,然后丢弃结果,这将产生不必要的垃圾。 对此,我们应该存储对这些对象的引用并复用它们。 这种技术被称为缓存。
下面的函数每次调用都会引起堆分配,因为每次调用都会生成一个新的数组。
void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType<Renderer>();
ExampleFunction(allRenderers);
}
下面的代码只会有一次堆分配,因为数组创建赋值后被缓存起来了?;捍娴氖榭梢愿从靡蚨换岵?/p>
private Renderer[] allRenderers;
void Start()
{
allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
2.不要在频繁调用的函数中分配
如果我们需要在MonoBehaviour中分配堆内存,在频繁调用的函数里分配是最糟糕的。比如 每帧调用的函数Update()和LateUpdate(),在这些地方分配,垃圾将非常快的累积。我们应该尽可能在Start() 或 Awake() 里缓存这些对象的引用,或者确保分配内存的代码只在需要的时候被运行。
让我们来看个简单的例子,下面的代码在每次 Update()调用时都会调用一个引起堆分配的函数,会非??斓牟?/p>
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;
}
}
另一个在 Update()函数中减少垃圾内存产生量的方法是使用计时器.这适用于那些会产生垃圾内存的代码需要被频繁调用又不需要每帧调用的地方
下面的示例代码,产生垃圾内存的函数每帧被调用
void Update()
{
ExampleGarbageGeneratingFunction();
}
下面的代码,使用一个计时器来保证产生垃圾内存的函数每秒只被调一次
private float timeSinceLastCalled;
private float delay = 1f;
void Update()
{
timeSinceLastCalled += Time.deltaTime;
if (timeSinceLastCalled > delay)
{
ExampleGarbageGeneratingFunction();
timeSinceLastCalled = 0f;
}
}
像这样对频繁调用函数的小改动,可以显著的减少垃圾内存的产生量
3.清空容器
创建容器类会引起堆分配,如果在代码中发现多次创建同一个容器变量,则应该缓存该容器引用并在重复创建的地方使用 Clear()操作来替代
下面的示例中每次 *new *操作都会产生一次堆分配
void Update()
{
List myList = new List();
PopulateList(myList);
}
下面的示例中,只在容器被创建或者扩容时才会有堆分配,显著减少了垃圾内存的产生量
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
4.对象池
即使减少了脚本中的堆分配,在运行时大量对象的创建和销毁依然会引起GC问题. 对象池是一种通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术.对象池在游戏中广泛使用,最适合于频繁产生和销毁类似对象的情况;,例如,当枪射击子弹时.
引起不必要堆分配的常见原因
我们知道局部的,值类型的变量被分配在栈上,其他的都在堆上分配.但是很多情况下的堆分配可能让人惊讶.我们来看看一些不必要的堆分配的常见原因,并考虑如何最好地减少这些。
1.字符串
在C#中,字符串是引用类型,而不是值类型,尽管它们似乎保持字符串的“值”. 这意味着创建和丢弃字符串会产生垃圾.由于字符串常用在很多代码中,所以这些垃圾可能累积。
C#中的字符串也是不可变的,这意味着它们的值在第一次创建之后不能再被更改。 每次我们操纵一个字符串(例如,通过使用+运算符来连接两个字符串),Unity将创建一个包含更新值的新字符串,并丢弃旧字符串。 这会产生垃圾。
我们可以遵循一些简单的规则,将字符串产生的垃圾减至最少。 我们来看看这些规则,然后看一下应用它们的例子。
- 减少不必要的字符串创建。 如果多次使用相同的字符串值,应该创建一次该字符串并缓存该值。
- 减少不必要的字符串操作。 例如,如果有一个经常更新的Text组件,并且包含一个连接的字符串,可以考虑将它分成两个Text组件。
- 如果必须在运行时构建字符串,应该使用StringBuilder类。 StringBuilder类用于创建没有堆分配的字符串,并且在连接复杂字符串时减少生成的垃圾量。
- 当不在需要调试时,立即删除对Debug.Log()的调用。即使没有输出任何内容,对Debug.Log()的调用依然会被执行。调用Debug.Log()创建和处理至少一个字符串,所以如果我们的游戏包含许多这些调用,垃圾会累积
来看一个低效使用字符串而产生不必要垃圾的代码的例子。 在下面的代码中,在Update()中创建一个连接“TIME:”与浮点计时器的值的字符串来显示分数,这产生了不必要的垃圾。
public Text timerText;
private float timer;
void Update()
{
timer += Time.deltaTime;
timerText.text = "TIME:" + timer.ToString();
}
下面我们做些改进。 我们把单词“TIME:”放在一个单独的文本组件中,并在Start()中设置它的值。 这样在Update()中,我们不再需要连接字符串。 可以大大减少垃圾的产生。
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
timerHeaderText.text = "TIME:";
}
void Update()
{
timerValueText.text = timer.toString();
}
2.unity函数调用
重要的是要注意,每当我们调用不是自己写的代码时,无论是在Unity中还是在插件中,都可能会产生垃圾。 调用一些Unity函数会产生堆分配,因此应谨慎使用以避免产生不必要的垃圾。
并没有一个应该避免使用的函数列表。 每个函数在某些情况下都是有用的,而在其他情况下则不太有用。所以最好仔细分析我们的游戏,确定垃圾的产生位置并仔细思考如何处理。 在某些情况下,可以缓存函数的结果; 在某些情况下,可以降低调用函数的频率; 在其他情况下,最好重构代码以使用不同的函数。 话虽如此,我们来看几个常见的会导致堆分配 的Unity函数,并考虑如何更好地处理它们。
每次访问返回值为数组的Unity函数时,都会创建一个新的数组,并将其作为返回值传递给我们。 这种行为并不总是显而易见的或可预期的,特别是当函数是访问器的时候(例如 Mesh.normals)。
下面的代码中,每次循环迭代都会生成一个新的数组
void ExampleFunction()
{
for (int i = 0; i < myMesh.normals.Length; i++)
{
Vector3 normal = myMesh.normals[i];
}
}
这种情况下很容易减少分配:我们可以简单地缓存对数组的引用。 这样可以只创建一个数组,并相应地减少了产生的垃圾量。
下面的代码演示了这一点。 在这种情况下,我们在循环之前调用Mesh.normals并缓存引用,这样就只创建一个数组。
void ExampleFunction()
{
Vector3[] meshNormals = myMesh.normals;
for (int i = 0; i < meshNormals.Length; i++)
{
Vector3 normal = meshNormals[i];
}
}
访问GameObject.name或GameObject.tag也会有堆分配。 这两个都是返回新字符串的访问器,这意味着调用这些函数会产生垃圾。 缓存该值可能是有用的,但在这种情况下,可以使用相关的Unity函数。 要检查一个GameObject的标签的值而不产生垃圾,我们可以使用 GameObject.CompareTag()。
下面的示例代码中,访问GameObject.tag会产生垃圾内存:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.tag == playerTag;
}
如果使用 GameObject.CompareTag(),则该函数不会产生垃圾:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.CompareTag(playerTag);
}
GameObject.CompareTag并不是唯一的,很多Unity的函数都有无堆分配的替代版本。比如可以使用Input.GetTouch() 和 Input.touchCount 替换Input.touches, 或者使用Physics.SphereCastNonAlloc() 替换 Physics.SphereCastAll()。
3.装箱
装箱是指当一个值类型变量被用作一个引用类型变量时所执行的操作。当我们将值类型的变量(如int或float)传递给具有object类型参数的函数时,通?;岱⑸跋洌鏞bject.Equals()函数。
例如,函数String.Format()接受一个string和一个object参数。 当我们传递一个string和一个int时,int就会被装箱。 下面的代码包含了一个装箱的例子:
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format("Price: {0} gold", cost);
}
装箱会产生垃圾源于其后台操作。当一个值类型变量被装箱时,Unity在堆上创建一个临时的System.Object来包装值类型变量。 一个System.Object是一个引用类型的变量,所以当这个临时对象被处理掉时会产生垃圾。
装箱是不必要的堆分配的常见原因。 即使我们不在我们的代码中直接装箱变量,我们可能也会使用导致装箱的插件,装箱也可能发生在其他函数的后台。 最好的做法是尽可能避免装箱,并删除导致装箱的任何函数调用。
4.协程
调用StartCoroutine()会产生少量的垃圾,因为Unity必须创建一些管理协程的实例的类。 所以,当游戏在交互时或在性能热点时应该限制对StartCoroutine()的调用。 为了减少这种方式产生的垃圾,必须在性能热点运行的协程应该提前启动,当使用可能包含对StartCoroutine()的延迟调用的嵌套协程时,我们应特别小心。
协程中的yield语句不会自己产生堆分配; 然而,我们传递给yield语句的值可能会产生不必要的堆分配。 例如,以下代码会产生垃圾:
yield return 0;
该代码产生垃圾,因为int变量0被装箱。 在这种情况下,如果我们希望只是等待一个帧而不会导致任何堆分配,那么最好的方法是使用以下代码:
yield return null;
协程的另一个常见错误是在多次使用相同的值时使用了new操作, 例如,以下代码将在循环迭代时每次都重复创建和销毁一个WaitForSeconds对象:
while (!isComplete)
{
yield return new WaitForSeconds(1f);
}
如果缓存和复用WaitForSeconds对象,就能减少垃圾的产生量,请看以下示例代码:
WaitForSeconds delay = new WaitForSeconds(1f);
while (!isComplete)
{
yield return delay;
}
如果我们的代码由于协程而产生大量垃圾,我们可能考虑使用除协程之外的其他东西来重构我们的代码。 重构代码是一个复杂的问题,每个项目都是独一无二的,但是有一些常用的手段或许对协程问题有帮助。 例如,如果我们主要使用协同程序来管理时间,我们可以简单地在一个Update()函数中记录时间。 如果我们主要使用协同程序来控制游戏中发生的事情的顺序,我们可以创建某种消息系统来允许对象进行通信。 一个方法不能解决所有问题,但是有必要记住,在代码中可以有多种方法来实现相同的事情。
5.foreach循环
在Unity5.5之前的版本中,使用foreach遍历数组之外的所有集合,在循环终止时都会产生垃圾,这是因为其后台的装箱操作。当循环开始并且循环终止时,一个System.Object对象被分配在堆上。 Unity 5.5中已修复此问题。
在5.5之前的Unity版本中,以下代码中的循环会生成垃圾:
void ExampleFunction(List listOfInts)
{
foreach (int currentInt in listOfInts)
{
DoSomething(currentInt);
}
}
如果我们无法升级我们的Unity版本,则有一个简单的解决方案来解决这个问题。 for和while循环不会在后台引起装箱,因此不会产生任何垃圾。 当迭代不是数组的集合时,我们应该优先使用它们。
下面的代码不会产生垃圾:
void ExampleFunction(List listOfInts)
{
for (int i = 0; i < listOfInts.Count; i ++)
{
int currentInt = listOfInts[i];
DoSomething(currentInt);
}
}
6.函数引用
函数引用,无论是引用匿名函数还是命名函数,都是Unity中的引用类型变量。 它们将导致堆分配。 将匿名函数转换为 闭包(匿名函数可在其创建时访问范围中的变量)显著增加了内存使用量和堆分配数量。
函数引用和闭包如何分配内存的精确细节因平台和编译器设置而异,但是如果GC是一个问题,那么最好在游戏过程中尽量减少使用函数引用和闭包。 <u><u>这个Unity性能最佳实践指南</u></u> 在这个主题上有更多的技术细节。
7.LINQ和正则表达式
LINQ和正则表达式由于在后台会有装箱操作而产生垃圾。在有性能要求的时候最好不使用。 同样,<u><u>这个Unity性能最佳实践指南</u></u> 提供了有关此主题的更多技术细节。
8.构建代码以最小化GC的影响
代码的构建方式可能会影响GC。即使代码中没有堆分配,也有可能增加GC的负担。
可能增加GC的负担之一是要求它检查它不应该检查的东西。Structs是值类型变量,但是如果有一个包含引用类型变量的struct,那么垃圾收集器必须检查整个结构体。 如果有大量这样的结构体,那么垃圾回收器将增加大量额外的工作。
在这个例子中,下面的struct包含了一个引用类型的字符串。 现在在垃圾回收器运行时必须检查结构体的整个数组。
public struct ItemData
{
public string name;
public int cost;
public Vector3 position;
}
private ItemData[] itemData;
在这个例子中,我们将数据存储在单独的数组中。 当垃圾收集器运行时,它只需要检查字符串数组,并且可以忽略其他数组。 这减少了垃圾收集器的工作。
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
另一个可能增加GC负担的操作是使用不必要的对象引用,当垃圾收集器搜索对堆上对象的引用时,它必须检查代码中的每个当前对象引用。 更少的对象引用意味着更少的工作量,即使我们不减少堆上的对象总数。
在这个例子中,我们有一个类填充一个对话框。 当用户查看对话框时,会显示另一个对话框。 我们的代码包含对应该显示的DialogData的下一个实例的引用,这意味着垃圾回收器必须在其操作中检查此引用:
public class DialogData
{
private DialogData nextDialog;
public DialogData GetNextDialog()
{
return nextDialog;
}
}
这里我们重构下代码,以便它返回一个用于查找下一个DialogData实例的标识符,而不是实例本身。 这不是一个对象引用,所以它不会增加垃圾收集器所花费的时间。
public class DialogData
{
private int nextDialogID;
public int GetNextDialogID()
{
return nextDialogID;
}
}
这是个小例子。 然而,如果我们的游戏中有许多包含对其他对象引用的对象,那么我们可以通过以这种方式重构代码来大大降低堆的复杂性。
定时GC
1.手动强制GC
最后,我们可能希望自己触发GC。 如果我们知道堆内存已被分配但不再使用(例如,如果我们的代码在加载资源时生成垃圾),并且我们知道垃圾收集冻结不会影响播放器(例如,当加载界面还显示时),我们可以使用以下代码请求GC:
System.GC.Collect();
这将强制运行GC,在我们方便的时候释放未使用的内存。