偵錯 LMK

在 Unity 遊戲中解決 LMK 問題的過程有系統性:

圖 1. 解決 Unity 遊戲中記憶體不足終止 (LMK) 問題的步驟。

取得記憶體快照

使用 Unity Profiler 取得 Unity 管理的記憶體快照。 圖 2 顯示 Unity 用於處理遊戲記憶體的記憶體管理層。

圖 2. Unity 的記憶體管理總覽。

受管理記憶體

Unity 的記憶體管理功能會實作受控記憶體層,使用受管理堆積和垃圾收集器自動分配及指派記憶體。受管理記憶體系統是以 MonoIL2CPP 為基礎的 C# 腳本環境。受管理記憶體系統的優點是會使用垃圾收集器,自動釋出記憶體配置。

C# 未受管理的記憶體

非受管 C# 記憶體層可存取原生記憶體層,讓您在使用 C# 程式碼時,精確控管記憶體配置。這個記憶體管理層可透過 Unity.Collections 命名空間和 UnsafeUtility.MallocUnsafeUtility.Free 等函式存取。

原生記憶體

Unity 的內部 C/C++ 核心會使用原生記憶體系統,管理場景、資產、圖形 API、驅動程式、子系統和外掛程式緩衝區。雖然直接存取受到限制,但您可以使用 Unity 的 C# API 安全地操控資料,並享有高效能的原生程式碼。原生記憶體很少需要直接互動,但您可以使用分析器監控原生記憶體對效能的影響,並調整設定來提升效能。

如圖 3 所示,C# 與原生程式碼不會共用記憶體。C# 所需的資料會在每次需要時,分配到受管理記憶體空間。

