Unity3D性能优化最佳实践 字串和Text

2017年04月06日 17:21 0 点赞 0 评论 更新于 2025-11-21 21:20

字符串与Text

在Unity项目中,字符串和Text是常见的影响性能的因素之一。在C#里,所有的字符串都是不可变(Immutable)的。对字符串进行任何操作都会导致分配一个全新的字符串,这一操作的开销较大。若重复拼接大字符串、拼接大量字符串,或是在多次循环中进行字符串拼接,都会引发性能问题。

此外,由于拼接N个字符串需要分配N - 1个中间字符串,这种字符串拼接操作容易造成内存压力。

对于那些必须在每帧或循环内进行字符串拼接的项目,建议使用StringBuilder来执行拼接操作。实例化后的StringBuilder可以重复使用,从而将内存开销降至最低。微软有一份关于C#字符串的最佳实践文档,可点击此处查看

强制语系转化和序数比较法

许多与字符串相关的程序存在效率问题,这往往是由于误用了默认的较慢的字符串API。这些API是为商业应用而设计的,它们会尝试分析处理来自不同文化和语言规则的字符串。

例如,以下代码在US - English语言环境下执行时会返回true,但在许多欧洲语言环境下会返回false

// 注意,从Unity 5.3和5.4开始,Unity脚本执行时默认使用US English (en - US)环境
String.Equals("encyclopedia", "encyclopædia");

对于大多数Unity项目而言,这种处理是完全不必要的。使用序数比较(Ordinal)会快约10倍,它采用C和C++程序员惯用的比对方式,即逐位比对,而非找出位构成的字符并判断两个字符在当前语系中是否等价。

要改用序数字符串比对方法,只需在原本的String.Equals方法后加上StringComparison.Ordinal参数,示例如下:

myString.Equals(otherString, StringComparison.Ordinal);

效能低落的内建字符串API

除了改用序数比对法之外,C#中有些内建的字符串API效率不佳,其中包括String.FormatString.StartsWithString.EndsWithString.Format较难被替换,但另外两个效率不高的比对方法较易优化。

虽然微软建议在不需要考虑语系的字符串比对中使用StringComparison.Ordinal,但从Unity性能分析结果来看,使用序数比对的提升与手动编写比对方法相比,效果甚微。

String.StartsWithString.EndsWith都可以手动替换为以下简单示例:

public static bool CustomEndsWith(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 && a.Length >= b.Length) || (ap < 0 && b.Length >= a.Length);
}

public static bool CustomStartsWith(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 && aLen >= bLen) || (ap == aLen && bLen >= aLen);
}

正则表达式

正则表达式是一种强大的字符串比对和操作方法,但它的性能代价可能较高。由于C#函数库的正则表达式特性,即使是简单的isMatch查询,也会在幕后分配大量临时数据结构。除了在初始化期间,这种短暂的内存峰值应该被视为不可接受的。

因此,如果需要使用正则表达式,强烈建议不要使用接受正则表达式字符串作为参数的静态Regex.MatchRegex.Replace方法。这些方法会当场编译正则表达式,用过即丢。

以下示例代码看似无害:

Regex.Match(myString, "foo");

但每次执行都会产生5KB的垃圾。可以通过简单修改来解决:

var myRegExp = new Regex("foo");
myRegExp.Match(myString);

此示例每次调用myRegExp.Match“仅”产生320字节的垃圾,虽然仍有一定代价,但相比5KB已大幅改善。

因此,如果正则表达式是固定的字符串文本,应将其作为Regex对象构造函数的第一个参数进行预编译,并重复使用这些预编译的正则表达式。

XML、JSON和其他大型文字的解析

解析文本通常是加载时最耗费性能的操作之一。在某些情况下,解析文本所花费的时间可能超过加载和实例化资源的时间。

其背后的原因取决于所使用的解析器(parser)。C#的内建XML解析器非常灵活,但正因如此,它没有针对特定数据结构进行优化。

许多第三方解析器基于反射(Reflection)构建。虽然反射在开发中是一个很好的解决方案(因为它能让解析器快速适应数据架构变化),但它的速度众所周知地慢。

Unity引入了内建的JSONUtilityAPI解决方案,它为Unity序列化系统(Serialization system)提供了读写JSON的接口。从各方面的性能分析来看,它甚至比纯C# JSON解析器更快。但它与Unity的序列化系统其他接口有相同的限制:在用户改写之前,无法序列化许多复杂的数据结构,如Dictionary(可参考ISerializationCallbackReceiver接口的说明,了解如何在Unity的序列化系统上处理这些数据结构)。

如果遇到上述资料解析产生的性能问题,可以考虑以下三种替代方案:

方案一:在打包时解析

要避免过长的文本解析时间成本,最佳方法是在运行时不进行文本解析操作。通常可以通过某些流程将文件数据预先“烘焙”成二进制格式。

大多数选择此方案的开发者会将数据移到继承自ScriptableObject的类中,然后使用AssetBundles进行打包。关于ScriptableObjects的详细讨论,可参考Richard Fine在Unite 2016的演讲。

此方案能提供最佳性能,但仅适用于不需要动态生成的数据,例如游戏设计参数等固定内容。

方案二:分解(Split)和延迟(Lazy)载入

第二种方案是将需要解析的数据分成小块。这样,解析的性能成本可以分摊到多个帧上。甚至可以根据需求,仅解析客户端需要显示的特定部分并加载这些部分。

例如,对于一个闯关游戏,无需一次性序列化并加载所有关卡数据。如果将关卡数据分割为每关一个独立的资源文件,甚至每关再分割成不同区域进行打包,就可以在玩家接近时再解析下一区域。

虽然听起来简单,但实际操作中需要在构建工具上花费大量精力,并且可能需要重新定义数据结构。

方案三:线程

对于完全解析为纯C#对象且无需与Unity API进行交互的资料,可以将解析操作移到工作线程(Worker threads)中进行。

此方案在多核平台上具有优势(目前iOS设备最多两核,Android设备大多为二到四核,这项技术更适用于资源更丰富的计算机和家用主机),但需要谨慎编写程序,以避免出现死锁(Deadlocks)和竞态条件(Race conditions)问题。

选择使用线程的开发者可以使用内建的C# ThreadThreadPool类来管理工作线程,以及标准的C#同步(Synchronization)类。

作者信息

孟子菇凉

孟子菇凉

共发布了 3994 篇文章