Version: 2022.2
资源审核
Resources 文件夹

字符串和文本

字符串和文本的处理不当是 Unity 项目中性能问题的常见原因。在 C# 中,所有字符串均不可变。对字符串的任何操作均会导致分配一个完整的新字符串。这种操作的代价相对比较高,而且在大型字符串上、大型数据集上或紧凑循环中执行时,接连不断的重复的字符串可能发展成性能问题。

此外,由于 N 个字符串连接需要分配 N–1 个中间字符串,串行连接也可能成为托管内存压力的主要原因。

如果必须在紧凑循环中或每帧期间对字符串进行连接,请使用 StringBuilder 执行实际连接操作。为最大限度减少不必要的内存分配,可重复使用 StringBuilder 实例。

Microsoft 整理了一份处理 C# 中的字符串的最佳做法清单,可在这里的 MSDN 网站上找到该清单:msdn.microsoft.com

区域约束与序数比对

在与字符串相关的代码中经常出现的核心性能问题之一是无意间使用了缓慢的默认字符串 API。这些 API 是为商业应用程序构建的,可根据与文本字符有关的多种不同区域性和语言规则来处理字符串。

For example, the following example code returns true when run under the US-English locale, but returns false for many European locales.

Note: As of Unity 5.3 and 5.4, Unity’s scripting runtimes always run under the US English (en-US) locale:

    String.Equals("encyclopedia", "encyclopædia");

For most Unity projects, this is entirely unnecessary. It’s roughly ten times faster to use the ordinal comparison type, which compares strings in a manner familiar to C and C++ programmers: by simply comparing each sequential byte of the string, without regard for the character represented by that byte.

切换至序数比对的方式非常简单,只需将 StringComparison.Ordinal 作为最终参数提供给 String.Equals

myString.Equals(otherString, StringComparison.Ordinal);

低效的内置字符串 API

Beyond switching to ordinal comparisons, certain C# String APIs are known to be extremely inefficient. Among these are String.Format, String.StartsWith and String.EndsWith. String.Format is difficult to replace, but the inefficient string comparison methods are trivially optimized away.

While Microsoft’s recommendation is to pass StringComparison.Ordinal into any string comparison that doesn’t need to be adjusted for localization, Unity benchmarks show that the impact of this is relatively minimal compared to a custom implementation.

方法 100k 短字符串的时间(毫秒)
String.StartsWith,默认区域性 137
String.EndsWith, default culture 542
String.StartsWith,序数 115
String.EndsWith,序数 34
自定义 StartsWith 替换 4.5
自定义 EndsWith 替换 4.5

String.StartsWithString.EndsWith 均可以替换为类似于以下示例的简单的手工编码版本。


    public static bool CustomEndsWith(this 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);
        }

        public static bool CustomStartsWith(this 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);
        }

正则表达式

While Regular Expressions are a powerful way to match and manipulate strings, they can be extremely performance-intensive. Further, due to the C# library’s implementation of Regular Expressions, even simple Boolean IsMatch queries allocate large transient datastructures “under the hood.” This transient managed memory churn should be deemed unacceptable, except during initialization.

If regular expressions are necessary, it’s strongly recommended to not use the static Regex.Match or Regex.Replace methods, which accept the regular expression as a string parameter. These methods compile the regular expression on-the-fly and don’t cache the generated object.

以下示例代码为无害的单行代码。


Regex.Match(myString, "foo");

但是,该代码每次执行时会产生 5 KB 的垃圾。通过简单的重构即可消除其中的大部分垃圾:


var myRegExp = new Regex("foo");

myRegExp.Match(myString);

In this example, each call to myRegExp.Match “only” results in 320 bytes of garbage. While this is still expensive for a simple matching operation, it’s a considerable improvement over the previous example.

Therefore, if the regular expressions are invariant string literals, it’s considerably more efficient to precompile them by passing them as the first parameter of the Regex object’s constructor. These precompiled Regexes should then be reused.

XML、JSON 和其他长格式文本解析

Parsing text is often one of the heaviest operations that occurs at loading time. Sometimes, the time spent parsing text can outweigh the time spent loading and instantiating Assets.

The reasons behind this depend on the specific parser used. C#’s built-in XML parser is extremely flexible, but as a result, it’s not optimizable for specific data layouts.

Many third-party parsers are built on reflection. While reflection is an excellent choice during development (because it allows the parser to rapidly adapt to changing data layouts), it’s notoriously slow.

Unity has introduced a partial solution with its built-in JSONUtility API, which provides an interface to Unity’s serialization system that reads/emits JSON. In most benchmarks, it’s faster than pure C# JSON parsers, but it has the same limitations as other interfaces to Unity’s serialization system – it can’t serialized many complex data types, such as Dictionaries, without additional code.

Note: See the ISerializationCallbackReceiver interface for one way to add the additional processing necessary to convert to/from complex data types during Unity’s serialization process.

当遇到文本数据解析所引起的性能问题时,请考虑三种替代解决方案。

方案 1:在构建时解析

避免文本解析成本的最佳方法是完全取消运行时文本解析。通常,这意味着通过某种构建步骤将文本数据“烘焙”成二进制格式。

大多数选择使用该方法的开发者会将其数据移动到某种 ScriptableObject 衍生的类层级视图中,然后通过 AssetBundle 分配数据。有关使用 ScriptableObjects 的精彩讨论,请参阅 youtube 上 Richard Fine 的 Unite 2016 讲座

This strategy offers the best possible performance, but is only suitable for data that doesn’t need to be generated dynamically. it’s best suited for game design parameters and other content.

方案 2:拆分和延迟加载

第二种可行的方法是将必须解析的数据拆分为较小的数据块。拆分后,解析数据的成本可分摊到多个帧。在理想的情况下,可识别出为用户提供所需体验而需要的特定数据部分,然后只加载这些部分。

举一个简单的例子:如果项目为平台游戏,则没必要将所有关卡的数据一起序列。如果将数据拆分为每个关卡的独立资源,并且将关卡划分到区域中,则可以在玩家闯关到相应位置时再解析数据。

虽然这听起来不难,但实际上需要在工具编码方面投入大量精力,并可能需要重组数据结构。

方案 3:线程

For data that’s parsed entirely into plain C# objects, and doesn’t require any interaction with Unity APIs, it’s possible to move the parsing operations to worker threads.

This option can be extremely powerful on platforms with a significant number of cores. However, it requires careful programming to avoid creating deadlocks and race conditions.

Note: iOS devices have at most 2 cores. Most Android devices have from 2 to 4. This technique of more interest when building for standalone and console build targets.

Projects that choose to implement threading use the built-in C# Thread and ThreadPool classes (see msdn.microsoft.com) to manage their worker threads, along with the standard C# synchronization classes.

资源审核
Resources 文件夹