06. 字符串和文本
这是摘自Unity官方文档有关优化的部分,原文链接:https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity.html
总共分为如下系列:
- 采样分析
- 内存部分
- 协程
- Asset审查
- 理解托管堆 【推荐阅读】
5.1 上篇:原理,临时分配内存,集合和数组
5.2 下篇:闭包,装箱,数组 - 字符串和文本
- 资源目录
- 通用的优化方案
- 一些特殊的优化方案
在Unity中处理字符串和文本很容易产生性能问题。在C#中,所有的字符串都是不可变的(immutable)。任何对字符串的操作都会分配一个新的字符串,这个开销还是比较大的。重复进行字符串拼接更容易造成性能问题,例如对很长的字符串、数据库进行操作,或者在循环中处理字符串拼接都很容易造成性能问题。
N个字符的拼接的过程中会产生N-1个中间字符串,这些字符串对托管堆也会有一定的压力。
如果需要每帧进行字符串操作,建议使用StringBuilder
类来完成这些操作,StringBuilder
不会反复分配新的内存,可以减少内存开销。
Microsoft提供了C#中如何进行字符串操作的最佳指导,可以参见MSDN网站。
有关本地化的字符串比较问题
和字符串相关的代码中,最容易出现的问题就是使用效率很低的默认字符串相关API。这些API是为了商业目的设计,所以考虑了非常多的情况,包括在不同语言环境和文化下的字符问题。
例如,下面的代码在US-English的语言环境下返回true,但是在绝大多数的欧洲语言环境下返回false。
注意,在Unity 5.3和Unity 5.4中,Unity的脚本环境总是运行在US-English的环境下。
String.Equals("encyclopedia", “encyclopædia”);
对于大部分的Unity工程而言,这些原生API考虑的复杂情况根本用不上。如果采用类似于C和C++中比较字符串的方法,按照顺序逐个进行匹配,不需要考虑这些字符所处的语言环境是什么,差不多要快10倍左右。
如果需要这么做,只需要将StringComparison.Ordinal作为最后一个参数传递给String.Equal即可。
myString.Equals(otherString, StringComparison.Ordinal);
内置的低效API
除了需要将比较方法的参数设置成Ordinal,某些特定的C#字符串也是非常低效的,这些API包括String.Format,String.StartsWith,String.EndsWith。虽然String.Format很难被取代,但是还是可以通过一些细微的设置来提升一点性能。Microsoft推荐将StringComparison.Ordinal变量传递给任何不需要考虑本地化字符串的比较方法。
Unity的相关测评结果如下:
Method | Time (ms) for 100k short strings |
---|---|
String.StartsWith, default culture | 137 |
String.EndsWith, default culture | 542 |
String.StartsWith, ordinal | 115 |
String.EndsWith, ordinal | 34 |
Custom StartsWith replacement | 4.5 |
Custom EndsWith replacement | 4.5 |
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");
这段代码每次运行的时候,都差不多要产生5KB的垃圾。
下面的版本是修改之后的版本:
var myRegExp = new Regex("foo");
myRegExp.Match(myString);
改进之后的版本,每次调用myRegExp.Match
的时候,只会产生320字节的垃圾。虽然对于简单的匹配操作而言,开销还是比较大,但是和之前的版本相比还是提升了不少性能。
因此,如果正则表达式字符串不会改变,将它们作为第一个参数传递给某个Regex
对象的构造器,这样提前被编译好的正则表达式就可以重复被利用。
XML,JSON和其他的长本文解析问题
解析文本总是加载过程中最耗时的操作。在某些案例中,解析文本耗费的时间甚至超过加载和初始化Asset
。
这个问题背后的原因是底层使用的解析器造成的。C#
内置的XML解析器虽然很灵活,但是没有针对特定的布局文件进行优化。
很多第三方的解析器是基于反射设计的。虽然在开发阶段很容易进行调整,但是速度则是非常的慢。
Unity针对部分问题引入了内置的JSONUtility API,提供了针对Unity序列化系统的读取和输出JSON的方法。在大多数情况下,它比C#的解析器要快一点。但是它和其他的Unity序列化系统的API一样也有限制——如果不添加额外的代码的话不支持对复杂的数据结构类型进行序列化操作,比如说字典这种数据结构。
【可以参照ISerializationCallbackReceiver接口来实现对复杂数据类型进行序列化的操作。】
如果在Unity中解析文本出现性能问题可以参见如下的解决方案:
方案1:在打包的时候解析
避免文本解析开销最好的办法就是不在运行阶段对其进行解析,比如可以将文本化的数据在打包过程中处理成二进制文件格式。
大部分开发者会选择将他们的数据移动到ScriptableObject
的继承类中,然后通过AssetBundle进行数据发布。对于ScriptableObject
方法的使用,参见Youtube视频 Richard Fine’s Unite 2016 talk。
方案2:拆分和延迟加载
第二个解决方案就是对数据进行拆分,然后按照分块的方式进行解析。拆分之后,消耗可以平摊到几帧当中。最理想的情况下,对数据进行划分,只需要当前玩家状态需要的数据,只对这些数据进行解析即可。
举个简单的例子,对于某个平台游戏而言,没有必要将所有的数据都加载进内存,如果每个关卡的数据都可以被拆分,所以只需要加载玩家目前所需要的关卡信息即可。
但是这个方案在实际操作过程中需要精力去开发工具,而且数据的组织架构可能也需要重新调整。
方案3:使用线程加载
如果解析的数据只会和C#
进行交互,不会和Unity API进行交互,最好是将解析的过程放到子线程中。
这样对于多核机器而言非常有优势。iOS设备至少有两个核,Android设备则有2~4个核。对于单机和终端平台更有优势。当然,编程的时候需要注意防止死锁和线程竞争的情况出现。
当需要使用到线程的时候,基本上会采用C#的线程和线程池相关的类。可以参见Microsoft提供的指导来管理线程和标准的C#同步类。