在 Android 裝置上最佳化 Unity 遊戲的記憶體

雖然現代行動裝置的實體 RAM 容量持續增加,但由於高解析度素材資源和複雜的算繪管道,遊戲記憶體耗用量已超過這個成長幅度。

記憶體用量過多造成的主要問題

  • 記憶體不足終止工具 (LMK) 終止程序:OS 會強制關閉背景應用程式,甚至是前景應用程式,以解決系統記憶體不足的問題。
  • 影格遺失和卡頓 (jank):代管堆積中頻繁的垃圾回收 (GC) 尖峰或 OS 層級記憶體交換,會造成處理瓶頸。
  • 熱調節和電池耗電:持續配置、取消配置記憶體,以及提交記憶體頁面,會耗用大量 CPU 資源,導致溫度升高,電池耗電速度加快。

Android 記憶體管理更新

  • Android 17:推出 MemoryLimiter:Android 17 推出 MemoryLimiter,可主動監控應用程式的記憶體用量是否超過裝置專屬的門檻。如果應用程式超出記憶體限制,系統會立即終止該應用程式。由於這項機制比傳統 LMK 更嚴格,因此管理尖峰記憶體用量比以往更重要。

減少 Unity 中的記憶體用量

在 Unity 中,引擎擴充內部記憶體集區 (原生區塊分配器和受管理堆積) 以因應高尖峰負載後,會保留這些記憶體頁面,而不是立即將其傳回作業系統。因此,即使卸載大型素材資源,基準常駐記憶體仍會膨脹,導致應用程式很容易遭到 Android 作業系統程序終止 (例如 LMK 或 MemoryLimiter)。

如要避免 OS 層級強制執行,必須從以下三個主要支柱著手進行記憶體最佳化:

  • 類別 A:減少尖峰記憶體用量
  • B 類:消除不必要的資產和系統足跡
  • 類別 C:排除不必要的 GC 配置

類別 A:降低記憶體用量尖峰

Unity 的配置器會保留釋出的記憶體頁面供重複使用,而不是立即傳回 OS,因此基準記憶體往往會反映達到的最高峰值,而非目前用量。因此,相較於事後清理,預先防範尖峰流量更有效。

1. 避免過大的 AssetBundle

由於 Unity 的套件載入機制,大型 AssetBundle 會導致記憶體嚴重膨脹和 OOM 異常終止。保持套件模組化,確保資源管理效率。

主要問題

  • 記憶體負擔過高:要求單一小型資產會迫使 Unity 將整個套件檔案 (包括標頭、中繼資料和串流緩衝區) 載入 RAM。
  • 卸載陷阱:如果套件中的任何資產正在使用中,整個套件就無法卸載,導致未使用的資料滯留在 RAM 中。

最佳做法

  • 保持套件模組化:依場景或生命週期,以邏輯方式將素材資源分組。
  • Unity 6.6 以上版本提示:使用內容目錄,避免發生非預期的跨套件依附元件。

2. 最佳化 ScriptableObjects 中的素材資源參照

直接在 UnityEngine.ObjectScriptableObject 中序列化欄位會建立直接硬體參照,強制所有參照的資產在 ScriptableObject 本身載入或例項化後,立即載入至 RAM。

// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public Object _worldAsset;
    public Object _spawnSettings;
}

// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public AssetReference _worldAsset;
    public AssetReference _spawnSettings;
}

3. 設定音訊片段和聲道

將所有未壓縮的音訊片段直接載入記憶體,會造成大量永久記憶體尖峰。音訊負載設定應根據使用設定檔進行設定:

音訊類別 負載類型 原因
背景音樂 (BGM) 串流 從磁碟以小緩衝區串流音訊,避免記憶體用量暴增。
Long SFX 記憶體內壓縮 可降低 RAM 負擔,並在播放期間即時解壓縮音訊。
短暫且頻繁的音效 載入時解壓縮 載入時將音訊解壓縮至 RAM,避免播放期間產生執行階段 CPU 負擔。

除了載入類型外,您也可以套用下列音訊設定,進一步減少記憶體負擔:

  • 為非定向音訊啟用「Force To Mono」:許多音訊素材資源 (例如 UI 音效、旁白和 3D 空間化音效) 不需要立體聲聲道分離。在這些素材資源的AudioClip匯入設定中啟用「Force To Mono」,可將記憶體用量和緩衝區空間減半 (減少 50%)。
  • 依裝置層級節流使用中的音訊管道:每個使用中的音訊語音都會分配原生 DSP 混音器和解壓縮緩衝區。在記憶體容量較小的裝置上,減少可同時啟用的頻道數量上限,例如在 Unity 中使用 AudioSettings.Reset 降低 Max Real Voices (例如從 32 降至 16),或在第三方音訊引擎中限制軟體播放頻道。

