目前的專案做到一段落了,然後在發佈時還是遇到記憶體和 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

14083-2y0k845vso.png

接著開啓 Project Auditor

Window > Analysis > Project Auditor

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

97031-g0lrgqaxor.png

分析結果:Summary

分析完成後會先進入 Summary 頁:
30832-2e5lkabbdgv.png

這頁可以快速看出:

  • Code、Assets、Project Settings 三個區塊各有多少問題,以及嚴重程度的分布
  • Top Ten Issues:工具判斷最優先處理的十個問題,點下去會跳到對應的詳細頁
  • 本次分析的 Unity 版本、Project Auditor 版本與時間

Code Issues

點進 Code 區塊,可以看到所有 C# 相關的問題,每一條都有說明和建議處理方式:

75877-fz0k1x4wymg.png

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

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,可以選全部、只匯出目前篩選結果,或只匯出選取的項目。我的做法是:

  1. 勾選 Only Major/Critical,匯出 Filtered 結果
  2. 按照檔案或模組分給負責的人
  3. 修完重新跑一次分析,比對數量變化

但如果不打算處理的項目,例如確定只在 Editor 跑的程式碼,則可以在右側 Details 面板選 Ignore Issue,下次就不會干擾。

但它是靜態分析,不是 Profiler,所以使用時要注意幾點:

  • 可能報到實際不會打包的程式碼:官方文件明確提到,它會回報可能在 build 時被剝除的程式碼
  • 呼叫鏈是推斷出來的:Only Major/Critical 判斷的是「可能」從 Update 被呼叫,不代表實際每幀都會跑到
  • 不知道實際頻率和量級:一個每幀配置 20 bytes 的地方,和一個每幀配置 20 KB 的地方,在列表上看起來一樣

所以比較好的流程是:Project Auditor 撒網找候選 → Profiler(GC.Alloc 欄位)或 Memory Profiler 在實機上確認 → 按實際影響排序處理。

無標籤

關注作者:

新增評論