字符串与 Text
字符串和 Text 是 Unity 项目里常见到影响效能的原因之一。在 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.Format、String.StartsWith 和 String.EndsWith。String.Format 很难被替换,但其他两个效率不好的比对方法比较容易优化。
虽然微软的建议是一样将 StringComparison.Ordinal 用在这些不需要考虑语系的字符串比对上,但从 Unity 效能分析结果来看会发现有没有用序数比对的进步跟跟自己手写一个比对比起来算是九牛一毛。
String.StartsWith 和 String.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.Match 或 Regex.Replace 方法。这些方法都是当场编译正规表示法后用过即丢。
底下这个范例程序看起来人畜无害。
Regex.Match(myString, "foo");
但每次执行都会产生 5k 的垃圾。可以简单修改一下来解决:
var myRegExp = new Regex("foo");
myRegExp.Match(myString);
这个范例每次呼叫 myRegExp.Match“只会”产生 320byte 的垃圾,虽然还是有相当代价,但比起 5k 是好多了。
因此如果正规表示法是不变的字符串文字,把它们作为 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# Thread 和 ThreadPool 类别来管理工作线程,还有标准 C# 同步(Synchronization)类别。