[Unity] 引擎脚本相关的字符串优化
本文始发于:https://www.cnblogs.com/wildmelon/p/16180980.html
一、参考资料
- .Net源代码,https://referencesource.microsoft.com/#mscorlib/system/string.cs
- 字符串和文本,https://docs.unity.cn/cn/current/Manual/BestPracticeUnderstandingPerformanceInUnity5.html
- Concatenating Strings Efficiently,https://jonskeet.uk/csharp/stringbuilder.html
二、序数比对
根据 官方文档优化建议 中提到的:
在与字符串相关的代码中经常出现的核心性能问题之一,是无意间使用了缓慢的默认字符串 API。这些 API 是为商业应用程序构建的,可根据与文本字符有关的多种不同区域性和语言规则来处理字符串。
这里提到的区域性,即指与 CultureInfo
有关的处理,在 string.cs 中,大约有以下方法与 CultureInfo 有关:
- Equals
- Compare
- StartsWith
- EndsWith
- ToUpper
- ToLower
除了 ToUpper
和 ToLower
之外,都是与字符串对比有关的。绝大多数情况下我们不需要认定字符'e'与'æ'之类的字符是相同的。所以需要改用带 StringComparison 形参的方法,切换到 StringComparison.Ordinal,直接按序数进行比对。否则可能产生数十到上百倍的性能差距:
// 耗时对比
public void TestString1() {
System.Diagnostics.Stopwatch stopwatch = new System.Diagnostics.Stopwatch();
int loopCount = 1000000;
string strA = "test1";
string strB = "test2";
stopwatch.Start();
for (int i = 0; i < loopCount; i++) {
string.Compare(strA, strB);
//strA.Equals(strB, StringComparison.CurrentCulture);
}
stopwatch.Stop();
Debug.Log("Compare method 1: " + stopwatch.Elapsed.TotalMilliseconds);
stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < loopCount; i++) {
string.CompareOrdinal(strA, strB);
//strA.Equals(strB, StringComparison.Ordinal);
}
stopwatch.Stop();
Debug.Log("Compare method 2: " + stopwatch.Elapsed.TotalMilliseconds);
}
// 某次不严谨的测试结果:
// Compare method 1: 574.2263
// Compare method 2: 7.5011
不过与 Unity文档讲的不同,Equals 接口似乎默认就是使用的 StringComparison.Ordinal 序数比对。
另外类似 a.StartsWith("b", System.StringComparison.Ordinal)
本身效率已经足够,似乎也没有必要自己重新实现一个 CustomStartsWith,有可能会忽略掉某些提前退出的情况,反而使得性能下降。
三、字符串拼接
(一)加号操作符
基本上在 StringBuilder 的相关教程上,都能看到类似如下的示例:
public string TestString2() {
// 会产生多个临时对象,产生 GC
string str = "";
str = str + "a";
str = str + "b";
str = str + "c";
return str;
}
使用 ILSpy 工具打开编译之后的 dll 文件,可见 C# 源码被编译为以下 IL 代码,可以看到 string 的加号操作符,实际上调用的是 string.Concat 方法:
.method public hidebysig
instance string TestString2 () cil managed
{
IL_0000: ldstr ""
IL_0005: ldstr "a"
IL_000a: call string [mscorlib]System.String::Concat(string, string)
IL_000f: ldstr "b"
IL_0014: call string [mscorlib]System.String::Concat(string, string)
IL_0019: ldstr "c"
IL_001e: call string [mscorlib]System.String::Concat(string, string)
IL_0023: ret
} // end of metho
阅读 Concat 方法的源码,会发现底层是在统计所需长度后,调用 FastAllocateString 一次性分配内存,然后 FillStringChecked 对源字符串进行拷贝,实际上是一个非常效率的接口:
[System.Security.SecuritySafeCritical] // auto-generated
private static String ConcatArray(String[] values, int totalLength) {
String result = FastAllocateString(totalLength);
int currPos=0;
for (int i=0; i<values.Length; i++) {
Contract.Assert((currPos <= totalLength - values[i].Length),
"[String.ConcatArray](currPos <= totalLength - values[i].Length)");
FillStringChecked(result, currPos, values[i]);
currPos+=values[i].Length;
}
return result;
}
(二)拼接优化
那么对于刚才的示例,是否有必要改成如下代码呢:
StringBuilder builder = new StringBuilder();
builder.Append ("a");
builder.Append ("b");
builder.Append ("c");
string result = builder.ToString();
对于已知数量的字符串拼接,其实直接改成 string str = "a"+"b"+"c";
即可。单行的连续加号运算会编译成 String Concat(params String[] values)
方法调用,既保持可读性又不会生成多个中间字符串。
严格来讲上述说明不太准确。string str = "a"+"b"+"c";
生成的 IL 代码如下所示:
.method public hidebysig
instance string TestString2 () cil managed
{
.maxstack 8
IL_0000: ldstr "abc"
IL_0005: ret
}
可见如果代码里用的是常量表达式,在编译时就能确认字符串内容的话,会进行优化直接跳过拼接的调用。但使用 StringBuilder 的话则是无法提前确认进行优化的。
(三)使用总结
StringBuilder 和 string.Concat 效率是相近的。
对于预先组织好的、固定数量的字符串拼接(能直接确认总长度),string.Concat 效率稍高一些,可读性也更高。
对于不确定数量的或者需要缓存中间结果的字符串拼接,则使用 StringBuilder。
顺带一提,String.Format 是效率更低的字符串接口,看似只用了一个额外字符串,实际上会逐字符遍历解析占位符。如果只是用作拼接目的,先考虑是否能用其他接口替代。
四、C# 0GC 字符串方案
StringBuilder 和 Concat,每次调用都会分配新的内存来保存新字符串,在项目中如果存在大量的字符串拼接,就会导致频繁的 GC Alloc。
可以考虑使用 https://github.com/871041532/zstring
参考以下文章说明: