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