從「萬能單例」到「優雅架構」的蛻變之路
📚 目錄
- 前言:單例模式的愛恨糾葛
- 為什麼單例模式在 Unity 中這麼受歡迎?
- 單例模式的真實問題:看似簡單的災難
- 更好的替代方案:跨場景物件管理器
- 實戰改造:從單例地獄到清爽架構
- 不同場景的最佳實踐
- 總結與學習資源推薦
前言:單例模式的愛恨糾葛
在我三年的遊戲前端工程師經驗中,單例模式絕對是最具爭議的設計模式。一方面,它解決了 Unity 開發中的實際問題;另一方面,它又被很多資深工程師稱為「反模式」。
記得剛入行時,我被單例模式的簡單易用給吸引了。音效管理器、遊戲管理器、玩家資料管理器...什麼都用單例。「全域存取,一行代碼搞定」,感覺超爽!但隨著專案越來越大,我開始體會到單例模式帶來的痛苦:測試困難、依賴混亂、除錯噩夢。
直到學會用 Persistent Object Spawner 替代單例模式,整個開發世界都變得清爽了。今天想分享這套「告別單例」的實戰方案,讓你的程式碼從混亂走向優雅。
為什麼單例模式在 Unity 中這麼受歡迎?
🎯 Unity 開發的特殊需求
Unity 有個特殊情況:很多管理器需要在整個遊戲過程中持續存在,而且要跨場景使用。
存檔管理器:玩家進度要在切換場景時保持不丟失 遊戲設定管理器:玩家的音量、畫質設定要全域可用 玩家資料管理器:金幣、經驗、成就要隨時可以存取 網路管理器:連線狀態要在所有場景都能檢查
傳統的 Unity 物件會在場景切換時被銷毀,所以單例模式看起來是完美解決方案。
🔧 單例模式的誘人之處
全域存取:在任何地方都能用 SaveDataManager.Instance 存取 自動管理:確保只有一個實例,不用擔心重複建立 跨場景持續:配合 DontDestroyOnLoad 可以保持不被銷毀 簡單易懂:概念簡單,新手也能快速上手
看起來很完美,對吧?但實際上是個美麗的陷阱。
🎮 典型的單例使用場景
// 看起來很爽的單例用法
SaveDataManager.Instance.SaveProgress("level_01_completed");
GameManager.Instance.AddScore(100);
PlayerData.Instance.AddCoins(50);
一行代碼就能呼叫各種功能,簡直不要太方便!但這種便利是有代價的...
單例模式的真實問題:看似簡單的災難
🚨 測試噩夢
單例模式讓單元測試變成災難。想測試一個使用了 SaveDataManager 單例的類別?你得先初始化 SaveDataManager,但 SaveDataManager 又依賴其他單例,結果為了測試一個小功能,你得把半個遊戲系統都啟動起來。
我曾經遇過一個測試,為了測試玩家升級邏輯,要先啟動存檔管理器、成就管理器、UI 管理器、統計管理器...測試跑一次要 30 秒!
🎭 隱藏的依賴關係
單例模式最陰險的地方是隱藏依賴。看程式碼時,你根本不知道這個類別用了哪些單例。
public class PlayerController
{
public void LevelUp()
{
// 看起來很簡單的升級函數
IncreaseLevel();
// 但實際上隱藏了一堆依賴
SaveDataManager.Instance.SavePlayerLevel(newLevel);
UIManager.Instance.ShowLevelUpEffect();
GameManager.Instance.CheckAchievements();
StatisticsManager.Instance.RecordLevelUp();
}
}
這個 LevelUp 函數看起來只是增加等級,實際上依賴了 4 個單例!如果任何一個單例出問題,整個升級系統就掛了。
🎰 真實災難案例
在我參與的一個卡牌手遊專案中,我們有 15 個不同的單例管理器。看起來很有條理,實際上是災難:
啟動順序問題:GameManager 要在 SaveDataManager 之前初始化,但 SaveDataManager 又要在 SettingsManager 之後...結果我們花了一週時間調整啟動順序。
循環依賴地獄:UIManager 需要 GameManager,GameManager 需要 PlayerData,PlayerData 又需要 UIManager 來顯示數據更新...最後變成無解的循環依賴。
除錯噩夢:玩家回報存檔有問題,但存檔可能是被任何一個系統觸發的,要找出問題源頭就像大海撈針。
🏆 為什麼說單例是「假的簡單」?
單例模式給人一種「簡單」的錯覺,但實際上:
看起來簡單:一行代碼就能存取 實際上複雜:隱藏了大量的依賴關係和狀態管理 前期很爽:快速開發,立即見效 後期很痛:維護困難,除錯地獄
就像信用卡一樣,前期爽翻天,後期還債痛苦。
更好的替代方案:跨場景物件管理器
🎯 核心思想
跨場景物件管理器的概念很簡單:不要讓物件自己管理「只能有一個」,而是讓專門的管理器來處理。
傳統單例:每個管理器自己確保只有一個實例 管理器方案:一個專門的管理器負責所有需要跨場景存在的物件
🔧 設計原理
存檔管理器不再是單例,而是普通的 MonoBehaviour 遊戲管理器不再是單例,而是普通的 MonoBehaviour
跨場景物件管理器負責在遊戲開始時生成這些管理器,並確保它們跨場景持續存在
🎮 實際實作概念
設定方式:把所有需要跨場景存在的管理器做成 Prefab,在管理器中設定要生成哪些 Prefab。
自動管理:管理器在遊戲啟動時檢查是否已經生成過,沒有的話就生成,有的話就跳過。
清爽依賴:管理器之間透過正常的依賴注入或事件系統通訊,不再有隱藏的全域存取。
⚡ 立即可見的好處
測試友善:每個管理器都可以獨立測試,不需要啟動整個系統 依賴清晰:所有依賴都在建構函數或公開介面中明確顯示 除錯簡單:出問題時可以清楚追蹤調用鏈 擴展容易:新增管理器只要加到 Spawner 的設定中
實戰改造:從單例地獄到清爽架構
🔧 改造步驟
第一步:移除單例邏輯 把所有管理器的單例代碼移除,讓它們變成普通的 MonoBehaviour。
第二步:建立 Prefab 把每個管理器做成 Prefab,方便 Spawner 生成。
第三步:設定跨場景管理器 建立跨場景物件管理器,設定要生成哪些管理器 Prefab。
第四步:重構依賴 用依賴注入或事件系統替代原本的單例存取。
🎮 實際改造案例
改造前的存檔管理器:
// 單例版本:充滿靜態代碼
public class SaveDataManager : MonoBehaviour
{
private static SaveDataManager instance;
public static SaveDataManager Instance => instance;
void Awake()
{
if (instance == null) {
instance = this;
DontDestroyOnLoad(gameObject);
} else {
Destroy(gameObject);
}
}
}
改造後的存檔管理器:
// 普通版本:清爽簡潔
public class SaveDataManager : MonoBehaviour
{
public void SavePlayerProgress(PlayerData data)
{
// 專心處理存檔邏輯
}
}
跨場景物件管理器:
public class CrossSceneObjectManager : MonoBehaviour
{
[SerializeField] private GameObject[] persistentPrefabs;
private static bool hasSpawned = false;
void Awake()
{
if (!hasSpawned) {
foreach (var prefab in persistentPrefabs) {
var spawned = Instantiate(prefab);
DontDestroyOnLoad(spawned);
}
hasSpawned = true;
}
}
}
🎯 依賴注入的簡單應用
改造後的系統不再用全域存取,而是透過依賴注入:
public class PlayerController : MonoBehaviour
{
[SerializeField] private SaveDataManager saveManager;
public void LevelUp()
{
IncreaseLevel();
saveManager.SavePlayerProgress(playerData); // 明確的依賴
}
}
現在依賴關係一目了然,測試時可以輕鬆替換 saveManager。在編輯器中直接拖拉指定依賴,效能好又清楚。
不同場景的最佳實踐
🎮 小型專案:直接依賴注入
小專案不需要複雜的依賴注入框架,直接在 Inspector 中指定依賴即可:
把需要的管理器做成 SerializeField,在編輯器中拖拉指定。這樣效能好、依賴清楚、不用寫額外程式碼。
🏆 中大型專案:服務定位器模式
大專案可以結合服務定位器模式:
管理器生成後,把它們註冊到服務定位器中。其他系統透過服務定位器獲取需要的管理器,避免每次都搜尋物件。
🎰 多人遊戲:網路同步考量
多人遊戲中要特別注意哪些管理器需要在所有客戶端保持一致:
本地管理器(如 UI、存檔)可以各自獨立。遊戲狀態管理器需要透過網路同步。跨場景管理器要能區分本地和網路管理器。
📱 手遊:記憶體優化
手遊要特別注意記憶體使用,不是所有管理器都需要一直存在:
核心管理器:存檔、設定、網路等一直保持 場景管理器:只在特定場景才生成的管理器 按需載入:某些管理器可以在需要時才生成
跨場景管理器可以設定不同的生成策略,在記憶體和便利性之間找到平衡。
總結與學習資源推薦
🎯 單例模式:不是不能用,而是要看情況
很多人會問:「AudioManager 或 SlotManager 到底要不要用單例?」答案是:看專案需求和團隊情況。
可以考慮用單例的情況: 小型專案或原型開發,AudioManager 只是簡單播放音效背景音樂,團隊小不需要複雜測試,確定整個專案只會有一個這樣的系統。
不該用單例的情況: 需要支援多個類似系統(如 UI 音效 vs 遊戲音效),要做 A/B 測試不同實作,需要單元測試相關功能,多人遊戲要區分本地/網路邏輯,可能有多種玩法變化。
實戰建議: 小型專案追求快速開發,AudioManager 用單例問題不大。正式產品盡量避免,用依賴注入或跨場景管理器,為未來擴展留空間。
記住核心原則:重構單例比一開始設計好要痛苦 100 倍。如果你覺得「這個系統永遠只會有一個」,通常後來都會被需求打臉。寧可一開始多花點時間設計,也不要後期重構時痛苦。
🎯 告別單例的核心價值
從單例模式轉向跨場景物件管理器模式,帶來的不只是技術改進,更是開發思維的轉變:
從隱藏依賴到明確依賴:讓程式碼的依賴關係一目了然 從全域存取到局部注入:提高程式碼的可測試性和可維護性 從緊密耦合到鬆散耦合:讓系統更容易擴展和修改 從除錯地獄到清晰追蹤:問題發生時可以快速定位原因
記住,方便不等於正確。單例模式的方便是以犧牲程式碼品質為代價的。
💡 實踐建議
改造現有的單例系統需要循序漸進:
從最簡單的管理器開始改造,積累經驗後再處理複雜的系統。不要一次改造所有單例,風險太大。
新專案建議一開始就用跨場景物件管理器模式,避免後期重構的痛苦。
記住,好的架構不是一蹴而就的,而是在實踐中不斷優化的結果。
🚀 進階學習資源
如果想深入學習更多遊戲開發的設計模式和架構技巧,強烈推薦以下兩門課程:
📖 Programming Design Patterns For Unity: Write Better Code
這門課程深入講解在 Unity 開發中最實用的設計模式,包括觀察者模式的進階應用、單例模式的正確使用方式、工廠模式在遊戲物件建立中的應用、狀態機模式在遊戲邏輯中的實作。每個模式都有完整實戰案例和重構範例,非常適合想提升程式碼品質的 Unity 開發者。
📖 Unity C# Scripting Intermediate - Upgrade Your C# Skills
這門課程專注於提升 C# 程式設計技能,幫你掌握中級開發者必備技能:升級 C# 腳本技能、實作不同的資料結構、向量數學的學習與實作、精通物件池技術、四元數的清晰概念、物件導向程式設計精進。
這兩門課程相輔相成,能讓你從程式設計新手變成有架構思維的資深開發者。
希望這篇文章能幫助你告別單例模式的陷阱,走向更優雅的程式碼架構!
🔥 想要更多遊戲開發技巧?記得關注我們後續文章,會繼續分享更多實用的程式設計模式和 Unity 開發經驗!
請先 登入 以發表留言。