Unity效能最佳化——字串和文字

Flamesky發表於2024-04-25

字串和文字

字串和文字的處理不當是 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.FormatString.StartsWithString.EndsWith。儘管 String.Format 難以替換,但低效率字串比較方法很容易最佳化掉。

儘管 Microsoft 建議將 StringComparison.Ordinal 傳遞給任何不需要為本地化做調整的字串比較,但 Unity 基準測試表明,相比自定義實現,該方法對效能的提升效果有限。

方法100k 短字串的時間(毫秒)
String.StartsWith,預設區域性 137
String.EndsWith,預設區域性 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);
        }

正規表示式

儘管正規表示式是匹配和操作字串的強大方法,但它們可能對效能的影響極大。此外,由於 C# 庫的正規表示式實現方式,即使簡單的布林值 IsMatch 查詢也需要在底層分配大型瞬態資料結構。除非在初始化期間,否則這種瞬態託管記憶體波動都是不可接受的。

如果必須使用正規表示式,強烈建議不要使用靜態 Regex.MatchRegex.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# ThreadThreadPool 類(請參閱 msdn.microsoft.com)來管理其工作執行緒以及標準 C# 同步類。

腳註

  • (1) 請注意,從 Unity 5.3 和 5.4 開始,Unity 的指令碼執行時始終在美國英語 (en-US) 區域設定下執行。

  • (2) 請參閱 ISerializationCallbackReceiver 介面,瞭解如何透過一種方法輕鬆新增必要的額外處理以便在 Unity 序列化過程中來回轉換複雜資料型別。

  • (3) 請注意,iOS 裝置最多有 2 個核心。大多數 Android 裝置具有 2–4 個核心。該技術適用於針對電腦平臺和遊戲主機發布的專案。

相關文章