字符串和文本
字符串和文本的處理不當(dāng)是 Unity 項(xiàng)目中性能問(wèn)題的常見(jiàn)原因。在 C# 中,所有字符串均不可變。對(duì)字符串的任何操作均會(huì)導(dǎo)致分配一個(gè)完整的新字符串。這種操作的代價(jià)相對(duì)比較高,而且在大型字符串上、大型數(shù)據(jù)集上或緊湊循環(huán)中執(zhí)行時(shí),接連不斷的重復(fù)的字符串可能發(fā)展成性能問(wèn)題。
此外,由于 N 個(gè)字符串連接需要分配 N–1 個(gè)中間字符串,串行連接也可能成為托管內(nèi)存壓力的主要原因。
如果必須在緊湊循環(huán)中或每幀期間對(duì)字符串進(jìn)行連接,請(qǐng)使用 StringBuilder 執(zhí)行實(shí)際連接操作。為最大限度減少不必要的內(nèi)存分配,可重復(fù)使用 StringBuilder 實(shí)例。
Microsoft 整理了一份處理 C# 中的字符串的最佳做法清單,可在這里的 MSDN 網(wǎng)站上找到該清單:msdn.microsoft.com。
區(qū)域約束與序數(shù)比對(duì)
在與字符串相關(guān)的代碼中經(jīng)常出現(xiàn)的核心性能問(wèn)題之一是無(wú)意間使用了緩慢的默認(rèn)字符串 API。這些 API 是為商業(yè)應(yīng)用程序構(gòu)建的,可根據(jù)與文本字符有關(guān)的多種不同區(qū)域性和語(yǔ)言規(guī)則來(lái)處理字符串。
例如,在美國(guó)英語(yǔ)區(qū)域設(shè)置下運(yùn)行時(shí),以下示例代碼將返回 true,但在許多歐洲區(qū)域設(shè)置下,將返回 false (1)
注意: 從 Unity 5.3 和 5.4 開始,Unity 的腳本運(yùn)行時(shí)始終在美國(guó)英語(yǔ) (en-US) 區(qū)域設(shè)置下運(yùn)行:
? ?String.Equals("encyclopedia", "encyclop?dia");
對(duì)于大多數(shù) Unity 項(xiàng)目,上述代碼完全沒(méi)有必要。使用序數(shù)比對(duì)可將速度提高大約十倍,這種比較類型以 C 和 C++ 工程師熟悉的方式比較字符串:簡(jiǎn)單地比較字符串的每個(gè)連續(xù)字節(jié),不考慮該字節(jié)所表示的字符。
切換至序數(shù)比對(duì)的方式非常簡(jiǎn)單,只需將?StringComparison.Ordinal
?作為最終參數(shù)提供給?String.Equals
:
myString.Equals(otherString, StringComparison.Ordinal);
低效的內(nèi)置字符串 API
除了切換至序數(shù)比對(duì)以外,目前已知某些 C#?String
?API 的效率極低,其中包括?String.Format
、String.StartsWith
?和?String.EndsWith
。盡管?String.Format
?難以替換,但低效率字符串比較方法很容易優(yōu)化掉。
盡管 Microsoft 建議將?StringComparison.Ordinal
?傳遞給任何不需要為本地化做調(diào)整的字符串比較,但 Unity 基準(zhǔn)測(cè)試表明,相比自定義實(shí)現(xiàn),該方法對(duì)性能的提升效果有限。
方法????????????????????????????????????????????????????????100k 短字符串的時(shí)間(毫秒)
String.StartsWith
,默認(rèn)區(qū)域性????????????????????????????????137
String.EndsWit
h,默認(rèn)區(qū)域性????????????????????????????????????542
String.StartsWith
,序數(shù)???????????????????????????????????????????115
String.EndsWith
,序數(shù)????????????????????????????????????????????? ? 34
自定義?StartsWith
?替換????????????????????????????????????????????????4.5
自定義?EndsWith
?替換????????????????????????????????????????????????????4.5
String.StartsWith
?和?String.EndsWith
?均可以替換為類似于以下示例的簡(jiǎn)單的手工編碼版本。
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);
}
正則表達(dá)式
盡管正則表達(dá)式是匹配和操作字符串的強(qiáng)大方法,但它們可能對(duì)性能的影響極大。此外,由于 C# 庫(kù)的正則表達(dá)式實(shí)現(xiàn)方式,即使簡(jiǎn)單的布爾值?IsMatch
?查詢也需要在底層分配大型瞬態(tài)數(shù)據(jù)結(jié)構(gòu)。除非在初始化期間,否則這種瞬態(tài)托管內(nèi)存波動(dòng)都是不可接受的。
如果必須使用正則表達(dá)式,強(qiáng)烈建議不要使用靜態(tài)?Regex.Match
?或?Regex.Replace
?方法,這些方法會(huì)將正則表達(dá)式視為字符串參數(shù)。這些方法即時(shí)編譯正則表達(dá)式,并且不緩存生成的對(duì)象。
以下示例代碼為無(wú)害的單行代碼。
Regex.Match(myString, "foo");
但是,該代碼每次執(zhí)行時(shí)會(huì)產(chǎn)生 5 KB 的垃圾。通過(guò)簡(jiǎn)單的重構(gòu)即可消除其中的大部分垃圾:
var myRegExp = new Regex("foo");?
myRegExp.Match(myString);
在本示例中,每次調(diào)用?myRegExp.Match
“只”產(chǎn)生 320 字節(jié)的垃圾。盡管這對(duì)于簡(jiǎn)單的匹配操作仍然代價(jià)高昂,但比前面的示例有了相當(dāng)大的改進(jìn)。
因此,如果正則表達(dá)式是不變的字符串字面值,通過(guò)將正則表達(dá)式傳遞為正則表達(dá)式對(duì)象構(gòu)造函數(shù)的第一個(gè)參數(shù)來(lái)預(yù)編譯它們,可顯著提高效率。這些預(yù)編譯的正則表達(dá)式之后會(huì)被重用。
XML、JSON 和其他長(zhǎng)格式文本解析
解析文本通常是加載期間所發(fā)生的最繁重的操作之一。在某些情況下,解析文本所花費(fèi)的時(shí)間可能超過(guò)加載和實(shí)例化資源所花費(fèi)的時(shí)間。
此問(wèn)題背后的原因取決于所使用的具體解析器。C# 的內(nèi)置 XML 解析器極為靈活,但因此無(wú)法針對(duì)具體數(shù)據(jù)布局進(jìn)行優(yōu)化。
許多第三方解析器都是基于反射構(gòu)建的。盡管反射在開發(fā)過(guò)程中是絕佳選擇(因?yàn)樗茏尳馕銎骺焖龠m應(yīng)不斷變化的數(shù)據(jù)布局),但眾所周知,它的速度非常慢。
Unity 引入了采用其內(nèi)置?JSONUtility?API 的部分解決方案,該解決方案提供了讀取/發(fā)出 JSON 的 Unity 序列化系統(tǒng)接口。在大多數(shù)基準(zhǔn)測(cè)試中,它比純 C# JSON 解析器快,但它與 Unity 序列化系統(tǒng)的其他接口具有相同的限制:沒(méi)有額外代碼的情況下,無(wú)法對(duì)許多復(fù)雜的數(shù)據(jù)類型(如字典)進(jìn)行序列化(2)(注意: 請(qǐng)參閱?ISerializationCallbackReceiver?接口,了解如何通過(guò)一種方法輕松添加必要的額外處理以便在 Unity 序列化過(guò)程中來(lái)回轉(zhuǎn)換復(fù)雜數(shù)據(jù)類型)。
當(dāng)遇到文本數(shù)據(jù)解析所引起的性能問(wèn)題時(shí),請(qǐng)考慮三種替代解決方案。
方案 1:在構(gòu)建時(shí)解析
避免文本解析成本的最佳方法是完全取消運(yùn)行時(shí)文本解析。通常,這意味著通過(guò)某種構(gòu)建步驟將文本數(shù)據(jù)“烘焙”成二進(jìn)制格式。
大多數(shù)選擇使用該方法的開發(fā)者會(huì)將其數(shù)據(jù)移動(dòng)到某種 ScriptableObject 衍生的類層級(jí)視圖中,然后通過(guò) AssetBundle 分配數(shù)據(jù)。有關(guān)使用 ScriptableObjects 的精彩討論,請(qǐng)參閱 youtube 上?Richard Fine 的 Unite 2016 講座。
該策略可實(shí)現(xiàn)盡可能高的性能,但只適用于不需要?jiǎng)討B(tài)生成的數(shù)據(jù)。它適用于游戲設(shè)計(jì)參數(shù)和其他內(nèi)容。
方案 2:拆分和延遲加載
第二種可行的方法是將必須解析的數(shù)據(jù)拆分為較小的數(shù)據(jù)塊。拆分后,解析數(shù)據(jù)的成本可分?jǐn)偟蕉鄠€(gè)幀。在理想的情況下,可識(shí)別出為用戶提供所需體驗(yàn)而需要的特定數(shù)據(jù)部分,然后只加載這些部分。
舉一個(gè)簡(jiǎn)單的例子:如果項(xiàng)目為平臺(tái)游戲,則沒(méi)必要將所有關(guān)卡的數(shù)據(jù)一起序列。如果將數(shù)據(jù)拆分為每個(gè)關(guān)卡的獨(dú)立資源,并且將關(guān)卡劃分到區(qū)域中,則可以在玩家闖關(guān)到相應(yīng)位置時(shí)再解析數(shù)據(jù)。
雖然這聽起來(lái)不難,但實(shí)際上需要在工具編碼方面投入大量精力,并可能需要重組數(shù)據(jù)結(jié)構(gòu)。
方案 3:線程
如果數(shù)據(jù)完全解析成純 C# 對(duì)象,并且不需要與 Unity API 進(jìn)行任何交互,則可以將解析操作移至工作線程。
該方案在具有大量核心的平臺(tái)上非常強(qiáng)大(3)(注意: iOS 設(shè)備最多有 2 個(gè)核心。大多數(shù) Android 設(shè)備具有 2–4 個(gè)核心。該技術(shù)適用于針對(duì)電腦平臺(tái)和游戲主機(jī)發(fā)布的項(xiàng)目。)但是,該方案需要仔細(xì)編程,以免產(chǎn)生死鎖和競(jìng)態(tài)條件。
選擇實(shí)現(xiàn)線程的項(xiàng)目通常使用內(nèi)置的 C#?Thread?和?ThreadPool?類(請(qǐng)參閱?msdn.microsoft.com)來(lái)管理其工作線程以及標(biāo)準(zhǔn) C# 同步類。
腳注
(1) 請(qǐng)注意,從 Unity 5.3 和 5.4 開始,Unity 的腳本運(yùn)行時(shí)始終在美國(guó)英語(yǔ) (en-US) 區(qū)域設(shè)置下運(yùn)行。
(2) 請(qǐng)參閱?ISerializationCallbackReceiver?接口,了解如何通過(guò)一種方法輕松添加必要的額外處理以便在 Unity 序列化過(guò)程中來(lái)回轉(zhuǎn)換復(fù)雜數(shù)據(jù)類型。
(3) 請(qǐng)注意,iOS 設(shè)備最多有 2 個(gè)核心。大多數(shù) Android 設(shè)備具有 2–4 個(gè)核心。該技術(shù)適用于針對(duì)電腦平臺(tái)和游戲主機(jī)發(fā)布的項(xiàng)目。