如要讓受管理遊戲的程式碼 (C#) 存取引擎的原生記憶體資料,例如呼叫 GameObject.transform,請發出原生呼叫來存取原生區域中的記憶體資料,然後使用 Bindings 將值傳回 C#。繫結可確保每個平台都有適當的呼叫慣例,並將受管理型別自動封送至對應的原生型別。

這只會發生在第一次,因為存取 transform 屬性的受管理殼層會保留在原生程式碼中。快取轉換屬性可減少受管理程式碼與原生程式碼之間的來回呼叫次數,但快取是否有用取決於屬性的使用頻率。此外,請注意,存取這些 API 時,Unity 不會將部分原生記憶體複製到受管理記憶體中。

圖 3. 從 C# 受管理程式碼存取原生記憶體。

詳情請參閱「Unity 中的記憶體簡介」。

此外,建立記憶體預算對於確保遊戲順暢運作至關重要,而導入記憶體消耗量分析或報表系統,則可確保每個新版本都不會超出記憶體預算。整合遊戲模式測試與持續整合 (CI),驗證遊戲特定區域的記憶體用量,也是深入瞭解情況的策略。

管理資產

這是記憶體用量中最具影響力且可執行的部分。盡早建立個人資料。

Android 遊戲的記憶體用量差異很大,取決於遊戲類型、資產數量和類型,以及記憶體最佳化策略。不過,常見的記憶體用量貢獻者通常包括紋理、網格、音訊檔案、著色器、動畫和指令碼。

偵測重複的資產

首先,請使用記憶體分析器、建構報表工具或專案稽核工具,偵測設定不良的資產重複資產

紋理

分析遊戲的裝置支援情況,並決定正確的紋理格式。您可以使用 Play Asset DeliveryAddressable,或透過 AssetBundle 手動處理,將高階和低階裝置的紋理組合分開。

請參閱「改善行動遊戲效能」和「改善 Unity 紋理匯入設定」討論文章,瞭解最廣為人知的建議。然後嘗試下列解決方案:

  • 使用 ASTC 格式壓縮紋理,減少記憶體用量,並嘗試使用較高的區塊率,例如 8x8。

    如果必須使用 ETC2,請將紋理封裝在 Atlas 中。將多個紋理放入單一紋理中,可確保紋理的 2 的冪 (POT),減少繪製呼叫,並加快算繪速度。

  • 最佳化 RenderTarget 紋理格式和大小。避免使用不必要的高解析度紋理。在行動裝置上使用較小的紋理可節省記憶體。

  • 使用紋理管道封裝功能,節省紋理記憶體。

網格和模型

首先請檢查基本設定 (第 27 頁),並確認下列網格匯入設定:

  • 合併多餘和較小的網格。
  • 減少場景中物件的頂點數量 (例如靜態或遠處物件)。
  • 為高幾何資產生成詳細程度 (LOD) 群組。

材質和著色器

  • 在建構過程中,以程式輔助方式移除未使用的著色器變體。
  • 將常用的著色器變體整合至超級著色器,避免著色器重複。
  • 啟用動態著色器載入功能,解決 VRAM/RAM 中預先載入的著色器佔用大量記憶體的問題。不過,如果著色器編譯導致影格延遲,請務必留意。
  • 使用動態著色器載入功能,避免載入所有變體。詳情請參閱「改善著色器建構時間和記憶體用量」網誌文章。
  • 善用 MaterialPropertyBlocks,正確使用材質例項。

音訊

請先檢查基本設定 (第 41 頁),並確認下列網格匯入設定:

  • 使用 FMOD 或 Wwise 等第三方音訊引擎時,請移除未使用的或多餘的 AudioClip 參照。
  • 預先載入音訊資料。在執行階段或場景啟動期間,停用不需要立即載入的片段預先載入功能。這有助於減少場景初始化期間的記憶體負擔。

動畫

  • 調整 Unity 的動畫壓縮設定,盡量減少關鍵影格數量,並消除多餘資料。
    • 減少主要畫面格:自動移除不必要的主要畫面格
    • 四元數壓縮:壓縮旋轉資料,減少記憶體用量

您可以在「Rig」(骨架) 或「Animation」(動畫) 分頁下方的「Animation Import Settings」(動畫匯入設定) 中調整壓縮設定。

  • 重複使用動畫片段,而非為不同物件複製動畫片段。

    使用 Animator Override Controller 重複使用 Animator Controller,並為不同角色取代特定片段。

  • 烘焙以物理為基礎的動畫:如果動畫是由物理或程序驅動,請將其烘焙到動畫片段中,避免執行階段計算。

  • 最佳化骨架裝備:在裝備中使用較少的骨骼,以降低複雜度和記憶體消耗量。

    • 避免為小型或靜態物件使用過多骨架。
    • 如果某些骨架不需要動畫或不需要,請從骨架中移除。
  • 縮短動畫片段長度。

    • 剪輯動畫片段,只保留必要的影格。避免儲存未使用的動畫或過長的動畫。
    • 使用循環動畫,不必為重複動作製作長片。
  • 確認只附加或啟動一個動畫元件。舉例來說,如果您使用 Animator,請停用或移除「舊版動畫」元件。

  • 如果不需要,請避免使用 Animator。如要製作簡單的 VFX,請使用補間動畫程式庫,或在指令碼中實作視覺效果。動畫師系統可能會耗用大量資源,尤其是在低階行動裝置上。

  • 處理大量動畫時,請使用 Job System,因為這個系統經過全面重新設計,可更有效率地運用記憶體。

場景

載入新場景時,會將資產做為依附元件帶入。不過,如果沒有適當的資產生命週期管理,這些依附元件就不會受到參照計數器監控。因此,即使卸載未使用的場景,素材資源仍可能留在記憶體中,導致記憶體片段化。

  • 請使用 Unity 的物件集區,重複使用遊戲物件例項,以處理重複出現的遊戲元素,因為物件集區會使用堆疊來保存物件例項集合,以供重複使用,且並非執行緒安全。盡量減少 InstantiateDestroy,可提升 CPU 效能和記憶體穩定性。
  • 卸載資產:
  • 重組場景,而不是不斷使用 Resources.UnloadUnusedAssets。
  • 呼叫 Resources.UnloadUnusedAssets()Addressables 可能會無意間卸載動態載入的套件。請謹慎管理動態載入資產的生命週期。

其他

  • 場景轉換導致的片段化 - 呼叫 Resources.UnloadUnusedAssets() 方法時,Unity 會執行下列操作:

    • 釋出不再使用的資產所占用的記憶體
    • 執行類似垃圾收集器的作業,檢查受管理和原生物件堆積中未使用的資產,並卸載這些資產
    • 清除紋理、網格和資產記憶體,前提是沒有有效的參照
  • AssetBundleAddressable - 變更這個區域的設定相當複雜,需要團隊共同努力才能實施策略。不過,一旦掌握這些策略,就能大幅提升記憶體用量、縮減下載大小,並降低雲端成本。如要進一步瞭解 Unity 中的資產管理,請參閱 Addressables

  • 集中管理共用依附元件:將著色器、紋理和字型等共用依附元件,有系統地歸入專屬套件或 Addressable 群組。這樣可減少重複作業,並確保系統有效率地卸載不必要的資產。

  • 使用 Addressables 追蹤依附元件 - Addressables 可簡化載入和卸載作業,並自動卸載不再參照的依附元件。視遊戲的具體情況而定,改用 Addressables 管理內容和解決依附元件問題,或許是可行的解決方案。使用「分析」工具分析依附元件鏈,找出不必要的重複項目或依附元件。如果您使用 AssetBundle,請改用 Unity Data Tools。

  • TypeTrees:如果遊戲的 AddressablesAssetBundles 是使用與播放器相同的 Unity 版本建構及部署,且不需要與其他播放器建構版本回溯相容,請考慮停用寫入 TypeTreeTypeTrees,這應該能減少套件大小和序列化檔案物件的記憶體用量。在本地 Addressables 套件設定中修改建構程序,將 ContentBuildFlags 設為 DisableWriteTypeTree

編寫適合垃圾收集器的程式碼

Unity 會利用垃圾收集 (GC) 功能管理記憶體,自動識別並釋出未使用的記憶體。雖然垃圾收集是必要的,但如果處理不當,可能會導致效能問題 (例如畫面更新率突然飆升),因為這個程序可能會暫時停止遊戲,導致效能不穩,使用者體驗不佳。

如需減少受管理堆積分配頻率的實用技巧,請參閱 Unity 手冊,並參閱 UnityPerformanceTuningBible 第 271 頁的範例。

  • 減少垃圾收集器分配:

    • 避免使用 LINQ、lambda 和閉包,因為這些會分配堆積記憶體。
    • 使用 StringBuilder 處理可變動的字串,取代字串串連。
    • 呼叫 COLLECTIONS.Clear() 即可重複使用集合,不必重新例項化。

    詳情請參閱「Unity 遊戲剖析終極指南」電子書。

  • 管理 UI 畫布更新:

    • 動態變更 UI 元素:更新文字、圖片或 RectTransform 屬性等 UI 元素時 (例如變更文字內容、調整元素大小或為位置設定動畫),引擎可能會為暫時物件分配記憶體。
    • 字串配置 - Text 等 UI 元素通常需要更新字串,因為大多數程式設計語言中的字串都是不可變動的。
    • 髒畫布 - 畫布上的項目變更時 (例如調整大小、啟用和停用元素,或修改版面配置屬性),整個畫布或部分畫布可能會標示為「髒」並重建。這可能會觸發暫時性資料結構的建立作業 (例如網格資料、頂點緩衝區或版面配置計算),進而增加垃圾產生量。
    • 複雜或頻繁的更新 - 如果畫布有大量元素或經常更新 (例如每個影格),這些重建作業可能會導致大量記憶體流失。
  • 啟用增量 GC,將分配清理作業分散到多個畫面格,以減少大型垃圾收集尖峰。進行分析,確認這個選項是否能提升遊戲效能及減少記憶體用量。

  • 如果遊戲需要受控方法,請將垃圾收集模式設為手動。接著,在層級變更時或沒有進行遊戲的其他時刻,呼叫垃圾收集。

  • 針對遊戲狀態轉換 (例如切換關卡) 呼叫手動垃圾收集 GC.Collect()

  • 從簡單的程式碼做法開始,視需要使用原生陣列或其他原生容器處理大型陣列,藉此最佳化陣列

  • 使用 Unity 記憶體分析器等工具監控受管理物件,追蹤在銷毀後仍存在的非受管理物件參照。

    使用分析器標記提交至成效報表工具,採用自動化方法。

避免記憶體流失和分散

記憶體流失

在 C# 程式碼中,如果物件遭到毀損後仍有對 Unity 物件的參照,則稱為「受管理殼層」的受管理包裝函式物件會留在記憶體中。當場景卸載時,或當記憶體所附加的 GameObject 或任何父項物件透過 Destroy() 方法刪除時,與參照相關聯的原生記憶體就會釋出。不過,如果沒有清除對 Scene 或 GameObject 的其他參照,受管理記憶體可能會以洩漏的 Shell 物件形式持續存在。如要進一步瞭解受管理 Shell 物件,請參閱受管理 Shell 物件手冊。

此外,事件訂閱、Lambda 和閉包、字串串連,以及集區物件管理不當,都可能導致記憶體流失:

  • 如要開始使用,請參閱「找出記憶體洩漏」,瞭解如何正確比較 Unity 記憶體快照。
  • 檢查事件訂閱項目和記憶體洩漏。如果物件訂閱事件 (例如透過委派或 UnityEvents),但未在銷毀前正確取消訂閱,事件管理員或發布者可能會保留對這些物件的參照。這會導致這些物件無法進行垃圾收集,進而造成記憶體流失。
  • 監控物件毀損時未取消註冊的全球或單例類別事件。舉例來說,在物件解構函式中取消訂閱或取消連結委派。
  • 確保銷毀集區物件會完全將對文字網格元件、紋理和父項 GameObject 的參照設為空值。
  • 請注意,比較 Unity Memory Profiler 快照並觀察到記憶體用量差異,但沒有明確原因時,差異可能是由圖形驅動程式或作業系統本身造成。

記憶體片段化

如果以隨機順序釋出許多小型分配項目,就會發生記憶體片段化。堆積分配作業是循序進行,也就是說,當前一個區塊空間不足時,系統會建立新的記憶體區塊。因此,新物件不會填滿舊區塊的空白區域,導致片段化。此外,在遊戲工作階段期間,大量暫時分配作業可能會導致永久片段化。

如果短期的大型配置作業是在長期配置作業附近進行,這個問題就特別嚴重。

根據分配項目的生命週期將其分組;理想情況下,應在應用程式生命週期的早期,一起進行長期分配。

觀察員和活動管理員

  • 除了 (記憶體流失)77 一節中提到的問題外,隨著時間推移,記憶體流失會導致記憶體片段化,因為記憶體會分配給不再使用的物件。
  • 確保銷毀集區物件會完全將對文字網格元件、紋理和父項 GameObjects 的參照設為空值。
  • 活動管理員通常會建立及儲存清單或字典,以便管理活動訂閱項目。如果這些物件在執行階段動態成長和縮減,可能會因頻繁配置和取消配置而導致記憶體片段化。

程式碼

  • 協同程式有時會分配記憶體,但只要快取 IEnumerator 的傳回陳述式,而非每次都宣告新的陳述式,即可輕鬆避免這種情況。
  • 持續監控集區物件的生命週期狀態,避免保留 UnityEngine.Object 虛擬參照。

素材資源

  • 針對文字驅動的遊戲體驗使用動態備援系統,避免預先載入多種語言的所有字型。
  • 依類型和預期生命週期,將素材資源 (例如紋理和粒子) 分類。
  • 縮減具有閒置生命週期屬性的資產,例如多餘的 UI 圖片和靜態網格。

以生命週期為準的分配

  • 在應用程式生命週期開始時分配長期資產,確保分配緊湊。
  • 針對耗用大量記憶體或暫時性的資料結構 (例如物理叢集),請使用 NativeCollections 或自訂分配器。

遊戲可執行檔和外掛程式也會影響記憶體用量。

IL2CPP 中繼資料

IL2CPP 會在建構時為每個型別 (例如類別、泛型和委派) 產生中繼資料,然後在執行階段用於反映、型別檢查和其他執行階段專屬作業。這項中繼資料會儲存在記憶體中,並大幅增加應用程式的總記憶體用量。IL2CPP 的中繼資料快取可大幅縮短初始化和載入時間。此外,IL2CPP 不會重複資料去重某些中繼資料元素 (例如泛型或序列化資訊),這可能會導致記憶體使用量過大。專案中重複或多餘的型別使用情形會加劇這個問題。

您可以透過下列方式減少 IL2CPP 中繼資料:

  • 避免使用反射 API,因為這類 API 可能會大幅增加 IL2CPP 中繼資料分配量
  • 停用內建套件
  • 導入 Unity 2022 完整泛型共用,這應有助於減少泛型造成的額外負荷。不過,如要進一步減少分配作業,請減少使用泛型。

程式碼清除

除了縮減建構大小,程式碼剝除功能也能減少記憶體用量。針對 IL2CPP 指令碼後端建構時,系統會移除受管理組件中未使用的程式碼,而受管理位元組碼清除功能預設為啟用。這個程序會先定義根組件,然後使用靜態程式碼分析,判斷這些根組件使用的其他受管理程式碼。系統會移除任何無法存取的程式碼。如要進一步瞭解代管程式碼清除功能,請參閱「Tales from the optimization trenches: Better managed code stripping with Unity 2020 LTS」網誌文章,以及「代管程式碼清除功能」說明文件。

原生分配器

實驗使用原生記憶體分配器,微調記憶體分配器。如果遊戲記憶體不足,請使用較小的記憶體區塊,即使這會導致分配器速度變慢也沒關係。詳情請參閱動態堆積分配器範例

管理原生外掛程式和 SDK

  • 找出有問題的外掛程式:移除每個外掛程式,並比較遊戲記憶體快照。這包括使用「Scripting」定義符號停用大量程式碼功能,以及使用介面重構高度耦合的類別。請參閱「運用遊戲程式設計模式提升程式碼品質」,瞭解如何停用外部依附元件,同時確保遊戲仍可正常運作。

  • 聯絡外掛程式或 SDK 作者,因為大多數外掛程式並非開放原始碼。

  • 重現外掛程式的記憶體用量:您可以編寫簡單的外掛程式 (參考這個 Unity 外掛程式),進行記憶體配置。使用 Android Studio 檢查記憶體快照 (因為 Unity 不會追蹤這些分配),或在同一個專案中呼叫 MemoryInfo 類別和 Runtime.totalMemory() 方法。

Unity 外掛程式會配置 Java 和原生記憶體,方法如下:

Java

byte[] largeObject = new byte[1024 * 1024 * megaBytes];
list.add(largeObject);

原生

char* buffer = new char[megabytes * 1024 * 1024];

// Random data to fill the buffer
for (int i = 1; i < megabytes * 1024 * 1024; ++i) {
   buffer[i] = 'A' + (i % 26); // Fill with letters A-Z
}