應用 Singleton pattern 及 Unity Component 做系統拆分與管理 – Dividing your game system in unity.

在使用 Unity 開發遊戲的時候,為了實現各種功能,往往會不斷衍伸出一個又一個的系統,分別執掌不同的任務,可能是為了管理 UI 介面,也可能是為了建立連線,又或者是為了管理存檔。一個個的系統往往又為了方便而採用 Singleton pattern,或者互相注入,最後的結果就是系統之間的關係複雜,程式碼不易維護及重複使用。

於是乎我就一直思考著如何將一個巨大的系統架構,拆分成一個個獨立且靈活的小系統,就像電腦與周邊設備可以用 USB 輕易連接與斷開,我也希望我所開發的一個個系統,可以自由自在地安置在不同的開發專案之中。

如今這是我的初步成果,一套用於拆分以及管理各個子系統的架構:

gamesystemmanager

上面的圖片展示了再開始設計這套架構時,所期望達到的幾個特性 (Feature) ,

  • 於 Unity 專案程式 (Application) 啟動當下,可以自動初始化已經採用、不定數量的子系統。
  • 程式執行中途,可以隨時添加或拆卸子系統。
  • 程式執行中途,可以在任意時機地點輕易呼叫到特定子系統,有不輸給使用 Singleton pattern 的便利性。

而這些特性我也一一在架構中實現了,使用的方案接下來依序介紹。

子系統基底 – IGameSystemMono

首先準備子系統基礎類別,用來統一所有子系統的基本控制接口:

using UnityEngine;
using System.Collections;

namespace DouduckGame {
	public abstract class IGameSystemMono : MonoBehaviour {
		public abstract void StartGameSystem();
		public abstract void DestoryGameSystem();
	}
}

在 IGameSystemMono  類別的設計中,首先繼承了 MonoBehaviour 這個 Unity component 的基礎類別,如此一來可以得到兩個特性:

  • 可以在 Unity Inspector 上面看見子系統的 public 參數,在不修改程式碼的情況下進行序列化參數的調整。
  • 即使沒有使用 子系統管理的架構,每個子系統也可以做為單純的 Component 應用於專案之中。

以上這兩個特性可以讓子系統的使用更加靈活,減少改寫程式碼的需求;但反過來也有限制,那就是整個 子系統管理的架構 必須依賴著一個 DontDestoryObject 作為載體才能運作。

line_p201719_142419
一個掛載的子系統操作參數的方式跟 Component 相當接近

另外,這個 IGameSystemMono  類別中定義了兩個 abstract method 函式,用來給予 子系統管理器 主動呼叫,分別用於取代 Start() 及 OnDestory() 這兩個原生於 MonoBehaviour  的函式。這樣的設計是為了實現隨時 添加或拆卸子系統 的特性,避免 Start() 及 OnDestory() 沒有在預期的時機生效的錯誤,可以由管理器來決定呼叫的時機。

不過雖然設計上要避免使用 Start() 及 OnDestory() 兩個 message (已經用 abstract method 函式取代),但是 Update() 等其他 message 還是可以使用。

子系統管理器 – GameSystemManager

接下來是整個系統的核心,用來管理與控制的管理器類別:

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;

namespace DouduckGame {
	public sealed class GameSystemManager {

		private bool m_bIsInitialized = false;

		private GameObject m_oContainer;
		private Dictionary<Type, IGameSystemMono> m_GameSystemList;

		// Initializaion method
		public GameSystemManager(GameObject oContainer) {
			m_oContainer = oContainer;
			GameObject.DontDestroyOnLoad(m_oContainer);

			m_GameSystemList = new Dictionary<Type, IGameSystemMono> ();
		}

		public void StartInitialSystem() {
			if (m_bIsInitialized) {
				return;
			}
			m_bIsInitialized = true;

			IGameSystemMono[] systemList_ = m_oContainer.GetComponents<IGameSystemMono>();
			for (int i = 0; i < systemList_.Length; i++) {
				m_GameSystemList.Add(systemList_ [i].GetType(), systemList_ [i]);
				systemList_ [i].StartGameSystem();
			}
		}

		// Functional method
		public void AddSystem<T> () where T : IGameSystemMono {
			if (m_GameSystemList.ContainsKey(typeof(T))) {
				Debug.LogError("[GameSystemManager] There was a " + typeof(T).Name);
			} else {
				T gameSys_ = m_oContainer.AddComponent<T> ();
				gameSys_.StartGameSystem ();
				m_GameSystemList.Add(gameSys_.GetType(), gameSys_);
			}
		}