4. 實作物件集區和釋出策略

在場景轉換期間,物件集區中未釋出的執行個體會無限期保留預留記憶體,導致基準記憶體用量不必要地增加。

  • 動作:在場景轉換或低活動期間,定期清除或修剪未使用的集區物件,讓 Unity 傳回或重複使用該保留空間,用於其他分配。

類別 B:消除不必要的記憶體用量

消除多餘的圖像資源並直接轉譯目標緩衝區,可降低基準記憶體用量。

1. 最佳化算繪紋理和攝影機深度

  • 移除深度/模板緩衝區:針對只需要顏色資料的轉譯紋理,將深度模板格式設為「None」。
  • 停用 UI 攝影機深度紋理:如果 UI 攝影機不需要深度資料,請在 URP 攝影機設定中停用深度紋理生成功能,以消除 CopyDepth 階段和相關聯的 GPU 紋理記憶體。

2. 最佳化紋理和網格

類別 最佳化指南
紋理壓縮 一律套用目標平台壓縮格式 (例如 Android 的 ASTC)。
已啟用讀取/寫入 除非必要,否則請保持停用。啟用後,系統會在 CPU 和 GPU RAM 複製紋理記憶體。
Mipmap 針對 UI 紋理或固定在恆定攝影機距離的物件停用 Mipmap,可節省約 33% 的紋理記憶體。
網格複雜度 減少不必要的多邊形數量和頂點串流,降低 GPU 和原生記憶體用量。

3. 移除著色器變化版本並最佳化記憶體

Uber 著色器 (例如 URP Lit Shader) 使用 #multi_compile 和 shader_feature 關鍵字封裝多項功能。如果未經最佳化,組合爆炸會產生數萬個不重複的著色器變化版本,導致建構大小膨脹、原生記憶體消耗量龐大,以及遊戲期間 GPU 驅動程式編譯卡頓。

A. 著色器變體記憶體額外負荷的機制

  • 組合爆炸:每新增一組關鍵字,可能的變化版本總數就會呈指數成長。
  • 區塊分配架構:Unity 會將編譯的二進位變體分組為壓縮記憶體區塊,稱為「區塊」 (預設:4 MB)。
  • 原生記憶體膨脹:當執行階段程式碼要求區塊中的單一變體時,整個 4 MB 區塊會解壓縮至 RAM。如果未經最佳化,這些區塊中封裝的數千個未使用變化版本會永久占用原生記憶體。

B. Unity 內建多級式剝除管道:Unity 會根據目標平台圖像設定和未使用的引擎功能 (例如霧、光照貼圖和 XR 設定),在建構時自動剝除不必要的變體。此外,如果專案中沒有任何素材資源使用 shader_feature 變體,系統會自動篩除這些變體;而無論使用情況如何,系統都會強制納入 #multi_compile 變體。不過,如果 URP 或管道設定中仍啟用算繪或後續處理功能 (例如額外光源、即時陰影或未使用的 UberPost 效果),Unity 就無法判斷內容是否實際使用這些功能,並產生所有對應的 #multi_compile 變體。檢查並停用未使用的管道設定,確保設定符合實際算繪需求,避免產生不必要的變化版本。

C. 自訂自動化移除架構 (IPreprocessShaders):由於靜態分析無法偵測使用執行階段 C# 指令碼 (Material.EnableKeyword) 動態修改的關鍵字,因此標準移除通常不夠。為確保只納入實際使用的變體,您可以在 QA 測試套件期間,使用 Player.log (在編輯器設定中啟用「Log Shader Compilation」) 或 Profiler Traces (Shader.CreateGPUProgram 標記) 收集變體。接著,在編輯器指令碼中實作 IPreprocessShaders.OnProcessShader,篩除執行階段從未執行的變化版本,只在建構作業中保留必要版本。

D. 整合多餘的著色器:Lit 和 Simple Lit 等超級著色器包含許多 #multi_compile 指令,在產生多個變體時會耗用大量記憶體:

  • 分析同時使用情形:分析記憶體用量,檢查兩個著色器是否同時耗用大量記憶體,並確認專案是否確實需要兩者。
  • 標準化單一著色器:如果不需要物理基礎算繪 (PBR),請設定素材資源,避免重複使用Lit著色器,並標準化Simple Lit (例如,檢查匯出的 FBX 模型是否在匯入期間意外套用預設Lit材質)。

類別 C:消除不必要的 GC 配置

受管理堆積中的垃圾收集 (GC) 分配作業會導致記憶體片段化、堆積擴充 (不會縮減),以及 GC 暫停期間的嚴重影格掉格。

