Unity Project Auditor 實戰:用 Only Major/Critical 找出每幀 GC 熱點,修正 Boxing、Linq 與 GetComponentsInChildren
目前的專案做到一段落了,然後在發佈時還是遇到記憶體和 GC 問題,而且通常在發佈后才會面臨到不少的回報,很多時候内部測試因爲量級、設備、環境諸多因素很難第一時間發現
例如,某個 Update 裡多一個 Linq、某段 UI 每幀抓一次 GetComponentsInChildren。等到 Profiler 上看到 GC.Alloc 尖峰,要回頭找來源就很花時間。
偶然看到 Unity 官方介紹的 Project Auditor,這是一個靜態分析工具。它不需要執行遊戲,直接掃描程式碼、資源和專案設定,列出可能影響效能與記憶體的問題,也會附上說明和建議修法。這篇記錄我用它分析專案的流程,以及挑出來的幾個記憶體問題要怎麼改。
Project Auditor 包含了幾個模塊内容
- Code:C# 程式碼裡影響效能、記憶體、Editor 迭代時間的寫法
- Assets:匯入設定或資源組織不當,影響啟動時間與執行期記憶體
- Project Settings:可能影響效能與記憶體的專案設定
- Build Report:最近一次 clean build 各步驟花的時間,以及打包了哪些資源
它的定位是在進 Profiler 之前,先把明顯的反模式列出來。
安裝與開啟
- Unity 6.4 以後:已內建,不用另外安裝
- Unity 6.3 以前:透過 Package Manager 安裝
com.unity.project-auditor

接著開啓 Project Auditor
Window > Analysis > Project Auditor
第一次開會看到 Welcome 畫面,按下分析即可。預設的分析平台會跟 Build Settings 目前的 build target 一致,程式碼分析的對象是主要的 player assemblies。如果要切換平台或納入 package 內容,可以到 Preferences 調整。

分析結果:Summary
分析完成後會先進入 Summary 頁:

這頁可以快速看出:
- Code、Assets、Project Settings 三個區塊各有多少問題,以及嚴重程度的分布
- Top Ten Issues:工具判斷最優先處理的十個問題,點下去會跳到對應的詳細頁
- 本次分析的 Unity 版本、Project Auditor 版本與時間
Code Issues
點進 Code 區塊,可以看到所有 C# 相關的問題,每一條都有說明和建議處理方式:

一般專案在這裡掃出幾百到上千條都很正常,全部看完不實際,所以下一步要縮小範圍。將 Only Major/Critical 勾選后再看,只看每幀會跑到的程式碼

