Phase 6: UI 元件大翻修 — 從零散工具到統一介面

這次做了什麼?

這次的重點是 收尾與打磨 — 繼續完善 Phase 5 建立的編輯器工具,然後處理了一批零散的 UI 擴充元件。

1. DirectoryTreeDrawer 體驗優化

上次做完樹狀檢視器後,發現測試起來還是有點不順手。這次做了幾個小改進:

  • 路徑選擇改用帶資料夾圖示的按鈕 — 用 EditorKit.FolderPathField 取代手動輸入,點一下就能選擇資料夾
  • 選完自動刷新 — 不用再手動按「掃描」按鈕,選好路徑馬上就能看到樹狀結構
  • 自動過濾 .meta 檔 — Unity 的 .meta 檔對使用者來說沒什麼意義,直接濾掉讓樹更乾淨
  • 顯示邏輯調整 — 以選中的資料夾為根節點,不顯示完整路徑前綴,看起來更簡潔
  • 修正縮排問題 — 同層級的檔案縮排不應該比資料夾多一層,調整後視覺層次更清晰

2. 檔案 Icon 分類系統

在測試時發現,有些檔案類型沒有對應的 icon,會顯示白紙圖示,看起來很突兀。像是 .renderTexture.fbx 這些 Unity 很常見的格式竟然都沒處理。

所以我把副檔名轉 icon 的邏輯整個封裝到 EditorKit 作為公開靜態方法,並且補齊了大量的檔案類型映射:

  • 3D Model — .fbx, .obj, .blend, .ma, .mb, .max
  • Rendering — .renderTexture, .cubemap, .flare
  • Audio — .mp3, .wav, .ogg, .aif, .aiff
  • Video — .mp4, .mov, .avi, .webm
  • Font — .ttf, .otf, .fnt
  • Physics — .physicMaterial, .physicsMaterial2D
  • GUI — .guiskin, .fontsettings

這樣不管什麼檔案類型都能顯示正確的 icon 了。

3. EditorKit.IconLabel 封裝

發現「畫一個帶 icon 的 label」這個需求很常見,所以把 DrawIcon + GUILayout.Button 的組合封裝成 EditorKit.IconLabel(icon, text, iconSize) 靜態方法,回傳 bool 表示是否被點擊。

DirectoryTreeDrawer 移除了本地的 DrawIcon 方法,全部委託給 EditorKit。這樣以後其他編輯器工具也能直接用,不用重複寫繪製邏輯。

4. LongDataParser 邏輯精簡

這是一個用來切分長字串的工具類別,原本的切分邏輯有點囉嗦,用了一堆 if-else 判斷剩餘長度。

重寫後用 ceiling division 來計算需要切幾段,用 Math.Min 統一 Substring 的邊界判斷,整個邏輯變得清爽很多。雖然功能沒變,但可讀性提升了不少。

5. SerializeDic 字典繪製器改進

上次做完可序列化字典後,發現在 Inspector 裡新增項目時,如果 key 重複,新項目會憑空消失 — 這是 Unity 序列化系統的保護機制,但對使用者來說超級困惑。

解決方法是在新增時自動產生 不重複的預設 key:

  • 如果是 string 類型,預設 “NewKey”,重複就改成 “NewKey1”、“NewKey2” 遞增
  • 如果是 int 類型,預設 0,重複就改成 1、2、3 遞增
  • 如果是 float 類型,預設 0.0,重複就改成 1.0、2.0、3.0 遞增

這樣就不會有新項目消失的問題,而且使用者可以立刻看到新增成功,體驗好很多。

6. Utility 模組大清理

這次重點處理了 Utility 模組 裡四個零散的 UI 元件,逐一檢視後決定:

移除的元件

  • MeshClosure — 專案特定的網格封閉工具,不適合放在通用框架裡
  • PushButtonSwitch — 功能與 Unity 內建的 Toggle 元件重複,沒有存在必要

重寫的元件

SliderInputLinker — 原本的設計很混亂,完全重寫成符合 Unity UI 慣例的 API:

  • value/minValue/maxValue/interactable 屬性,與 Slider API 保持一致
  • SetValueWithoutNotify() 方法,可以靜默設值不觸發事件
  • decimalPlaces 控制顯示精度,預設 2 位小數
  • 雙向同步 Slider ↔ InputField ↔ 程式碼,統一走 SetValue(value, notify) 內部方法

