原文链接:https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity5.html
在Unity的工程中处理字符串和文本是造成性能问题的常见原因。在C#中,所有的字符串是不可变的。一个字符串的任何操作造成的结果都是一个完全新的字符串的内存分配。这是相对昂贵的,并且当有大量字符串、大量数据集、或是在紧凑的循环中,重复的字符串拼接会发展成为性能问题。
进一步说,N个字符串的连接需要N-1个中间的字符串,一些列的字符串拼接也是造成托管内存压力的主要原因。
对于那种每帧都要在紧凑的循环中进行字符串拼接的情况来说,使用StringBuilder来执行实际的拼接操作。StringBuilder实例也可以被重用以来最小化无必要的内存分配。
微软维护着一个在C#中进行字符串工作的最佳实践列表,可以在MSDN网站上找到:https://docs.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings
语言环境强制和顺序比较
经常在字符串相关代码中发现的核心性能问题之一是使用了慢速默认的字符串API。这些API是为商业应用程序构建的,并且尝试对文本中字符发现的对许多不同的文化和语言的规则进行处理。
比如,下面的示例代码当在US-English环境下运行返回true,但是在其他欧洲语言环境下会返回false。
请注意:在Unity5.3和5.4,Unity的脚本运行时总是在US English (en-US)语言环境下运行:
String.Equals("encyclopedia", “encyclopædia”);
对于绝大多数Unity项目来说,这完全是不必要的。使用顺序的比较类型大概要快十倍,这种比较字符串的方式类似于C和C++编程者:只是简单的比较字符串的连续字节,二部考虑对应的字节表示的是什么。
通过简单的使用StringComparison.Ordinal作为String.Equals最后一个参数来切换到顺序的字符串比较:
myString.Equals(otherString, StringComparison.Ordinal);
低效的内置字符串API
出了切换到顺序比较法外,某些C#的string API已知是非常低效的。其中String.Format, String.StartsWith和String.EndsWith. String.Format是非常难以替换的,但是低效的字符串比较函数被简单的优化掉了。
虽然微软的建议是传递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“只”会产生320B的垃圾。虽然这对于一个简单的比较操作仍然昂贵,但它可观的提高了之前例子的性能。
因此,如果正则表达式是不变的字符串文字,那么可以通过将这些字符串作为正则表达式构造函数第一个参数传递来可观的提高效率。这些预先编译的正则应该在接下来被复用。
XML, JSON以及其他长格式的文本解析
解析文本通常是发生在加载时的一项繁重的操作。在一些情况下,解析文本所花费的时间会超过加载和实例化资源的时间。
这后面的原因是由于特定的解析器使用。C#内置的XML解析器非常的灵活,但是这却造成了对特定数据布局无法优化。
很多第三方的解析器是基于反射构建的。虽然反射在开发中是一个非常好的选择(因为它允许解析器迅速的适应不断改变的数据布局),但是它是众所周知的慢。
Unity推荐使用其内置的JSONUtility API来作为一部分的解决方案,它提供了一个Unity的序列化系统读取和输出JSON文件的接口。在大多数标准检查程序中,它都比纯粹的C# JSON解析器快,但是就像Unity序列化系统中的其他接口一样它也有其限制性。没有额外代码的话它不能序列化许多复杂的数据类型,比如说字典。(请注意:查看 ISerializationCallbackReceiver接口在Unity的序列化过程中来找到一个简单的方法增加一个额外必要的执行过程来转化成或是转化一个复杂的数据类型)。
当在文本数据解析中发生性能问题时,考虑三个可选择的解决方案。
选择1:在构建时解析
避免文本解析消耗最好的办法是彻底在运行时消除文本解析。总体来说,这意味着将文本的数据通过一些构建的步骤“烘焙”到二进制格式中。
大多数选择这种方式的开发者移动他们的数据到一些ScriptableObject衍生的类层级中,并且通过AssetBundle分配这些数据。对于使用ScriptableObject非常好的讲解,请去youtube上看Richard Fine’s Unite 2016 talk(https://www.youtube.com/watch?v=VBA1QCoEAX4)。
这个策略提供了性能的最好可能性,但是只适用于数据并不会动态生成的情况。它最好适用于游戏设计参数和其他内容。
选择2:拆分和延迟加载
第二个可选择的方法是拆分要解析的数据到更小的块儿中。一旦拆分,解析数据的消耗就将分散到多个帧中。在理想状态下,识别那些指定的需要按需求体验呈现给用户的数据部分,并值加载这些部分。
在一个简单的例子中,如果项目是一个平台游戏,那么就没有必要将所有关卡的数据序列化到一个巨大的数据团中。如果将数据按每个关卡拆分到单独的资源中,并且也许可以将关卡拆分为区域,当玩家接近它时数据才会被解析。
虽然这听起来容易,但这实际上大量的投资到工具代码中并且也许会需要重新组织数据结构。
选择3:线程
对于那些要完全解析成普通C#对象,并且无需与Unity的API交互的的数据来说,可以把解析操作移动到工作线程中。
这个选项在拥有大量核心的平台上非常强大(请注意:iOS设备最多有两个核心,大多数安卓设备有2-4个。这个技术最适用于构建桌面和控制台目标应用。)但是,它需要仔细的编程来避免造成死锁和竞争条件。
选择线程实现的项目通常使用C#内置的Thread和ThreadPool类(请参阅msdn.microsoft.com)来管理其工作线程以及标准C#同步类。
网友评论