Unity3D:垃圾回收最佳做法

推荐:将NSDT场景编辑器加入你的3D工具链
3D工具集:NSDT简石数字孪生

垃圾回收最佳做法

垃圾回收是自动的,但该过程需要大量的 CPU 时间。

与其他编程语言(如 C++)相比,C# 的自动内存管理降低了内存泄漏和其他编程错误的风险,在 中,您必须手动跟踪和释放分配的所有内存。

自动内存管理使您可以快速轻松地编写代码,并且几乎没有错误。但是,这种便利性可能会对性能产生影响。若要优化代码以提高性能,必须避免应用程序大量触发垃圾回收器的情况。本节概述了影响应用程序触发垃圾回收器时间的一些常见问题和工作流。

  • 临时拨款
  • 可重用对象池
  • 重复字符串串联
  • 返回数组值的方法
  • 收集和数组重用
  • 闭包和匿名方法
  • 拳击
  • 数组值统一 API
  • 空数组重用

临时分配

应用程序通常会在每个帧中将临时数据分配给托管堆;但是,这可能会影响应用程序的性能。例如:

  • 如果程序每帧分配一千字节 (1KB) 的临时内存,并且它以每秒 60 帧的速度运行,则它必须每秒分配 60 KB 的临时内存。在一分钟内,这会增加垃圾回收器可用的 3.6 MB 内存。
  • 每秒调用一次垃圾回收器会对性能产生负面影响。如果垃圾回收器每分钟只运行一次,它必须清理分布在数千个单独分配中的 3.6 兆字节,这可能会导致大量的垃圾回收时间。
  • 加载操作会影响性能。如果您的应用程序在繁重的资源加载操作期间生成了大量临时对象,并且 Unity 在操作完成之前引用了这些对象,则垃圾回收器无法释放这些临时对象。这意味着托管堆需要扩展,即使 Unity 在短时间内释放了它包含的许多对象。

要解决此问题,您应该尝试尽可能减少频繁管理的堆分配量:理想情况下是每帧 0 字节,或者尽可能接近零。

可重用对象池

在很多情况下,您可以减少应用程序创建和销毁对象的次数,以避免生成垃圾。游戏中有一些类型的对象,例如射弹,即使只有少数物体同时出现,它们也可能一遍又一遍地出现。在这种情况下,您可以重用对象,而不是销毁旧对象并用新对象替换它们。

例如,每次发射预制件时都实例化预制件中的新弹丸对象并不是最佳选择。相反,您可以计算在游戏过程中可以同时存在的最大射弹数量,并在游戏首次进入游戏场景时实例化正确大小的对象数组。为此:

  • 首先将所有射弹游戏对象设置为非活动状态。
  • 发射射弹时,搜索阵列以查找阵列中的第一个非活动射弹,将其移动到所需位置并将游戏对象设置为活动状态。
  • 当射弹被摧毁时,再次将游戏对象设置为非活动状态。

可以使用 ObjectPool 类,该类提供了此可重用对象池技术的实现。

下面的代码显示了基于堆栈的对象池的简单实现。如果您使用的是不包含对象池 API 的旧版 Unity,或者如果您想查看有关如何实现自定义对象池的示例,您可能会发现参考它很有用。

using System.Collections.Generic;
using UnityEngine;

public class ExampleObjectPool : MonoBehaviour {

   public GameObject PrefabToPool;
   public int MaxPoolSize = 10;
  
   private Stack<GameObject> inactiveObjects = new Stack<GameObject>();
  
   void Start() {
       if (PrefabToPool != null) {
           for (int i = 0; i < MaxPoolSize; ++i) {
               var newObj = Instantiate(PrefabToPool);
               newObj.SetActive(false);
               inactiveObjects.Push(newObj);
           }
       }
   }

   public GameObject GetObjectFromPool() {
       while (inactiveObjects.Count > 0) {
           var obj = inactiveObjects.Pop();
          
           if (obj != null) {
               obj.SetActive(true);
               return obj;
           }
           else {
               Debug.LogWarning("Found a null object in the pool. Has some code outside the pool destroyed it?");
           }
       }
      
       Debug.LogError("All pooled objects are already in use or have been destroyed");
       return null;
   }
  