		public void RemoveSystem<T> () where T : IGameSystemMono {
			if (m_GameSystemList.ContainsKey(typeof(T))) {
				IGameSystemMono gameSys_ = m_GameSystemList [typeof(T)];
				m_GameSystemList.Remove(typeof(T));
				gameSys_.DestoryGameSystem();
				GameObject.Destroy(gameSys_);
			} else {
				Debug.LogError("[GameSystemManager] There was no " + typeof(T).Name);
			}
		}

		public void EnableSystem<T> () where T : IGameSystemMono {
			if (m_GameSystemList.ContainsKey(typeof(T))) {
				m_GameSystemList [typeof(T)].enabled = true;
			} else {
				Debug.LogError ("[GameSystemManager] There was no " + typeof(T).Name);
			}
		}

		public void DisableSystem<T> () where T : IGameSystemMono {
			if (m_GameSystemList.ContainsKey(typeof(T))) {
				m_GameSystemList [typeof(T)].enabled = false;
			} else {
				Debug.LogError ("[GameSystemManager] There was no " + typeof(T).Name);
			}
		}

		public T GetSystem<T> () where T : IGameSystemMono {
			if (m_GameSystemList.ContainsKey(typeof(T))) {
				return m_GameSystemList [typeof(T)] as T;
			} else {
				Debug.LogError ("[GameSystemManager] There was no " + typeof(T).Name);
				return null;
			}
		}
	}
}

這個類別並沒有採用 MonoBehaviour 做為基底,一方面並沒有使用相關功能的必要性;另一方面是希望將類別的功能單純化,讓類別功能專注於子系統的管理,至於如何啟動與呼叫這個管理器將在下一段進行說明。

子系統管理器除了常態的建構子,需要傳入一個 gameobject 作為子系統的載體外 (因為子系統繼承了 MonoBehaviour,需要有物件掛載),還有一個 public method 函式 StartInitialSystem() 可以將原本就已經在物件上的子系統進行初始化,將用於實現在 專案程式啟動時自動初始化已掛載子系統 的特性需求。

另外為了實現 隨時添加或拆卸子系統 特性,準備了四個 public method 函式:

  • AddSystem() – 增加一個子系統,同時會呼叫子系統的 StartSystem()
  • RemoveSystem() – 卸除一個子系統,同時會呼叫子系統的 DestorySystem()
  • EnableSystem() – 將子系統的 MonoBehaviour.enabled 設為 true
  • DisableSystem() – 將子系統的 MonoBehaviour.enabled 設為 false

很明顯可以發現,這些函式都使用了泛型,而整個管理器用了一個 Dictionary 來儲存所有的子系統。如此設計的最大好處就是,整個管理器在使用上就跟 getComponent() 等 Unity 原生 API 相似及直觀,不需要額外的 id 或 string 來做為呼叫子系統的 key。

而最後的 GetSystem 函式,應該不需特別說明,便是取得掛載的子系統之方法。

將管理器包裝成一個簡單呼叫的工具 – DouduckGameCore

最後只剩下 容易呼叫 這個特性還沒實現了,原本在開發時我會盡量避免使用 Singleton pattern,以免專案會越來越難維護。不過現在我們有了一個子系統的管理器,這時候即使採用 Singleton pattern,那未來專案也不會持續增加更多的子系統 Singleton,因為所有需要被呼叫的遊戲系統,接統一在這個管理器之下了。

using UnityEngine;
using UnityEngine.Events;
using System.Collections;

namespace DouduckGame {
	public sealed class DouduckGameCore : MonoBehaviour {
		public static GameObject InstanceGameObject;
		private static GameSystemManager m_SystemManager;
		private static bool m_bIsInitialized = false;

		private void Awake () {
			if (m_bIsInitialized) {
				Debug.LogError("[DouduckGameCore] was initialized");
				Object.Destroy(this);
			} else {
				m_bIsInitialized = true;
				transform.name = "[DouduckGameCore]";
				GameObject.DontDestroyOnLoad(this.gameObject);
				InstanceGameObject = this.gameObject;
				m_SystemManager = new GameSystemManager (InstanceGameObject);
			}
		}