1. 避免 lambda 閉包分配

當 lambda 運算式擷取外部區域變數時,C# 會在堆積上產生隱含的顯示類別。在 Update 內執行這項操作,會為每個影格分配閉包執行個體。

// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
    int targetId = 100;
    Monster target = monsterList.Find(m => m.Id == targetId);
}

// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
    int targetId = 100;
    Monster target = null;
    for (int i = 0; i < monsterList.Count; i++)
    {
        if (monsterList[i].Id == targetId)
        {
            target = monsterList[i];
            break;
        }
    }
}

// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);

2. 使用 stackalloc 和 Span

使用堆疊記憶體,避免為短期臨時陣列分配堆積。

// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];

// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];

3. 最佳化集合疊代

避免因 LINQ 擴充功能或 ReadOnlyCollection 存取子而導致裝箱和列舉器堆積分配量。

// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;

// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);

private bool HasDataOptimized(TestComponent component)
{
    if (component == null) return false;

    var count = component.parameters.Count;
    for (var i = 0; i < count; ++i)
    {
        if (component.parameters[i].overrideState)
            return true;
    }
    return false;
}

4. 其他 GC 分配預防規則

  • 避免使用 Camera.allCameras,因為每次呼叫都會在堆積上產生新的 Camera[] 陣列。請改為快取攝影機陣列,並傳遞至 Camera.GetAllCameras(_allCameras)。
  • 避免在 foreach 上對 IReadOnlyList<T> 進行疊代:對介面進行疊代會導致結構體列舉值裝箱,產生 GC 分配。請改用標準 for 迴圈。
  • 快取協同程式物件:快取 WaitForSeconds 例項,而非重複例項化 yield return new WaitForSeconds(time);。
  • 字典中的自訂 struct 鍵:使用自訂 struct 做為 Dictionary 鍵會呼叫預設的 Equals,進而觸發物件裝箱。實作 IEqualityComparer<T> 並傳遞至 Dictionary 建構函式。
public struct TypeKey
{
    public int v1;
    public int v2;

    public class TypeKeyComparer : IEqualityComparer<TypeKey>
    {
        public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
        public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
    }
}

// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());

5. 盡量減少使用泛型和反射

雖然泛型方法可提供出色的程式碼重複使用性和可維護性,但在 Unity IL2CPP (中繼語言至 C++) 後端中,過度使用泛型方法可能會對專案造成負面影響。

  • IL2CPP 程式碼膨脹:對於每個不重複的一般類型組合,IL2CPP 都會產生專屬版本的程式碼。過度使用複雜的泛型可能會導致產生的 C++ 程式碼「組合爆炸」,大幅增加應用程式的二進位大小和原生記憶體用量。
  • 反映負擔:使用反映的方法 (例如 System.Reflection API) 本身就很慢,而且通常會在執行階段造成堆積分配。
  • 最佳做法:請謹慎使用泛型,優先用於釐清架構,而非廣泛、不加區別地套用。如果效能至關重要,請偏好具體型別或以介面為基礎的多型。如要進行反映,請在初始化期間快取 MethodInfo 或 FieldInfo 等結果,而非在更新迴圈中查詢這些結果。

6. 避免代管殼層外洩

每個 UnityEngine.Object (例如 MonoBehaviour、Texture 或 GameObject) 都有 C#「Managed Shell」包裝函式,可與原生 C++ 引擎通訊。

  • 問題:如果 Managed Shell 透過靜態參照、永久事件訂閱或未清除的閉包保留在記憶體中,GC 就無法回收記憶體。即使原生物件遭到刪除,受管理的包裝函式仍會保留,導致「幽靈」記憶體流失,使受管理的堆積膨脹。
  • 解決方法:請一律實作完善的清除模式。刪除物件或轉換場景時,請使用 -= 運算子明確取消訂閱事件,並將 UnityEngine.Object 類型的靜態參照設為空值。這樣一來,原生引擎釋放控制代碼後,垃圾收集器就能順利收集包裝函式。

7. 使用二進位目錄處理可定址項目

在 Unity 2022 以下版本中,Addressables 預設為 JSON 目錄,而非二進位目錄 (Unity 2023 以上版本的預設值)。如果專案使用 JSON 目錄,在啟動時載入大型目錄會將整個檔案讀取到單一的大型 C# 字串中,並在 JSON 剖析和 Base64 解碼期間,分配數千個受管理字串和暫時性 byte[] 陣列。這會導致 GC 分配量大幅增加,永久擴充 Managed Heap,因此請切換至二進位目錄,直接串流壓縮資料。