   public void ReturnObjectToPool(GameObject objectToDeactivate) {
       if (objectToDeactivate != null) {
           objectToDeactivate.SetActive(false);
           inactiveObjects.Push(objectToDeactivate);
       }
   }
}

重复字符串串联

C# 中的字符串是不可变的引用类型。引用类型意味着 Unity 将它们分配到托管堆上,并受垃圾回收的约束。不可变意味着一旦创建了字符串,就无法更改它;任何修改字符串的尝试都会导致一个全新的字符串。因此,应尽可能避免创建临时字符串。

请考虑以下示例代码,它将字符串数组合并为单个字符串。每次在循环内添加新字符串时,结果变量的先前内容都会变得多余,并且代码会分配一个全新的字符串。

// Bad C# script example: repeated string concatenations create lots of
// temporary strings.
using UnityEngine;

public class ExampleScript : MonoBehaviour {
    string ConcatExample(string[] stringArray) {
        string result = "";

        for (int i = 0; i < stringArray.Length; i++) {
            result += stringArray[i];
        }

        return result;
    }

}

如果输入 stringArray 包含 则此方法在堆上为以下字符串生成存储:{ "A", "B", "C", "D", "E" }

  • "A"
  • "AB"
  • "ABC"
  • "ABCD"
  • "ABCDE"

在此示例中,您只需要最后一个字符串,其他字符串是冗余分配。输入数组中的项越多,此方法生成的字符串就越多,每个字符串都比上一个长。

如果你需要将很多字符串连接在一起,那么你应该使用Mono库的System.Text.StringBuilder类。上述脚本的改进版本如下所示:

// Good C# script example: StringBuilder avoids creating temporary strings,
// and only allocates heap memory for the final result string.
using UnityEngine;
using System.Text;

public class ExampleScript : MonoBehaviour {
    private StringBuilder _sb = new StringBuilder(16);

    string ConcatExample(string[] stringArray) {
        _sb.Clear();

        for (int i = 0; i < stringArray.Length; i++) {
            _sb.Append(stringArray[i]);
        }

        return _sb.ToString();
    }
}

重复串联不会降低太多性能,除非频繁调用它,例如在每次帧更新时。下面的示例在每次调用 Update 时分配新字符串,并生成垃圾回收器必须处理的连续对象流:

// Bad C# script example: Converting the score value to a string every frame
// and concatenating it with "Score: " generates strings every frame.
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

为了防止这种连续的垃圾回收要求,您可以配置代码,以便仅在分数更改时更新文本:

// Better C# script example: the score conversion is only performed when the
// score has changed
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;
    
    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}

为了进一步改进这一点,您可以将乐谱标题(表示的部分)和乐谱显示在两个不同的对象中,这意味着不需要字符串串联。代码仍必须将分数值转换为字符串,但这是对以前版本的改进:"Score: "UI.Text

// Best C# script example: the score conversion is only performed when the
// score has changed, and the string concatenation has been removed
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
   public Text scoreBoardTitle;
   public Text scoreBoardDisplay;
   public string scoreText;
   public int score;
   public int oldScore;

   void Start() {
       scoreBoardTitle.text = "Score: ";
   }

   void Update() {
       if (score != oldScore) {
           scoreText = score.ToString();
           scoreBoardDisplay.text = scoreText;
           oldScore = score;
       }
   }
}

返回数组值的方法

有时,编写一个新数组的方法可能很方便,该方法用值填充数组,然后返回它。但是,如果重复调用此方法,则每次都会分配新的内存。

下面的示例代码演示了一个方法的示例,该方法在每次调用数组时都会创建一个数组:

// Bad C# script example: Every time the RandomList method is called it
// allocates a new array
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];
        
        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }
        
        return result;
    }
}

避免每次都分配内存的一种方法是利用数组是引用类型的事实。可以修改作为参数传递到方法中的数组,结果在方法返回后保留。为此,您可以按如下方式配置示例代码:

// Good C# script example: This version of method is passed an array to fill
// with random values. The array can be cached and re-used to avoid repeated
// temporary allocations
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}

此代码将数组的现有内容替换为新值。此工作流要求调用代码执行数组的初始分配,但调用函数时不会生成任何新的垃圾。然后,下次调用此方法时,可以重复使用数组并用随机数重新填充数组,而无需在托管堆上进行任何新分配。

