处理字符串和文本是 Unity 项目中常见的性能问题来源。在 C# 中,所有字符串都是不可变的您无法更改不可变(只读)包的内容。这与可变相反。大多数包都是不可变的,包括从包注册表或通过 Git URL 下载的包。
请参阅术语表。任何对字符串的操作都会导致分配一个全新的字符串。这相对来说比较昂贵,并且当对大型字符串、大型数据集或在紧密循环中执行时,重复的字符串连接可能会演变成性能问题。
此外,由于 N 个字符串连接需要分配 N-1 个中间字符串,因此串行连接也可能是托管内存压力的主要原因。
对于必须在紧密循环中或在每一帧期间连接字符串的情况,请使用 StringBuilder 来执行实际的连接操作。StringBuilder 实例还可以重复使用,以进一步最大限度地减少不必要的内存分配。
Microsoft 提供了使用 C# 中字符串的最佳实践列表,您可以在 MSDN 网站上找到该列表:msdn.microsoft.com。
在与字符串相关的代码中经常发现的核心性能问题之一是意外使用了缓慢的默认字符串 API。这些 API 是为商业应用程序构建的,并试图处理来自许多不同文化和语言规则的字符串,这些规则与文本中找到的字符有关。
例如,以下示例代码在 US-English 区域设置下运行时返回 true,但在许多欧洲区域设置下返回 false。
注意:从 Unity 5.3 和 5.4 开始,Unity 的脚本运行时始终在 US English (en-US) 区域设置下运行
String.Equals("encyclopedia", “encyclopædia”);
对于大多数 Unity 项目来说,这完全没有必要。使用序数比较类型大约快十倍,该类型以 C 和 C++ 程序员熟悉的方式比较字符串:简单地比较字符串的每个顺序字节,而不考虑该字节表示的字符。
切换到序数字符串比较就像向 String.Equals
的最后一个参数提供 StringComparison.Ordinal
一样简单。
myString.Equals(otherString, StringComparison.Ordinal);
除了切换到序数比较之外,某些 C# String
API 已知效率极低。其中包括 String.Format
、String.StartsWith
和 String.EndsWith
。String.Format
难以替换,但低效的字符串比较方法可以很容易地优化掉。
虽然 Microsoft 建议将 StringComparison.Ordinal
传递到任何不需要调整本地化的字符串比较中,但 Unity 基准测试表明,与自定义实现相比,这样做产生的影响相对较小。
方法 | 10 万个短字符串的时间(毫秒) |
---|---|
String.StartsWith ,默认区域设置 |
137 |
String.EndsWith ,默认区域设置 |
542 |
String.StartsWith ,序数 |
115 |
String.EndsWith ,序数 |
34 |
自定义 StartsWith 替换 |
4.5 |
自定义 EndsWith 替换 |
4.5 |
String.StartsWith
和 String.EndsWith
都可以用简单的硬编码版本替换,类似于下面附带的示例。
public static bool CustomEndsWith(this string a, string b)
{
int ap = a.Length - 1;
int bp = b.Length - 1;
while (ap >= 0 && bp >= 0 && a [ap] == b [bp])
{
ap--;
bp--;
}
return (bp < 0);
}
public static bool CustomStartsWith(this string a, string b)
{
int aLen = a.Length;
int bLen = b.Length;
int ap = 0; int bp = 0;
while (ap < aLen && bp < bLen && a [ap] == b [bp])
{
ap++;
bp++;
}
return (bp == bLen);
}
虽然正则表达式是匹配和操作字符串的强大方法,但它们可能非常占用性能。此外,由于 C# 库对正则表达式的实现,即使是简单的布尔 IsMatch
查询也会在“幕后”分配大型瞬态数据结构。这种瞬态托管内存 churn 应该被认为是不可接受的,除非在初始化期间。
如果需要正则表达式,强烈建议不要使用静态 Regex.Match
或 Regex.Replace
方法,这些方法接受正则表达式作为字符串参数。这些方法会即时编译正则表达式,并且不会缓存生成的物体。
此示例代码是一个无害的一行代码。
Regex.Match(myString, "foo");
但是,每次执行它时,它都会生成 5 千字节的垃圾。简单的重构可以消除大部分垃圾。
var myRegExp = new Regex("foo");
myRegExp.Match(myString);
在此示例中,每次调用 myRegExp.Match
“仅”导致 320 字节的垃圾。虽然对于简单的匹配操作来说这仍然很昂贵,但与前面的示例相比,这是一个相当大的改进。
因此,如果正则表达式是不变的字符串文字,则通过将它们作为 Regex 对象构造函数的第一个参数预编译它们会更有效率。然后应重复使用这些预编译的 Regex。
解析文本通常是加载时最繁重的操作之一。有时,解析文本所花费的时间会超过加载和实例化资源所花费的时间。
其背后的原因取决于使用的特定解析器。C# 的内置 XML 解析器非常灵活,但因此,它无法针对特定数据布局进行优化。
许多第三方解析器是基于反射构建的。虽然反射在开发过程中是一个极好的选择(因为它允许解析器快速适应不断变化的数据布局),但它却臭名昭著地缓慢。
Unity 通过其内置的 JSONUtility API 引入了一种部分解决方案,该 API 提供了与 Unity 序列化系统交互的接口,该接口读取/发出 JSON。在大多数基准测试中,它比纯 C# JSON 解析器更快,但它与 Unity 序列化系统的其他接口具有相同的限制——它无法序列化许多复杂的数据类型,例如字典,而无需其他代码。
注意:请参阅 ISerializationCallbackReceiver 接口,了解在 Unity 序列化过程中添加将复杂数据类型转换为/来自复杂数据类型所需的其他处理的一种方法。
当遇到由于文本数据解析而引起的性能问题时,请考虑三种替代解决方案。
避免文本解析成本的最佳方法是在运行时完全消除文本解析。一般来说,这意味着通过某种构建步骤将文本数据“烘焙”成二进制格式。
大多数选择此路线的开发人员将其数据移动到某种 ScriptableObject 派生类层次结构,然后通过 AssetBundles 分发数据。有关使用 ScriptableObjects 的精彩讨论,请参阅 YouTube 上 Richard Fine 在 Unite 2016 上的演讲。
此策略提供了最佳性能,但仅适用于不需要动态生成的数据。它最适合游戏设计参数和其他内容。
第二种可能性是将必须解析的数据拆分成更小的块。拆分后,解析数据的成本可以在几帧内分摊。在理想情况下,确定呈现给用户所需体验所需的数据的特定部分,并仅加载这些部分。
在一个简单的示例中:如果项目是一个平台游戏,则无需将所有关卡的数据一起序列化成一个巨大的 blob。如果数据被拆分成每个关卡的单个资源,并且可能将关卡分割成区域,则可以在玩家接近时解析数据。
虽然这听起来很容易,但在实践中,它需要在工具代码上进行大量投资,并且可能需要重新组织数据结构。
对于完全解析为纯 C# 对象并且不需要与 Unity API 交互的数据,可以将解析操作移动到工作线程。
此选项在具有大量内核的平台上可能非常强大。但是,它需要仔细编程以避免创建死锁和竞争条件。
注意:iOS 设备最多只有 2 个内核。大多数 Android 设备有 2 到 4 个内核。此技术在构建独立和控制台构建目标时更受关注。
选择实现线程的项目使用内置的 C# Thread 和 ThreadPool 类(请参阅 msdn.microsoft.com)来管理其工作线程,以及标准的 C# 同步类。