Phase 3: AssetHub 大改造 — 從繼承地獄到 Provider 天堂
這次做了什麼?
這次要聊的是一個超級大工程 — AssetHub 模組的完整重寫!
AssetHub 是什麼?簡單來說,它就是「遊戲資源的統一載入系統」。你想載入一個音效、一張圖片、一個 Prefab?都跟 AssetHub 要就對了。它會幫你處理所有載入邏輯,你只需要告訴它「我要哪個資源」,它就會從正確的地方把資源拿給你。
聽起來很簡單對吧?但魔鬼藏在細節裡 — 資源可能來自 Resources 資料夾、可能是 AssetBundle、可能是 Addressable、甚至可能是動態產生的。每種來源的載入方式都不一樣,要怎麼統一管理?
這就是 AssetHub 要解決的問題,也是這次重寫的核心挑戰!
這次完成了什麼
全新的架構設計:
- 拋棄了原本的多層繼承 AssetLink 體系
- 採用扁平化的
AssetHubEntry+ 可插拔式 Provider/Registrar 模式 - Runtime 提供
AssetProvider基底類別(ResourceProvider、CacheProvider 內建實作) - Editor 提供
AssetRegistrar基底類別(ResourceRegistrar、CacheRegistrar 內建實作) - 透過
TypeResolver.FindDerivedTypes<T>()自動探索所有擴充實作,完全不用手動註冊!
核心功能系統:
- 引用計數機制(Load/Release 追蹤)
FormerProviderNameAttribute屬性支援向下相容的 Provider 重新命名- Setting 系統整合(AssetHubSetting.cs + AssetHubTable.setting)
編輯器工具(AssetHubEditor):
- 基於
ReorderableTable<EditEntry>建構,包含 6 個 Column 類別 - 完整中文化介面(儲存設定、載入設定、新增空項、加入選取項目)
- 搜尋欄過濾功能(支援 Label、Description、物件名稱)
- 批次資料夾匯入支援(拖曳資料夾自動遞迴加入所有資源)
- 拖放功能透過
IDragDropReceiver介面實作 - 警告圖示提示無效狀態(無 Key、遺失資源)
AI 資源探索功能:
- 新增
description、assetType、assetPath中繼資料欄位 - 建立
AssetHubManifestGenerator自動產生UserSettings/ASSET_MANIFEST.md - Manifest 包含每個資源的 Description、Type、Provider、Path 資訊
- 讓 AI 能夠理解專案中有哪些可用資源!
完整文件:
- 建立
ASSETHUB_EXTENSION_GUIDE.md擴充開發指南 - 建立
AssetHubExample.cs使用範例
統計數字
- 提交檔案: 16 個(5 個 Editor + 11 個 Runtime)
- 總進度: 已完成 115 個檔案(48 Editor + 67 Runtime)
- 剩餘: 70 個檔案(21 Editor + 49 Runtime,包含 4 個跳過)
- 已刪除: 16 個檔案(MapID、Localization 等棄用模組)
這是目前為止單一模組檔案數最多的一次提交!
用了哪些技術?
技術亮點一:Provider 模式 — 從繼承樹到插件系統
這是整個重寫的核心!
原本的設計:
舊版用的是多層繼承的 AssetLink 體系:
AssetLink (抽象基底)
├─ ResourceLink
├─ AssetBundleLink
├─ AddressableLink
└─ CustomLink
每種載入方式都是一個繼承類別,聽起來很物件導向對吧?
問題在哪?
- 擴充困難 — 每次新增載入方式都要改 AssetHub 核心程式碼
- 序列化複雜 — 多型序列化超級麻煩,容易出錯
- Editor 與 Runtime 混在一起 — 編輯器邏輯和執行階段邏輯糾纏不清
- 重複程式碼 — 每個子類別都要處理相似的載入/卸載邏輯
新的設計:Provider/Registrar 模式
我把整個架構改成這樣:
Runtime 端:
AssetHubEntry (單一資料類別,只存資料,不含邏輯)
├─ providerTypeName: string
├─ label: string
├─ description: string
├─ assetType: string
└─ ...
AssetProvider (抽象基底)
├─ Load()
├─ Release()
└─ IsLoaded()
ResourceProvider : AssetProvider
CacheProvider : AssetProvider
(任何人都可以寫自己的 Provider!)
Editor 端:
AssetRegistrar (抽象基底)
├─ Register()
└─ GetAssetType()
ResourceRegistrar : AssetRegistrar
CacheRegistrar : AssetRegistrar
(任何人都可以寫自己的 Registrar!)
魔法在哪?
- AssetHubEntry 變成純資料 — 只存「這個資源叫什麼」「用哪個 Provider 載入」,不管「怎麼載入」
- Provider 負責 Runtime 載入邏輯 — 每個 Provider 專注做好自己的事
- Registrar 負責 Editor 註冊邏輯 — 編輯器程式碼和執行階段完全分離
- 自動探索機制 — 用
TypeResolver.FindDerivedTypes<AssetProvider>()掃描所有 Provider 實作,完全不用手動註冊
這樣的設計有什麼好處?
- 想新增 AddressableProvider?寫一個類別繼承 AssetProvider,完成!
- 想新增 Editor 支援?寫一個類別繼承 AssetRegistrar,完成!
- AssetHub 核心程式碼完全不用改!
這就是「開放封閉原則」的實踐 — 對擴充開放,對修改封閉。
技術亮點二:ReorderableTable 的深度應用
AssetHubEditor 的 UI 是用 ReorderableTable<EditEntry> 建構的,這是我在 Phase 1 時完成的表格工具。
這次是它第一次真正投入實戰,而且是超級複雜的實戰!
挑戰:
AssetHub 的每一行資料都包含:
- ID(唯讀)
- Label(可編輯文字)
- Description(可編輯文字)
- Provider 下拉選單(動態內容)
- Object 欄位(拖放資源)
- Info 欄位(顯示路徑或警告)
每個欄位的繪製邏輯都不一樣,而且還要處理拖放、搜尋過濾、警告提示等功能。
解決方案:
我為每個欄位建立了一個 Column 類別:
IDColumn— 繪製唯讀 IDLabelColumn— 可編輯文字 + 搜尋過濾DescriptionColumn— 可編輯文字 + 搜尋過濾ProviderColumn— 動態 Provider 下拉選單ObjectColumn— 拖放資源 + 搜尋過濾InfoColumn— 路徑顯示 + 警告圖示
每個 Column 專注做好自己的事,Table 只負責排版和捲動。
這個設計讓整個 Editor 視窗的程式碼結構超級清晰,每次要改某個欄位的行為,直接找對應的 Column 類別就好,完全不會動到其他部分!
技術亮點三:AI 友善的資源探索
這是一個很特別的功能 — 讓 AI 能夠「看見」你的專案資源。
問題背景:
在跟 AI 協作開發時,我常常遇到這個情況:
我:「幫我載入爆炸特效」 AI:「好的,請問資源的路徑是?」 我:「呃…等我去找一下…」
AI 不知道我的專案裡有哪些資源,所以每次都要我手動提供路徑或資源名稱。
解決方案:
我在 AssetHubEntry 加入了三個中繼資料欄位:
description— 資源的人類可讀描述assetType— 資源類型(Prefab、AudioClip、Texture2D 等)assetPath— 資源在專案中的路徑
然後建立了一個 AssetHubManifestGenerator,它會掃描所有 AssetHubEntry,產生一個 Markdown 檔案:
# AssetHub Manifest
## explosion_vfx
- Description: 紅色爆炸特效,適合戰鬥場景
- Type: Prefab
- Provider: ResourceProvider
- Path: Assets/VFX/Explosion.prefab
## bgm_battle
- Description: 戰鬥背景音樂
- Type: AudioClip
- Provider: ResourceProvider
- Path: Assets/Audio/BGM_Battle.mp3這個檔案會放在 UserSettings/ASSET_MANIFEST.md,然後在專案說明文件裡告訴 AI:「想知道有哪些資源可用,請查看 ASSET_MANIFEST.md」。
從此以後,AI 就能直接查表找資源了!
這個功能的靈感來自「怎麼讓 AI 更好地理解專案」的思考 — 與其每次都手動告訴它,不如建立一個機器可讀的資源清單,讓它自己查。
技術亮點四:FormerProviderName 的向下相容
這是一個很貼心的小功能。
問題:
假設我今天把 ResourceProvider 重新命名成 UnityResourceProvider,會發生什麼事?
所有舊的 Setting 檔案裡都寫著 "providerTypeName": "ResourceProvider",載入時就會找不到對應的 Provider,資源全部掛掉!
解決方案:
我設計了一個 FormerProviderNameAttribute:
[FormerProviderName("ResourceProvider", "OldResourceProvider")]
public class UnityResourceProvider : AssetProvider
{
// ...
}當 AssetHub 載入 Setting 時,如果找不到 "ResourceProvider",就會去找有沒有哪個 Provider 標記了 FormerProviderName("ResourceProvider"),如果有就用那個!
這樣重新命名 Provider 就不會破壞舊專案的相容性,非常實用!
遇到什麼挑戰?
挑戰一:命名大作戰 — Assets vs AssetKeys
在設計程式碼產生器時,我遇到了一個命名衝突問題。
AssetHub 會自動產生一個常數類別,讓你用強型別的方式存取資源:
AssetHub.Load<AudioClip>(???.explosion);問號應該填什麼?
AssetKeys.explosion?但 AssetCollector 模組已經用了AssetKeys這個名字!AssetHubKeys.explosion?太長了,每次都要打一堆字Hub.explosion?太短太籠統,容易跟其他系統混淆
最後我選擇了 Assets 這個名字:
AssetHub.Load<AudioClip>(Assets.explosion);簡潔、清楚、語意明確,而且跟 AssetCollector 的 AssetKeys 不衝突!
有時候命名真的比寫邏輯還難…
挑戰二:InfoColumn 的 NullReferenceException
這是一個經典的「粗心大意」錯誤。
在實作 InfoColumn 時,我在建構子裡忘記指派一個欄位:
public InfoColumn(...)
{
// 忘記寫: this.iconContent = ...
}
public override void DrawColumn(...)
{
// 這裡用到 iconContent → 爆炸!
GUI.Label(rect, iconContent);
}結果一跑就 NullReferenceException,Unity 編輯器直接當掉。
Debug 了 10 分鐘才發現是建構子漏了一行…
教訓: 寫完建構子立刻檢查「所有欄位都指派了嗎?」,可以省下很多 Debug 時間。
挑戰三:拖放的雙重觸發問題
這個 Bug 超級詭異!
AssetHubEditor 支援兩種拖放方式:
- 拖曳物件到 ObjectField
- 拖曳物件到整個視窗(透過 IDragDropReceiver)
問題來了:當你拖曳物件到 ObjectField 時,兩個系統都會觸發!
結果就是:拖一個物件,清單裡出現兩筆資料…
原因:
ObjectField 本身會處理拖放事件,但它處理完後不會標記事件為已消費,所以事件繼續往上傳,被 IDragDropReceiver 再處理一次。
解決方案:
在 ObjectColumn 的 DrawColumn 裡,檢查當前事件:
if (Event.current.type == EventType.DragPerform)
{
// ObjectField 的拖放事件,標記為已消費
Event.current.Use();
}這樣 IDragDropReceiver 就不會重複處理了!
教訓: Unity 的事件系統有時候很隱晦,多個系統處理同一事件時要特別小心事件傳播機制。
挑戰四:架構設計的反覆推敲
這次重寫 AssetHub,光是架構設計就討論了很久。
一開始我想過幾個方案:
方案一:繼續用繼承,但簡化層級
- 優點:熟悉的 OOP 模式
- 缺點:根本問題沒解決,還是難擴充
方案二:用介面(IAssetLink)取代抽象類別
- 優點:更靈活
- 缺點:沒有共用程式碼,每個實作都要重複寫
方案三:完全拋棄繼承,改用組合(Composition)
- 優點:最靈活
- 缺點:太複雜,反而降低可讀性
最終方案:Provider 模式(抽象類別 + 自動探索)
- 優點:保留繼承的共用程式碼優勢,又有介面的擴充彈性
- 優點:自動探索機制讓擴充無痛
- 缺點:需要反射機制(但效能影響可接受,因為只在初始化時用)
選擇 Provider 模式是因為它在「易用性」和「擴充性」之間取得了最好的平衡。
這個過程讓我體會到:沒有完美的架構,只有最適合當前需求的架構。
下一步
AssetHub 模組算是完整收工了!接下來的計畫:
- 提交 Extension 三兄弟 — CollectionExtension、MathExtension、TransformExtension 已經寫好很久,該讓它們出山了
- 攻克其他大型模組 — Service、Flow、Stage、DataEditor、Console 等
- 整合測試 — 隨著模組越來越多,要確保它們協同工作沒問題
- 撰寫更多範例 — 好的範例勝過千言萬語
目前進度大約 62%(115/185 個檔案),而且這次搞定了一個超級大模組,成就感滿滿!
這次的 AssetHub 重寫讓我學到:好的架構不是一開始就想出來的,而是在不斷推敲、比較、試錯中逐漸成形的。 Provider 模式雖然聽起來簡單,但它解決了繼承體系的所有痛點,而且還保留了擴充的靈活性。
更重要的是,加入「AI 友善」的設計思維,讓工具不只是給人用,也能讓 AI 更好地協助開發,這種「人機協作」的思考方式會是未來開發工具的趨勢!
我們下次見!
→ 繼續閱讀:[[專案/EAS Foundation/日誌/#04-文件大整頓-當理想碰上現實的對帳時刻|#04 文件大整頓]]