C#引用类型管理
原文链接:Unity - Manual: Reference type management
引用类型管理
由于托管代码中所有非空引用类型对象和所有装箱后的值类型对象都必须在托管堆上分配,这些对象可能会导致应用程序的性能问题。以下部分概述了可用于提高代码性能的方法。
避免重复的字符串拼接
C# 中的字符串是不可变的引用类型。Unity 在托管堆上分配引用类型,并且它们会受到垃圾回收的影响。由于字符串是不可变的,一旦创建就无法更改,因此请尽可能避免创建临时字符串。
以下示例代码将一个字符串数组组合成单个字符串。每次在循环中添加新字符串时,result 变量的先前内容就变得冗余,代码会分配一个全新的字符串。
// 糟糕的 C# 脚本示例:重复的字符串拼接会创建大量临时字符串
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 类。改进后的脚本如下所示:
// 良好的 C# 脚本示例:StringBuilder 避免创建临时字符串,
// 仅为最终结果字符串分配堆内存
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 时分配新字符串,并产生垃圾收集器必须处理的连续对象流:
// 糟糕的 C# 脚本示例:每帧将分数值转换为字符串
// 并与 "Score: " 拼接会每帧生成字符串
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;
}
}
为防止这种持续的垃圾回收需求,您可以配置代码,使文本仅在分数变化时更新:
// 更好的 C# 脚本示例:仅在分数发生变化时才执行分数转换
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 对象中,这意味着无需进行字符串拼接。代码仍然必须将分数值转换为字符串,但这比之前的版本有所改进:
// 最佳的 C# 脚本示例:仅在分数发生变化时才执行分数转换,
// 并移除了字符串拼接
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;
}
}
}
为了获得更优化的版本,您可以使用 UGUI 包中 TMPro.TMP_Text 类的 SetText(Char []) 方法。SetText 方法允许您使用和重用字符数组来逐位构建分数,并更新字符数组中的值,而无需使用字符串。
有关在 C# 中处理字符串的最佳实践的更多信息,请参阅 Microsoft 关于 .NET 中字符串比较的最佳实践 的文档。
避免闭包和匿名方法
通常,尽可能避免在 C# 中使用闭包。您应该最小化在性能敏感代码中使用匿名方法和方法引用,特别是在每帧执行的基础代码中。
C# 中的方法引用是引用类型,因此它们在托管堆上分配。这意味着,如果您将方法引用作为参数传递,可能会创建临时分配。无论您传递的方法是匿名方法还是预定义方法,都会发生这种分配。
此外,当您将匿名方法转换为闭包时,将闭包传递给方法所需的内存量会大幅增加。
以下是一个代码示例,其中需要以特定顺序对随机数列表进行排序。这使用匿名方法来控制列表的排序顺序,且排序不会创建任何分配。
// 良好的 C# 脚本示例:使用匿名方法对列表进行排序
// 此排序方法不会产生垃圾
List<float> listOfNumbers = getListOfRandomNumbers();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/2))
);
为了使此代码片段可重用,您可以将常量 2 替换为本地作用域的变量:
// 糟糕的 C# 脚本示例:匿名方法已成为闭包,
// 现在每次调用时都会分配内存来存储 desiredDivisor 的值
List<float> listOfNumbers = getListOfRandomNumbers();
int desiredDivisor = getDesiredDivisor();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/desiredDivisor))
);
匿名方法现在需要访问其范围之外的变量状态,因此该方法已成为闭包。desiredDivisor 变量必须传递到闭包中,以便闭包的代码可以使用它。
为确保将正确的值传递到闭包中,C# 生成一个匿名类,该类可以保存闭包需要的外部作用域变量。当闭包传递给 Sort 方法时,会实例化此类的副本,并使用 desiredDivisor 整数的值初始化该副本。
执行闭包需要实例化其生成类的副本,而所有类都是 C# 中的引用类型。因此,执行闭包需要在托管堆上分配对象。
避免将值类型转换为引用类型
当值类型变量自动转换为引用类型时,这称为装箱(Boxing)。装箱是 Unity 项目中最常见的意外临时内存分配来源之一。这种情况最常发生在将原始值类型变量(如 int 和 float)传递给对象类型方法时。
在此示例中,x 中的整数被装箱,以便可以传递给 object.Equals 方法,因为对象上的 Equals 方法要求将对象传递给它。
int x = 1;
object y = new object();
y.Equals(x);
C# IDE 和编译器不会就装箱发出警告,即使装箱会导致意外的内存分配。这是因为 C# 假设小型临时分配可以由分代垃圾收集器和按分配大小敏感的内存池有效处理。
虽然 Unity 的托管内存分配器确实对小型和大型分配使用不同的内存池,但 Unity 的垃圾收集器不是分代的,因此它无法有效清除装箱生成的小型、频繁的临时分配。
识别装箱
装箱在 CPU 跟踪中显示为对以下少数方法的调用之一,具体取决于使用的脚本后端。这些方法采用以下形式之一,其中 是类或结构的名称,… 是多个参数:
<example class>::Box(…)
Box(…)
<example class>_Box(…)
要查找装箱,您还可以搜索反编译器或 IL 查看器的输出,例如 ReSharper 内置的 IL 查看器工具或 dotPeek 反编译器。IL 指令是 box。
避免 params 修饰符
使用 params 修饰符列出可选参数的方法会为您传递给它们的参数分配数组。如果可用,请使用不依赖该修饰符的这些方法的重载。

浙公网安备 33010602011771号