收集和数组重用

使用 System.Collection 命名空间中的数组或类(例如,列表或字典)时,重用或池化分配的集合或数组会非常有效。集合类公开一个 Clear 方法,该方法会消除集合的值,但不会释放分配给集合的内存。

如果要为复杂计算分配临时“帮助程序”集合,这将非常有用。下面的代码示例演示了这一点:

// Bad C# script example. This Update method allocates a new List every frame.
void Update() {

    List<float> nearestNeighbors = new List<float>();

    findDistancesToNearestNeighbors(nearestNeighbors);

    nearestNeighbors.Sort();

    // … use the sorted list somehow …
}

此示例代码每帧分配一次最近邻居列表,以收集一组数据点。

您可以将此 List 从方法中提升到包含类中,这样您的代码就不需要在每一帧中分配一个新的 List:

// Good C# script example. This method re-uses the same List every frame.
List<float> m_NearestNeighbors = new List<float>();

void Update() {

    m_NearestNeighbors.Clear();

    findDistancesToNearestNeighbors(NearestNeighbors);

    m_NearestNeighbors.Sort();

    // … use the sorted list somehow …
}

此示例代码在多个帧中保留并重用列表的内存。该代码仅在列表需要扩展时分配新内存。

闭包和匿名方法

通常,应尽可能避免在 C# 中使用闭包。应尽量减少在对性能敏感的代码中使用匿名方法和方法引用,尤其是在基于每帧执行的代码中。

C# 中的方法引用是引用类型,因此它们在堆上分配。这意味着,如果将方法引用作为参数传递,则可以轻松创建临时分配。无论传递的方法是匿名方法还是预定义方法,都会发生此分配。

此外,将匿名方法转换为闭包时,将闭包传递给方法所需的内存量会大大增加。

下面是一个代码示例,其中需要按特定顺序对随机数列表进行排序。这使用匿名方法来控制列表的排序顺序,并且排序不会创建任何分配。

// Good C# script example: using an anonymous method to sort a list. 
// This sorting method doesn’t create garbage
List<float> listOfNumbers = getListOfRandomNumbers();


listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/2)) 

);

要使此代码段可重用,您可以将常量 2 替换为局部范围内的变量:

// Bad C# script example: the anonymous method has become a closure,
// and now allocates memory to store the value of desiredDivisor
// every time it is called.
List<float> listOfNumbers = getListOfRandomNumbers();


int desiredDivisor = getDesiredDivisor();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/desiredDivisor))

);

匿名方法现在需要访问超出其范围的变量的状态,因此该方法已成为闭包。必须将变量传递到闭包中,以便闭包的代码可以使用它。desiredDivisor

为了确保将正确的值传入闭包,C# 会生成一个匿名类,该类可以保留闭包所需的外部作用域变量。当闭包传递给 Sort 方法时,将实例化此类的副本,并使用所需的除数整数的值初始化副本。

执行闭包需要实例化其生成的类的副本,并且所有类都是 C# 中的引用类型。因此,执行闭包需要在托管堆上分配对象。

装箱 (Boxing)

装箱是 Unity 项目中意外临时内存分配的最常见来源之一。当值类型的变量自动转换为引用类型时,就会发生这种情况。当将基元值类型变量(如 int 和 float)传递给对象类型方法时,最常发生这种情况。在为 Unity 编写 C# 代码时,应避免装箱。

在此示例中,x 中的整数被装箱,以便可以将其传递给方法,因为对象上的方法要求将对象传递给它。object.EqualsEquals

int x = 1;

object y = new object();

y.Equals(x);

C# IDE 和编译器不会发出有关装箱的警告,即使装箱会导致意外的内存分配。这是因为 C# 假定小型临时分配由分代垃圾回收器和对分配大小敏感的内存池有效处理。

虽然 Unity 的分配器确实使用不同的内存池进行小型和大型分配,但 Unity 的垃圾回收器不是分代的,因此它无法有效地清除装箱生成的小型、频繁的临时分配。

识别装箱

装箱在 CPU 跟踪中显示为对少数方法之一的调用,具体取决于正在使用的脚本后端。它们采用以下形式之一,其中 是类或结构的名称,并且是许多参数:<example class>…

<example class>::Box(…)
Box(…)
<example class>_Box(…)

