Bootstrap

Unity性能优化4【内存实战篇】

本文介绍Unity内存优化的具体方案

1.缓存函数返回值

如果我们的代码重复调用导致堆分配的函数,然后丢弃结果,则会产生不必要的垃圾。相反,我们应该存储对这些对象的引用并重用它们。这种技术称为缓存。

在以下示例中,代码每次调用时都会导致堆分配。这是因为创建了一个新数组。

void OnTriggerEnter(Collider other)
{
    Renderer[] allRenderers = FindObjectsOfType<Renderer>();
    ExampleFunction(allRenderers);
}

以下代码仅导致一次堆分配,因为数组被创建并填充一次,然后被缓存。缓存的数组可以一次又一次地重复使用,而不会产生更多的垃圾。

private Renderer[] allRenderers;

void Start()
{
    allRenderers = FindObjectsOfType<Renderer>();
}


void OnTriggerEnter(Collider other)
{
    ExampleFunction(allRenderers);
}

2.不要在频繁调用的函数中分配堆内存

如果我们必须在 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;
    }
}

另一种减少 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

在以下示例中,每次使用 new 时都会发生新的堆分配。

void Update()
{
    List myList = new List();
    PopulateList(myList);
}

在以下示例中,仅当创建集合或必须在后台调整集合大小时才会发生分配。这大大减少了垃圾的产生量。

private List myList = new List();

void Update()
{
    myList.Clear();
    PopulateList(myList);
}

4.使用对象池代替频繁创建销毁对象

即使我们减少脚本中的分配,如果我们在运行时创建和销毁大量对象,我们仍然可能会遇到垃圾收集问题。对象池是一种可以通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术。对象池在游戏中应用广泛,最适合我们频繁生成和销毁相似对象的情况;例如,用枪射击子弹时。

5.合理使用String

在 C# 中,字符串是引用类型,尽管它们似乎保存着字符串的“值”。这意味着创建和丢弃字符串会产生垃圾。由于字符串在很多代码中常用,因此这些垃圾确实会增加。

C# 中的字符串也是不可变的,这意味着它们的值在首次创建后就无法更改。每次我们操作一个字符串(例如,使用 + 运算符连接两个字符串)时,Unity 都会使用更新的值创建一个新字符串并丢弃旧字符串。这会产生垃圾。

我们可以遵循一些简单的规则来将字符串中的垃圾降至最低。让我们考虑一下这些规则,然后看一个如何应用它们的示例。

1. 我们应该减少不必要的字符串创建。如果我们多次使用相同的字符串值,我们应该创建一次字符串并缓存该值。

2. 我们应该减少不必要的字符串操作。例如,如果我们有一个经常更新并包含连接字符串的文本组件,我们可以考虑将其分成两个文本组件。

3. 如果我们必须在运行时构建字符串,我们应该使用 StringBuilder 类。 StringBuilder 类旨在构建无需分配的字符串,并且可以减少连接复杂字符串时产生的垃圾量。

4. 一旦调试不再需要对 Debug.Log() 的调用,我们就应该删除它们。对 Debug.Log() 的调用仍然在我们游戏的所有版本中执行,即使它们不输出任何内容。对 Debug.Log() 的调用会创建并处理至少一个字符串,因此如果我们的游戏包含许多此类调用,则垃圾会增加。

让我们看一下一个代码示例,该代码通过低效使用字符串生成不必要的垃圾。在下面的代码中,我们通过将字符串“TIME:”与浮点计时器的值组合起来,在 Update() 中创建一个用于显示分数的字符串。这会产生不必要的垃圾。

public Text timerText;
private float timer;

void Update()
{
    timer += Time.deltaTime;
    timerText.text = "TIME:" + timer.ToString();
}

在下面的示例中,我们已经大大改进了事情。我们将单词“TIME:”放在单独的 Text 组件中,并在 Start() 中设置其值。这意味着在 Update() 中,我们不再需要组合字符串。这大大减少了垃圾的产生量。

public Text timerHeaderText;
public Text timerValueText;
private float timer;

void Start()
{
    timerHeaderText.text = "TIME:";
}

void Update()
{
    timerValueText.text = timer.toString();
}

6.使用占用内存较少的API

重要的是要意识到,每当我们调用不是我们自己编写的代码时,无论是在 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;
}
If we use GameObject.CompareTag(), this function no longer generates any garbage:
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()。

7.避免装箱

装箱是指使用值类型变量代替引用类型变量时发生的情况的术语。当我们将值类型变量(例如 int 或 float)传递给具有对象参数(例如 Object.Equals())的函数时,通常会发生装箱。

例如,函数 String.Format() 采用一个字符串和一个对象参数。当我们向它传递一个字符串和一个 int 时,必须对 int 进行装箱。因此,以下代码包含装箱示例:

void ExampleFunction()
{
    int cost = 5;
    string displayString = String.Format("Price: {0} gold", cost);
}