		void Start () {
			m_SystemManager.StartInitialSystem();
		}

		// *** System manager method ***
		public static void AddSystem<T> () where T : IGameSystemMono {
			m_SystemManager.AddSystem<T>();
		}

		public static void RemoveSystem<T> () where T : IGameSystemMono {
			m_SystemManager.RemoveSystem<T>();
		}

		public static void EnableSystem<T> () where T : IGameSystemMono {
			m_SystemManager.EnableSystem<T>();
		}

		public static void DisableSystem<T> () where T : IGameSystemMono {
			m_SystemManager.DisableSystem<T>();
		}

		public static T GetSystem<T> () where T : IGameSystemMono {
			return m_SystemManager.GetSystem<T>();
		}
	}
}

最後 DouduckGameCore  這個腳本的使用方法,便是在場景中建立一個空物件,並將 DouduckGameCore  與希望一開始就啟動的子系統全掛載於其上即可。

1483943452274

這段腳本簡單來說只做了兩件事:

  • 將 GameSystemManager 重新包裝成 Singleton MonoBehaviour,來達成專案中隨時隨地都可以呼叫的特性。
  • 在 Awake() 的地方建立 GameSystemManager,並在 Start () 時呼叫 StartInitialSystem() 這個函式,將同樣掛載在這個物件底下的子系統進行初始化,完成 專案程式啟動時自動初始化已掛載子系統 的特性。

接下來在專案中任何地方需要子系統時,只要一段程式碼即可呼叫:

DouduckGameCore.GetSystem<MyGameSystem>();

後話

目前這個 子系統管理 的架構還有一些改善的空間,但就目前來說,使用起來的感受已經相當愉快,載開發專案的過程減少了許多打亂程式碼的疑慮。另外就是看到自己的程式碼保留了相當程度的可動性,這件事情本身就帶來了不少成就感。

如果要說這個架構中使用了甚麼 設計模式,除了很明顯的 Singleton pattern 外,就是採用了某種程度上的 Facade pattern 的理念,在 GameSystemManager  實現了子系統的統一取得介面,而 DouduckGameCore  如果加入了其他管理器或功能,則實現了眾管理器的統一介面。

特別提出設計模式並不是要表達如何透過設計模式去解決問題,而是為了規劃出具有相當維護性的架構,我不知不覺中會聯想到我曾經讀過的模式,進而設計出自己獨有的模式,而不是直接取用書上的方法。

希望大家也能在規劃程式時有各種體會,不只為了解決問題,同時也能享受在其中。

應用 Singleton pattern 及 Unity Component 做系統拆分與管理 – Dividing your game system in unity. 有 “ 8 則迴響 ”

    1. 關於你的第一個問題,DouduckGameCore 是在 Start 中進行已掛載系統的初始化,所以其他物件中的 Start 如果比較早執行,就有可能初始化尚未完成。這點你可以將 DouduckCore 中的 m_SystemManager.StartInitialSystem(); 由 Start() 換到 Awake () 執行,應該可以解決問題。

      在第二個問題中,我的設計是方向是 Singleton pattern,所以不允許相同的類別重複掛載。但如果是 SyatemA、SystemB 兩個類別皆繼承 IGameSystemMono,是可以同時使用的。你會出錯的原因應該是一開始便有掛載,只是尚未初始化,並不需要 AddSystem,同樣情況在問題一有說明到原因。

      希望有解決你的困惑。

      1. 原來如此,的確是調用順序上的問題,將m_SystemManager.StartInitialSystem();移到Awake中調用即可找到子系統。緊接著AddSystem的問題也就迎刃而解,感謝鴨神。
        本人是非資工本科最近也在學習設計模式,很多物件導向概念可能還是不清楚。有個疑問想請教版主來釐清觀,版主所說的繼承IGameSystemMono的子系統即使沒有DouduckGameCore,依然可以做為Component 來單獨執行,可是為什麼StartGameSystem()依然可以取代Start()在程式中直接執行?不是應該由管理器來決定呼叫的時機嗎?

      2. IGameSystemMono 沒有 DouduckGameCore 依然可以使用沒錯,畢竟他就是一個 Component。

        不過這邊 StartGameSystem() 因為沒有 DouduckGameCore 替你呼叫,就必須要手動呼叫才行,而呼叫的時機就看需求而定。所以你的理解是沒有錯的。

發表留言