UIDrag — 原本用 Update 輪詢滑鼠位置,效能不好且不符合 Unity 事件系統的設計。重寫後:

  • 改用 IBeginDragHandler/IDragHandler/IEndDragHandler 介面
  • handle/target 防呆 — 未指定時預設為元件自身
  • clampToScreen 開關,可選擇是否限制在螢幕邊界內
  • 註明僅適用 Screen Space - Overlay Canvas,避免誤用

用了哪些技術?

Unity UI Event System

UIDrag 的重寫是這次技術上最有趣的部分。Unity 的 UI 系統提供了一套 事件介面,可以讓你的元件響應各種互動事件。

常見的介面包括:

  • IPointerClickHandler — 點擊事件
  • IPointerEnterHandler / IPointerExitHandler — 滑鼠進入/離開
  • IBeginDragHandler / IDragHandler / IEndDragHandler — 拖曳事件

原本的 UIDrag 是在 Update() 裡每幀檢查 Input.GetMouseButton(0),然後手動計算滑鼠位置變化。這種做法有幾個問題:

  1. 效能浪費 — 即使沒有在拖曳,也會每幀執行檢查
  2. 不符合事件驅動設計 — Unity UI 系統本來就有拖曳事件,繞過它等於重新發明輪子
  3. 無法與其他 UI 元件整合 — EventSystem 不知道你在拖曳,可能會同時觸發其他互動

重寫後直接實作 IDragHandler,讓 EventSystem 來通知你「現在有人在拖曳」,你只需要在事件觸發時更新位置就好。這樣不僅效能更好,也能跟其他 UI 元件和平共存。

Screen Space 與 World Space 的座標轉換

UIDrag 有一個重要的限制 — 僅適用 Screen Space - Overlay Canvas

Unity 的 Canvas 有三種模式:

  • Screen Space - Overlay — 直接畫在螢幕上,座標系統是螢幕像素
  • Screen Space - Camera — 畫在相機前面一定距離,需要考慮相機的投影
  • World Space — 畫在 3D 世界裡,完全由 Transform 控制

原本的 UIDrag 假設使用者在 Overlay 模式下使用,所以直接用 transform.position += delta 來移動 UI。這在 Overlay 模式下沒問題,但在 Camera 或 World Space 模式下會完全錯亂。

我本來想支援所有三種模式,但發現需要針對每種模式寫不同的座標轉換邏輯,複雜度暴增。最後決定 專注於最常見的使用情境,在文件裡明確標註「僅支援 Overlay」,避免使用者踩坑。

PropertyDrawer 的狀態持久化

SerializeDicDrawer 裡有一個 Foldout 可以展開/收合字典內容。這個展開狀態需要記住,不然每次重繪 Inspector 都會自動收起來,體驗很差。

Unity 的 PropertyDrawer 是無狀態的 — 每次繪製都會建立新的 Drawer 實例。那展開狀態要存在哪裡?

答案是用 EditorPrefs — Unity 的編輯器設定儲存系統。你可以用一個唯一的 key 來存取狀態,即使 Drawer 被銷毀再重建,狀態也能保留。

不過 EditorPrefs 是全域的,所以 key 要小心設計,避免不同物件的 Drawer 互相干擾。我用 property.propertyPath 作為 key 的一部分,這樣每個不同的字典欄位都會有獨立的展開狀態。

API 設計的一致性原則

SliderInputLinker 的重寫體現了一個重要的設計原則 — API 應該與使用者的既有知識一致

原本的設計有自己的一套屬性命名方式,跟 Unity 內建的 Slider 完全不同。這意味著使用者需要學習一套新的 API,而且無法直接套用操作 Slider 的經驗。

重寫後刻意模仿 Unity Slider 的 API:

  • 同樣的屬性名稱 — value, minValue, maxValue, interactable
  • 同樣的方法命名模式 — SetValueWithoutNotify() 跟 Slider 一模一樣
  • 同樣的事件機制 — onValueChanged UnityEvent

這樣做的好處是 降低學習成本。使用者如果會用 Slider,就能無痛上手 SliderInputLinker,不需要查文件就能猜到怎麼用。

這種「向既有 API 看齊」的設計理念,在做工具類別時特別重要。

遇到什麼挑戰?

挑戰 1: 該不該移除舊元件?

Utility 模組裡有些元件功能很特殊,一看就知道是為了某個特定專案寫的。問題是:要不要把它們移除?

MeshClosure 就是個典型案例。它的功能是自動封閉 3D 網格的開放邊緣,聽起來很實用,但仔細看會發現它高度依賴專案的特定網格結構,換個專案根本用不了。

我一開始猶豫了一下 — 萬一以後有類似的需求怎麼辦?會不會刪掉之後又要重寫?

後來想通了:通用框架就是要專注於通用需求。如果一個功能只在特定情境下才有用,就不應該放在框架裡。真的需要時,可以從版本歷史裡撈回來改,或者重新寫一個更通用的版本。

所以我狠下心把 MeshClosure 和 PushButtonSwitch 都刪了。刪完之後整個模組清爽很多,剩下的都是真正有用的東西。

挑戰 2: UIDrag 的座標系統困境

重寫 UIDrag 時遇到最大的挑戰就是 要不要支援所有 Canvas 模式

我花了不少時間研究 Unity 的座標轉換 API,發現要支援 Camera 和 World Space 模式,需要用到 RectTransformUtility.ScreenPointToLocalPointInRectangle 這類複雜的轉換函數。

而且不同模式下,拖曳的行為邏輯也不太一樣:

  • Overlay — 直接加上滑鼠移動量就好
  • Camera — 需要考慮相機的視角和距離
  • World Space — 還要考慮 Canvas 的旋轉和縮放

我寫了一半發現 — 這根本是三個不同的元件啊!

硬要把三種模式塞進同一個類別裡,只會讓程式碼變得超級複雜,而且大部分使用者根本用不到 Camera 和 World Space 模式。

所以我做了一個取捨:只支援最常用的 Overlay 模式,在註解裡明確標註限制。這樣程式碼簡潔,使用者也清楚知道適用範圍,避免誤用後出現奇怪的 bug。

有時候 少即是多 — 專注做好一件事,比什麼都想做結果什麼都做不好要強得多。

挑戰 3: SliderInputLinker 的雙向同步陷阱

SliderInputLinker 的核心功能是 雙向同步 — Slider 改變時要更新 InputField,InputField 改變時要更新 Slider,程式碼改變時兩個都要更新。

這聽起來很簡單,但實作時會遇到一個經典的問題:循環觸發

假設你在 Slider 的 onValueChanged 事件裡更新 InputField,而 InputField 的 onValueChanged 又會更新 Slider,結果就是無限循環觸發事件,整個系統當掉。

我的解決方案是 內部統一走一個 SetValue 方法,這個方法有一個 notify 參數控制是否觸發事件:

SetValue(float value, bool notify)
  ├─ 更新內部狀態
  ├─ 靜默更新 Slider (不觸發事件)
  ├─ 靜默更新 InputField (不觸發事件)
  └─ 如果 notify == true,才觸發 onValueChanged

這樣不管從哪裡改變數值,都會走這個統一的入口點,不會有循環觸發的問題。而且對外提供 SetValueWithoutNotify() 方法,讓使用者可以選擇要不要觸發事件,跟 Unity 內建元件的設計保持一致。

挑戰 4: 自動產生不重複 Key 的邊界情況

SerializeDic 在新增項目時要自動產生不重複的 key,但這有一個潛在的問題:如果所有可能的 key 都用完了怎麼辦?

雖然這種情況極少發生(誰會在字典裡加幾千個 “NewKey1”、“NewKey2”…),但作為一個工具類別,還是要考慮邊界情況。

我加了一個 安全上限 — 最多嘗試 1000 次。如果嘗試 1000 次還找不到不重複的 key,就放棄新增,並在 Console 印出警告。

這樣既能處理正常使用情境,也能避免極端情況下的無限迴圈。雖然 1000 次聽起來很多,但對於電腦來說只是幾毫秒的事,不會影響效能。

挑戰 5: 縮排的微調地獄

DirectoryTreeDrawer 的縮排問題看起來很小,但調起來超級煩。

