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