字符串&文本
处理字符串和文本是Unity项目中常见的性能问题来源。c#中所有的 string 都是不可变的,任何对 string 的操作结果都会分配一个新 string,这是相对昂贵的,并且当在大字符串、大数据集或密集循环中执行时,重复的字符串拼接可能会导致性能问题。
此外,由于 N 个字符串连接需要分配 N-1 个中间字符串,串行连接也可能是托管内存压力的主要原因。
对于必须在紧密循环中或在每一帧中连接字符串的情况,使用 StringBuilder 来执行实际的连接操作。StringBuilder 实例也可以被重用,以进一步减少不必要的内存分配。
Microsoft 维护了c#中 string 的最佳实践:https://msdn.microsoft.com/en-us/library/dd465121(v=vs.110).aspx
ordinal comparisons
区域强制转换和顺序比较,在与字符串相关的代码中经常发现的一个核心性能问题是无意中使用缓慢的默认字符串api。这些api是为业务应用程序构建的,并试图处理来自许多不同文化和语言规则的字符串,这些字符串与文本中找到的字符有关。
使用 StringComparison.Ordinal 比较大约要快10倍,它以C和c++程序员熟悉的方式比较字符串:简单地比较字符串的每个 byte ,而不考虑该字节所表示的字符。
1 | myString.Equals(otherString, StringComparison.Ordinal); |
Inefficient string APIs
低效的内置字符串api,除了切换 StringComparison.Ordinal 比较之外,某些c# string api的效率非常低。其中包括:String.Format , String.StartsWith , String.EndsWith 。String.Format 很难替换,但是低效的字符串 Equals 方法被简单地优化掉了。
而 Microsoft 的建议是传递StringComparison。对于任何不需要针对本地化进行调整的字符串比较,Unity基准测试显示,与自定义实现相比,这种影响相对较小。
1 | public static bool CustomEndsWith(this string a, string b) |
其他方案:https://github.com/Cysharp/ZString
Regular Expressions
虽然正则表达式是一种强大的匹配和操作字符串的方式,但它们可能非常消耗性能。此外,由于c#库实现了正则表达式,即使是简单的布尔 IsMatch 查询也会在“底层”分配大量的临时数据结构。这种暂时的托管内存混乱应该被认为是不可接受的,除非在初始化期间。
若正则是必要的,强烈不推荐使用接受 string 的正则参数的 Regex.Match , Regex.Replace 方法,这些方法动态编译正则表达式,不缓存生成的正则对象。
例如:
1 | Regex.Match(myString, "foo"); |
然而,每次执行都会生成 5kb GC,简单重构以消除大量GC:
1 | // 仅320b |
XML, JSON…
解析文本通常是加载时最繁重的操作之一。有时,解析文本所花费的时间可能超过加载和实例化资产所花费的时间。这背后的原因取决于所使用的特定解析器。c#内置的XML解析器非常灵活,但其结果是无法针对特定的数据布局进行优化。
许多第三方解析器都是基于 reflection 构建的。虽然 reflection 在开发过程中是一个很好的选择(因为它允许解析器快速适应不断变化的数据布局),但它的速度很慢。
Unity通过其内置的 JsonUtility API引入了部分解决方案,该API为Unity的 serialization system 提供了一个读取/发送JSON的接口。
在大多数基准测试中,它比纯c# JSON解析器更快,但它与Unity序列化系统的其他接口有相同的限制:
- 不支持 Dictionary:需手动转换为列表或自定义类。
- 不支持接口/抽象类:若类中包含接口类型的字段(如IList
),序列化会失败。 - 不支持泛型集合以外的复杂类型:如HashSet、Queue、Stack等集合类型无法直接处理。
- 不支持 null 值。
- 必须依赖
[Serializable]特性。
注意:请参阅 isserializationcallbackreceiver 接口,了解在Unity的序列化过程中添加必要的额外处理来转换复杂数据类型的一种方法。
当遇到由文本数据解析引起的性能问题时,可以考虑三种备选解决方案:
Parse at build time
避免文本解析成本的最佳方法是在运行时完全消除文本解析。通常,这意味着通过某种构建步骤将文本数据“烘培”为二进制格式。
大多数选择这种方式的开发人员将他们的数据移动到某种 ScriptableObject 派生的类层次结构中,然后通过 AssetBundles 分发数据。这种策略提供了最好的性能,但只适用于不需要动态生成的数据。它最适合游戏设计参数和其他内容。
Split and lazy load
第二种可能性是将必须解析的数据分割成更小的块。一旦分割,解析数据的开销可以分散到几个帧上。在理想情况下,确定向用户呈现所需体验所需的数据的特定部分,并只加载这些部分。
举个简单的例子:如果项目是一款平台游戏,那么就没有必要将所有关卡的数据序列化成一个巨大的数据团。如果数据被分割成每个关卡的单个资产,或者将关卡分割成区域,那么数据就可以在玩家接近它时被解析。虽然这听起来很简单,但实际上它需要对工具代码进行大量投入,并且可能需要重新组织数据结构。
Threads
对于完全解析为普通c#对象的数据,并且不需要与Unity api进行任何交互,可以将解析操作移动到工作线程。
这个选项在拥有大量核心的平台上非常强大。但是它需要仔细编程,以避免创建死锁和竞争条件。
注意:iOS设备最多有2个内核。大多数Android设备都有2到4个。在为独立和控制台构建目标构建时,这种技术更令人感兴趣。