一開始我發現同層級的檔案比資料夾多縮排了一層,看起來很奇怪。追蹤程式碼發現是計算公式的問題:

原本: depth * IndentWidth + IndentWidth + 12
修正: depth * IndentWidth + 12

多加的 IndentWidth 本來是想讓檔案比資料夾再縮排一層,但視覺效果反而變亂了。

問題是,這種 UI 微調很難用文字描述清楚,只能反覆調整、截圖對比,直到「看起來對了」為止。

我大概調了五、六次,每次都覺得「應該好了」,結果用不同的目錄結構測試又發現有問題。最後才找到一個各種情況下都看起來舒服的數值。

這讓我深刻體會到 UI 設計真的是一門藝術 — 差幾個像素,整個感覺就完全不同。

學到了什麼?

經驗 1: 框架設計要敢於取捨

做通用框架時,很容易陷入「什麼都想支援」的陷阱。但實際上,專注於最常見的使用情境 才是正確的策略。

像 UIDrag 只支援 Overlay 模式,雖然限制了適用範圍,但換來的是程式碼的簡潔和可靠性。使用者寧願有一個功能明確、可靠穩定的工具,也不想要一個什麼都能做但處處是坑的萬用工具。

經驗 2: API 一致性比創新更重要

SliderInputLinker 的重寫讓我意識到,設計工具 API 時,向既有標準看齊 比自己發明一套新的更重要。

使用者已經熟悉 Unity 內建元件的 API 風格,如果你的工具能保持一致,學習成本幾乎是零。相反的,如果你自己發明一套命名規則,使用者每次都要查文件,體驗就會變差。

經驗 3: 事件驅動 vs 輪詢的效能差異

UIDrag 從 Update 輪詢改成事件驅動後,不僅程式碼更清晰,效能也有明顯提升。

Update 輪詢的問題是 即使沒有互動也在跑,浪費 CPU 時間。事件驅動則是 有事才做事,沒有拖曳時完全不佔用資源。

這個經驗可以推廣到很多場景:能用事件就不要用輪詢。雖然輪詢寫起來比較直覺,但長期來看事件驅動的設計更優雅、更高效。

經驗 4: 工具類別需要防呆機制

SerializeDic 的重複 key 處理、SliderInputLinker 的循環觸發防護,這些都是 防呆機制

好的工具類別不只是功能正確就好,還要考慮使用者可能的錯誤操作,並提供合理的容錯機制。這些小細節看起來不起眼,但能大幅提升使用體驗。

經驗 5: UI 微調需要實際測試

縮排、對齊、間距這些 UI 細節,光看程式碼是調不好的,一定要實際跑起來看

而且要用不同的資料來測試 — 不同深度的樹、不同長度的檔名、不同類型的檔案,確保各種情況下都能正常顯示。

這也是為什麼測試視窗很重要 — 有了方便的測試工具,你就能快速迭代 UI 設計,不用每次都重新編譯整個專案。

目前進度

這次的提交包含了:

  • Commit 1bf843f: LongDataParser 長資料切分器與 SerializeDic 可序列化字典 (6 檔案, 250 行)
  • Commit 0c75d09: SliderInputLinker 與 UIDrag UI 擴充元件 (4 檔案, 256 行)

加上 Phase 5 的進度,目前整體狀態:

  • 已提交: 約 151 個 .cs 檔
  • 未提交: 約 34 個 .cs 檔
  • 已移除: 約 20 個不適用檔案

換算下來,大約完成了 82% 的檔案重寫工作 — 離完工真的很近了!

下一步

Editor 工具和 UI 元件的部分基本上告一段落了。接下來應該要:

  1. 回到核心系統 — Service、Event、Flow 這些框架的核心機制還沒處理
  2. 整合測試 — 確保所有重寫的模組能正常運作,不會有相依性問題
  3. 文件補完 — 把重要的 API 和使用範例整理成文件

總之,終點線已經在前方了。保持這個節奏,應該很快就能完成整個框架的重構!

→ 繼續閱讀:[[專案/EAS Foundation/日誌/#07-Extension擴充方法大掃除-從9個檔案到7個精簡模組|#07 Extension 擴充方法大掃除]]


返回 EAS Foundation 專案主頁