雖然現代行動裝置的實體 RAM 容量持續增加,但由於高解析度素材資源和複雜的算繪管道,遊戲記憶體用量已超過這個成長幅度。
記憶體用量過高導致的主要問題
- 記憶體不足終止工具 (LMK) 終止程序:OS 會強制關閉背景或甚至前景應用程式,解決系統記憶體不足的問題。
- 掉格和延遲 (抖動):受管理堆積中的垃圾收集 (GC) 頻繁尖峰或 OS 層級的記憶體交換,會造成處理瓶頸。
- 熱節流和電池耗電:持續記憶體配置、取消配置和記憶體頁面提交作業會產生大量 CPU 負擔,導致溫度升高和電池耗電速度加快。
Android 記憶體管理更新
- Android 17:推出
MemoryLimiter:Android 17 推出MemoryLimiter,可主動監控應用程式的記憶體用量是否超過裝置專屬的門檻。如果應用程式超出記憶體限制,系統會立即終止應用程式。由於這項機制比傳統 LMK 更嚴格,因此管理尖峰記憶體用量比以往更加重要。
減少 Unity 中的記憶體用量
在 Unity 中,引擎擴充內部記憶體集區 (原生區塊分配器和受管理堆積) 以因應高尖峰負載後,會保留這些記憶體頁面,而不是立即將其傳回作業系統。因此,即使卸載了大量資產,常駐記憶體的基準仍會膨脹,導致應用程式極易遭到 Android OS 終止程序 (例如 LMK 或 MemoryLimiter)。
如要避免 OS 層級強制執行,記憶體最佳化必須從以下三個主要支柱著手:
- 類別 A:減少尖峰記憶體用量
- 第 B 類:消除不必要的資產和系統足跡
- 第 C 類:排除不必要的 GC 分配
類別 A:減少記憶體用量尖峰
Unity 的分配器會保留釋出的記憶體頁面以供重複使用,而不是立即將其傳回 OS,因此基準記憶體往往會反映達到的最高尖峰,而非目前的用量。因此,相較於事後清理,預先防止尖峰用量更有效。
1. 避免 AssetBundle 過大
由於 Unity 的套件組合載入機制,巨型 AssetBundle 會導致嚴重記憶體膨脹和 OOM 當機。請保持套件的模組化,確保資源管理效率。
主要問題
- 記憶體負擔過重:要求單一微小資產時,Unity 會強制將整個套件檔案 (包括標頭、中繼資料和串流緩衝區) 載入 RAM。
- 卸載陷阱:如果套件中的任何資產正在使用中,整個套件就無法卸載,導致未使用的資料留在 RAM 中。
最佳做法
- 保持套件模組化:依場景或生命週期將素材資源分組。
- Unity 6.6 以上版本適用提示:使用內容目錄,避免發生非預期的跨套件依附元件。
2. 最佳化 ScriptableObjects 中的素材資源參照
直接 UnityEngine.Object 序列化 ScriptableObject 中的欄位會建立直接硬體參照,強制所有參照的資產在 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 負荷過高。 |
4. 實作物件集區和發布策略
場景轉換後,物件集區中未釋放的執行個體會無限期保留預留記憶體,導致基準記憶體用量不必要地膨脹。
- 動作:在場景轉換或活動量較低期間,定期清除或修剪未使用的集區物件,讓 Unity 將保留空間退回或重複用於其他分配作業。
第 B 類:減少不必要的記憶體用量
直接算繪目標緩衝區,並排除多餘的圖像資源,可降低基準記憶體用量。
1. 最佳化算繪紋理和攝影機深度
- 移除深度/模板緩衝區:將只需要顏色資料的算繪紋理「深度模板格式」設為「無」。
- 停用 UI 相機深度紋理:對於不需要深度資料的 UI 相機,請在 URP 相機設定中停用深度紋理生成功能,以消除
CopyDepthPass 和相關聯的 GPU 紋理記憶體。
2. 最佳化紋理和網格
| 類別 | 最佳化指南 |
|---|---|
| 紋理壓縮 | 請一律套用目標平台壓縮格式 (例如 Android 適用的 ASTC)。 |
| 已啟用讀寫功能 | 除非必要,否則請保持停用。啟用這項功能會將紋理記憶體複製到 CPU 和 GPU RAM。 |
| Mipmap | 針對 UI 紋理或固定在恆定攝影機距離的物件停用 Mipmap,可節省約 33% 的紋理記憶體。 |
| 網狀網路複雜度 | 減少不必要的算繪多邊形數量和頂點串流,降低 GPU 和原生記憶體用量。 |
3. 剝除著色器變體並最佳化記憶體
超級著色器 (例如 URP Lit Shader) 會使用 #multi_compile 和 shader_feature 關鍵字封裝多項功能。如果沒有經過最佳化,組合爆炸會產生數以萬計的獨特著色器變體,導致建構大小膨脹、大量消耗原生記憶體,以及在遊戲期間發生 GPU 驅動程式編譯問題。
A. 著色器變體記憶體負擔的機制
- 組合爆炸:每新增一組關鍵字,可能的變體總數就會呈指數成長。
- 區塊分配架構:Unity 會將編譯後的二進位變體分組,放入稱為「區塊」的壓縮記憶體區塊 (預設為 4 MB)。
- 原生記憶體膨脹:當執行階段程式碼要求區塊內的單一變體時,整個 4 MB 的區塊都會解壓縮到 RAM 中。如果未經過最佳化,這些區塊中封裝的數千個未使用變體會永久佔用原生記憶體。
B. Unity 內建多階段剝除管道:Unity 會根據目標平台圖像設定和未使用的引擎功能 (例如霧、光照貼圖和 XR 設定),在建構時自動剝除不必要的變體。此外,如果專案中沒有任何素材資源使用 shader_feature 變體,系統會自動篩除這些變體;而無論使用情況為何,系統都會強制納入 #multi_compile 變體。
C. 自訂自動剝除架構 (IPreprocessShaders):靜態分析無法偵測使用執行階段 C# 指令碼 (Material.EnableKeyword) 動態修改的關鍵字,因此標準剝除功能通常不夠用。如要確保只納入實際使用的變體,您可以在 QA 測試套件期間,使用 Player.log (在編輯器設定中啟用「Log Shader Compilation」) 或 Profiler Traces (Shader.CreateGPUProgram 標記) 收集變體。然後在編輯器指令碼中實作 IPreprocessShaders.OnProcessShader,篩除執行階段從未執行的變體,只保留建構作業中必要的變體。
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)。 - 避免在
IReadOnlyList<T>上使用foreach:疊代處理介面會導致結構體列舉值裝箱,產生 GC 分配。請改用標準for迴圈。 - 快取協同程式物件:快取
WaitForSeconds例項,而非重複例項化yield return new WaitForSeconds(time);。 - 字典中的自訂結構體鍵:使用自訂結構體做為 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.ReflectionAPI) 本身就很慢,而且通常會在執行階段造成堆積分配。 - 最佳做法:謹慎使用泛型,優先考量架構清晰度,而非廣泛、不分青紅皂白地套用。如果效能至關重要,請優先使用具體型別或以介面為基礎的多型。如要進行反映,請在初始化期間快取
MethodInfo或FieldInfo等結果,而非在更新迴圈中查詢這些結果。
6. 避免外洩受管理 Shell
每個 UnityEngine.Object (例如 MonoBehaviour、Texture 或 GameObject) 都有 C#「受管理 Shell」包裝函式,可與原生 C++ 引擎通訊。
- 問題:如果 Managed Shell 保存在記憶體中,且有靜態參照、持續性事件訂閱或未清除的閉包,GC 就無法回收記憶體。即使原生物件遭到毀損,受管理包裝函式仍會持續存在,導致「幽靈」記憶體洩漏,使受管理堆積膨脹。
- 解決方法:請一律實作完善的清除模式。銷毀物件或轉換場景時,請使用
-=運算子明確取消訂閱事件,並將UnityEngine.Object型別的靜態參照設為空值。這項清理作業可確保原生引擎釋放控制代碼後,垃圾收集器能順利收集包裝函式。