從「萬能單例」到「優雅架構」的蛻變之路

📚 目錄

  1. 前言:單例模式的愛恨糾葛
  2. 為什麼單例模式在 Unity 中這麼受歡迎?
  3. 單例模式的真實問題:看似簡單的災難
  4. 更好的替代方案:跨場景物件管理器
  5. 實戰改造:從單例地獄到清爽架構
  6. 不同場景的最佳實踐
  7. 總結與學習資源推薦

前言:單例模式的愛恨糾葛

在我三年的遊戲前端工程師經驗中,單例模式絕對是最具爭議的設計模式。一方面,它解決了 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 開發經驗!

創作者介紹
創作者 傑克淺談遊戲邏輯 的頭像
傑克的遊戲宇宙

傑克淺談遊戲邏輯

傑克的遊戲宇宙 發表在 痞客邦 留言(0) 人氣( 72 )