若要查找装箱,还可以搜索反编译器或 IL 查看器的输出,例如 ReSharper 中内置的 IL 查看器工具或 dotPeek 反编译器。IL 指令是 。box

数组值统一 API

意外分配数组的一个微妙原因是重复访问返回数组的 Unity API。所有返回数组的 Unity API 在每次访问时都会创建数组的新副本。如果代码访问数组值 Unity API 的频率超过必要频率,则可能会对性能产生不利影响。

例如,下面的代码不必要地为每个循环迭代创建顶点数组的四个副本。每次访问属性时都会进行分配。.vertices

// Bad C# script example: this loop create 4 copies of the vertices array per iteration
void Update() {
    for(int i = 0; i < mesh.vertices.Length; i++) {
        float x, y, z;

        x = mesh.vertices[i].x;
        y = mesh.vertices[i].y;
        z = mesh.vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

您可以将此代码重构为单个数组分配,而不考虑循环迭代的次数。为此,请将代码配置为在循环之前捕获顶点数组:

// Better C# script example: create one copy of the vertices array
// and work with that
void Update() {
    var vertices = mesh.vertices;

    for(int i = 0; i < vertices.Length; i++) {

        float x, y, z;

        x = vertices[i].x;
        y = vertices[i].y;
        z = vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

更好的方法是维护一个顶点列表,该列表在帧之间缓存和重用,然后在需要时使用 Mesh.GetVertices 填充它。

// Best C# script example: create one copy of the vertices array
// and work with that.
List<Vector3> m_vertices = new List<Vector3>();

void Update() {
    mesh.GetVertices(m_vertices);

    for(int i = 0; i < m_vertices.Length; i++) {

        float x, y, z;

        x = m_vertices[i].x;
        y = m_vertices[i].y;
        z = m_vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

虽然访问一次属性的 CPU 性能影响不高,但在紧密循环中重复访问会产生 CPU 性能热点。重复访问会扩展托管堆。

此问题在移动设备上很常见,因为 Input.touches API 的行为与上述类似。项目通常包含类似于以下内容的代码,其中每次访问属性时都会进行分配。.touches

// Bad C# script example: Input.touches returns an array every time it’s accessed
for ( int i = 0; i < Input.touches.Length; i++ ) {
   Touch touch = Input.touches[i];

    // …
}

为了改善这一点,您可以将代码配置为将数组分配提升到循环条件之外:

// Better C# script example: Input.touches is only accessed once here
Touch[] touches = Input.touches;

for ( int i = 0; i < touches.Length; i++ ) {

   Touch touch = touches[i];

   // …
}

下面的代码示例将前面的示例转换为免分配的触摸 API:

// BEST C# script example: Input.touchCount and Input.GetTouch don’t allocate at all.
int touchCount = Input.touchCount;

for ( int i = 0; i < touchCount; i++ ) {
   Touch touch = Input.GetTouch(i);

   // …
}

注意:属性访问 () 保留在循环条件之外,以节省调用属性的 get 方法对 CPU 的影响。Input.touchCount

替代非分配 API

某些 Unity API 具有不会导致内存分配的替代版本。您应该尽可能使用这些。下表显示了一小部分常见的分配 API 及其非分配替代方法。该列表并不详尽,但应指示要注意的 API 类型。

分配接口非分配 API 替代方案
物理.射线投射全部Physics.RaycastNonAlloc
动画器参数Animator.parameterCount 和 Animator.GetParameter
渲染器.共享材质Renderer.GetSharedMaterials

空数组重用

当数组值方法需要返回空集时,某些开发团队更喜欢返回空数组而不是 null。这种编码模式在许多托管语言中很常见,尤其是 C# 和 Java。

通常,从方法返回零长度数组时,返回零长度数组的预分配静态实例比重复创建空数组更有效。

此文由3D建模学习工作室整理翻译,转载请注明出处!

上一篇:Unity3D:禁用垃圾回收 (mvrlink.com)

下一篇:Unity3D:应用程序性能分析 (mvrlink.com)

NSDT场景编辑器 | NSDT 数字孪生 | GLTF在线编辑器 | 3D模型在线转换 | UnrealSynth虚幻合成数据生成器 | 3D模型自动纹理化工具
2023 power by nsdt©鄂ICP备2023000829号