装箱之所以会产生垃圾,是因为幕后发生的事情。当值类型变量被装箱时,Unity 在堆上创建一个临时 System.Object 来包装该值类型变量。 System.Object 是一个引用类型变量,因此当这个临时对象被释放时,会产生垃圾。

装箱是不必要的堆分配的一个极其常见的原因。即使我们没有直接在代码中对变量进行装箱,我们也可能使用导致装箱的插件,或者它可能发生在其他函数的幕后。最佳实践是尽可能避免装箱并删除任何导致装箱的函数调用。

8.减少携程产生的内存分配

调用 StartCoroutine() 会产生少量垃圾,因为 Unity 必须创建类实例来管理协程。考虑到这一点,当我们的游戏是交互式的并且性能是一个问题时,对 StartCoroutine() 的调用应该受到限制。为了减少以这种方式产生的垃圾,任何必须在性能关键时刻运行的协程都应该提前启动,并且在使用可能包含对 StartCoroutine() 的延迟调用的嵌套协程时我们应该特别小心

协程中的yield语句本身不会创建堆分配;然而,我们通过yield语句传递的值可能会产生不必要的堆分配。例如,以下代码会产生垃圾:

yield return 0;

此代码会产生垃圾,因为值为 0 的 int 被装箱。在这种情况下,如果我们希望简单地等待帧而不引起任何堆分配,最好的方法是使用以下代码:

yield return null;

协程的另一个常见错误是在多次产生相同值时使用 new。例如,以下代码将在每次循环迭代时创建并释放一个 WaitForSeconds 对象:

while (!isComplete)
{
    yield return new WaitForSeconds(1f);
}

如果我们缓存并重用 WaitForSeconds 对象,那么产生的垃圾就会少得多。以下代码以此为例:

WaitForSeconds delay = new WaitForSeconds(1f);

while (!isComplete)
{
    yield return delay;
}

如果我们的代码由于协程而产生大量垃圾,我们可能希望考虑重构我们的代码以使用协程以外的东西。重构代码是一个复杂的主题,每个项目都是独一无二的,但我们可能希望记住一些协程的常见替代方案。例如,如果我们主要使用协程来管理时间,我们可能希望简单地在 Update() 函数中跟踪时间。如果我们主要使用协程来控制游戏中事件发生的顺序,那么我们可能希望创建某种消息传递系统来允许对象进行通信。没有一种万能的方法可以实现这一点,但记住,在代码中通常有不止一种方法可以实现相同的目标,这是很有用的。

9.减少垃圾收集器的校验工作

我们的代码的结构方式会影响垃圾收集。即使我们的代码不创建堆分配,它也会增加垃圾收集器的工作负载

我们的代码不必要地增加垃圾收集器工作量的一种方法是要求它检查不应该检查的东西。结构体是值类型变量,但如果我们有一个包含引用类型变量的结构体,那么垃圾收集器必须检查整个结构体。如果我们有大量这些结构,那么这可能会给垃圾收集器带来很多额外的工作。

在此示例中,结构体包含一个引用类型的字符串。现在,垃圾收集器在运行时必须检查整个结构数组。

public struct ItemData
{
    public string name;
    public int cost;
    public Vector3 position;
}
private ItemData[] itemData;

在此示例中,我们将数据存储在单独的数组中。当垃圾收集器运行时,它只需要检查字符串数组,可以忽略其他数组。这减少了垃圾收集器必须完成的工作。

private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;

10.减少对象引用

我们的代码不必要地增加垃圾收集器工作负载的另一种方式是拥有不必要的对象引用。当垃圾收集器搜索堆上对象的引用时,它必须检查代码中的每个当前对象引用。即使我们不减少堆上的对象总数,代码中的对象引用越少意味着要做的工作就越少。

在此示例中,我们有一个填充对话框的类。当用户查看该对话框时,将显示另一个对话框。我们的代码包含对应显示的 DialogData 的下一个实例的引用,这意味着垃圾收集器必须检查此引用作为其操作的一部分:

public class DialogData
{
    private DialogData nextDialog;

    public DialogData GetNextDialog()
    {
        return nextDialog;
    }
}

在这里,我们重构了代码,以便它返回一个用于查找 DialogData 的下一个实例的标识符,而不是实例本身。这不是对象引用,因此不会增加垃圾收集器所花费的时间。

public class DialogData
{
    private int nextDialogID;

    public int GetNextDialogID()
    {
        return nextDialogID;
    }
}

就其本身而言,这个例子相当简单。然而,如果我们的游戏包含大量持有其他对象引用的对象,我们可以通过以这种方式重构代码来大大降低堆的复杂性。

11.合适的时间强制垃圾回收

最后,我们可能希望自己触发垃圾收集。如果我们知道堆内存已分配但不再使用(例如,如果我们的代码在加载资源时生成了垃圾)并且我们知道垃圾收集冻结不会影响播放器(例如,在加载屏幕时)仍然显示),我们可以使用以下代码请求垃圾收集:

System.GC.Collect();

这将强制垃圾收集器运行,在我们方便的时候释放未使用的内存。

;