Only Major/Critical 的意思不只是「只看嚴重的」,它的篩選邏輯是:只顯示反向呼叫鏈(inverted call hierarchy)中包含 Update 方法的問題。
換句話說,留下來的都是「每幀都可能被執行」的程式碼。同樣是一次 Linq 配置,寫在 Start 裡跑一次無所謂;寫在 Update 裡,60 FPS 就是每秒 60 次配置,GC 遲早會來收。這個選項讓你直接聚焦在 ROI 最高的地方。
記憶體相關的四個問題與修法
勾選後,我把跟 managed 記憶體配置有關的幾條挑出來。
1. Boxing
Boxing happens where a value type, such as an integer, is converted into an object of reference type. This causes an allocation on the managed heap.
值型別(int、float、struct、enum)被轉成 object 或介面時,會在 managed heap 上配置一個新物件。這種配置很隱蔽,程式碼看起來完全正常。
常見來源:
// 字串格式化,int 被裝箱成 object
label.text = string.Format("HP: {0}", hp);
// 參數型別是 object
Debug.Log(score);
// struct 透過介面呼叫
IComparable c = myStruct;
改寫方向:
// 避免 object 參數,或在數值變動時才更新文字
if (hp != _lastHp)
{
_lastHp = hp;
label.text = "HP: " + hp.ToString();
}
// 自己寫的 API 用泛型取代 object
void Process<T>(T value) where T : struct { ... }
UI 數字每幀刷新是很常見的 Boxing 來源,加一個「有變才更新」的判斷,配置次數會降很多。
2. Linq
Linq allocates large amounts of managed memory and exhibits poor CPU performance.
Linq 寫起來很乾淨,但每次呼叫都會產生 enumerator、委派(lambda 有捕捉變數時還有 closure),ToList() / ToArray() 又是一次新的集合配置。
// 每幀配置:Where 的 enumerator + closure + ToList 的新 List
void Update()
{
var alive = enemies.Where(e => e.hp > 0).ToList();
foreach (var e in alive) e.Tick();
}
改寫:
private readonly List<Enemy> _alive = new List<Enemy>(64);
void Update()
{
_alive.Clear();
for (int i = 0; i < enemies.Count; i++)
{
if (enemies[i].hp > 0) _alive.Add(enemies[i]);
}
for (int i = 0; i < _alive.Count; i++) _alive[i].Tick();
}
原則是:Linq 留給初始化、Editor 工具、低頻邏輯;每幀路徑上改成 for 迴圈加上重複使用的容器。
3. Animator.GetCurrentAnimatorClipInfo()
Animator.GetCurrentAnimatorClipInfo() allocates managed memory.
這個 API 每次呼叫都回傳一個新的陣列。
// 每次都 new 一個 AnimatorClipInfo[]
var clips = animator.GetCurrentAnimatorClipInfo(0);
改寫:用帶 List 參數的版本,把結果寫進自己持有的容器:
private readonly List<AnimatorClipInfo> _clipInfos = new List<AnimatorClipInfo>(4);
void Update()
{
animator.GetCurrentAnimatorClipInfo(0, _clipInfos);
if (_clipInfos.Count > 0) { /* ... */ }
}
如果其實只是要判斷目前狀態或播放進度,GetCurrentAnimatorStateInfo(0) 回傳的是 struct,不會產生配置,搭配 IsName() 或 normalizedTime 通常就夠了。
4. Component.GetComponentsInChildren()
Component.GetComponentsInChildren() allocates managed memory.
和上一條一樣,回傳陣列的版本每次都會配置;而且它要走訪整棵子物件樹,CPU 成本也不低。
改寫方式有兩種,優先順序如下:
// 方案一:能快取就快取(子物件結構不會變動時)
private Renderer[] _renderers;
void Awake()
{
_renderers = GetComponentsInChildren<Renderer>();
}
// 方案二:結構會變、必須重新查詢時,用 List 版本重複使用容器
private readonly List<Renderer> _rendererBuffer = new List<Renderer>(32);
void Refresh()
{
GetComponentsInChildren(_rendererBuffer);
}
GetComponents、GetComponentsInParent 也有同樣的 List 重載,可以用同一套做法。
匯出與追蹤
每個列表都可以匯出成 CSV,可以選全部、只匯出目前篩選結果,或只匯出選取的項目。我的做法是:
- 勾選 Only Major/Critical,匯出 Filtered 結果
- 按照檔案或模組分給負責的人
- 修完重新跑一次分析,比對數量變化
但如果不打算處理的項目,例如確定只在 Editor 跑的程式碼,則可以在右側 Details 面板選 Ignore Issue,下次就不會干擾。
但它是靜態分析,不是 Profiler,所以使用時要注意幾點:
- 可能報到實際不會打包的程式碼:官方文件明確提到,它會回報可能在 build 時被剝除的程式碼
- 呼叫鏈是推斷出來的:Only Major/Critical 判斷的是「可能」從 Update 被呼叫,不代表實際每幀都會跑到
- 不知道實際頻率和量級:一個每幀配置 20 bytes 的地方,和一個每幀配置 20 KB 的地方,在列表上看起來一樣
所以比較好的流程是:Project Auditor 撒網找候選 → Profiler(GC.Alloc 欄位)或 Memory Profiler 在實機上確認 → 按實際影響排序處理。