使用框架的目標:低耦合,高內聚,表現和數據分離
耦合:對象,類的雙向引用,循環引用
內聚:相同類型的代碼放在一起
表現和數據分離:需要共享的數據放在Model里
對象之間的交互一般有三種
- 方法調用,A持有B才能調用B的方法
- 委托或回調,A持有B才能注冊B的委托,盡量避免嵌套調用
- 消息或事件,A不需要持有B
A調用B的方法,A就必須持有B,形成單向引用關系,為了避免耦合,B不應該引用A,如果B想調用A的方法,使用委托或回調。
總結:父節點調用子節點可以直接方法調用,子節點通知父節點用委托或事件,跨模塊通信用事件
模塊化一般有三種
- 單例,例如: Manager Of Managers
- IOC,例如: Extenject,uFrame的 Container,StrangelOC的綁定等等
- 分層,例如: MVC、三層架構、領域驅動分層等等
交互邏輯和表現邏輯
以計數器為例,用戶操作界面修改數據叫交互邏輯,當數據變更之后或者初始化時,從Model里查詢數據在View上顯示叫表現邏輯
交互邏輯:View -> Model
表現邏輯:Model -> View
很多時候,我們不會真的去用 MVC 開發架構,而是使用表現(View)和數據(Model)分離這樣的思想,我們只要知道 View 和 Model 之間有兩種邏輯,即交互邏輯 和 表現邏輯,我們就不用管中間到底是 Controller、還是 ViewModel、還是 Presenter。只需要想清楚交互邏輯 和 交互邏輯如何實現的就可以了。
View和Model怎樣交互比較好,或者說交互邏輯和表現邏輯怎樣實現比較好?
<1> 直接方法調用,表現邏輯是在交互邏輯完成之后主動調用,偽代碼如下
public class CounterViewController : MonoBehaviour
{void Start(){transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯CounterModel.Count++;// 表現邏輯UpdateView();});transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯CounterModel.Count--;// 表現邏輯UpdateView();});// 表現邏輯UpdateView();}void UpdateView(){transform.Find("CountText").GetComponent<Text>().text = CounterModel.Count.ToString();}
}public static class CounterModel
{public static int Count = 0;
}
<2> 使用委托
public class CounterViewController : MonoBehaviour
{void Start(){// 注冊CounterModel.OnCountChanged += OnCountChanged;transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯:這個會自動觸發表現邏輯CounterModel.Count++;});transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯:這個會自動觸發表現邏輯CounterModel.Count--;});OnCountChanged(CounterModel.Count);}// 表現邏輯private void OnCountChanged(int newCount){transform.Find("CountText").GetComponent<Text>().text = newCount.ToString();}private void OnDestroy(){// 注銷CounterModel.OnCountChanged -= OnCountChanged;}
}public static class CounterModel
{private static int mCount = 0;public static event Action<int> OnCountChanged ;public static int Count{get => mCount;set{if (value != mCount){mCount = value;OnCountChanged?.Invoke(value);}}}
}
<3> 使用事件,事件管理器寫法差不多,這里忽略具體實現
public class CounterViewController : MonoBehaviour
{void Start(){// 注冊EventManager.Instance.RegisterEvent(EventId, OnCountChanged);transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯:這個會自動觸發表現邏輯CounterModel.Count++;});transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯:這個會自動觸發表現邏輯CounterModel.Count--;});OnCountChanged();}// 表現邏輯private void OnCountChanged(){transform.Find("CountText").GetComponent<Text>().text = CounterModel.Count.ToString();}private void OnDestroy(){// 注銷EventManager.Instance.UnRegisterEvent(EventId, OnCountChanged);}
}public static class CounterModel
{private static int mCount = 0;public static int Count{get => mCount;set{if (value != mCount){mCount = value;// 觸發事件EventManager.Instance.FireEvent(EventId);}}}
}
比較上面3種實現方式,當數據量很多的時候,使用第1種方法調用會寫很多重復代碼調用,代碼臃腫,容易造成疏忽,使用委托或事件代碼更精簡,當數據變化時會自動觸發表現邏輯,這就是所謂的數據驅動。
所以表現邏輯使用委托或事件更合適,如果是單個數值變化,用委托的方式更合適,比如金幣、分數、等級、經驗值等等,如果是顆粒度較大的更新用事件比較合適,比如從服務器拉取了一個任務列表數據,然后任務列表數據存到了Model
BindableProperty
上面的Model類,每新增一個數據就要寫一遍類似的代碼,很繁瑣,我們使用泛型來簡化代碼
public class BindableProperty<T> where T : IEquatable<T>
{private T mValue;public T Value{get => mValue;set{if (!mValue.Equals(value)){mValue = value;OnValueChanged?.Invoke(value);}}}public Action<T> OnValueChanged;
}
BindableProperty 也就是可綁定的屬性,是 數據 + 數據變更事件 的合體,它既存儲了數據充當 C# 中的 屬性這樣的角色,也可以讓別的地方監聽它的數據變更事件,這樣會減少大量的樣板代碼
public class CounterViewController : MonoBehaviour
{void Start(){// 注冊CounterModel.Count.OnValueChanged += OnCountChanged;transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯:這個會自動觸發表現邏輯CounterModel.Count.Value++;});transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯:這個會自動觸發表現邏輯CounterModel.Count.Value--;});OnCountChanged(CounterModel.Count.Value);}// 表現邏輯private void OnCountChanged(int newValue){transform.Find("CountText").GetComponent<Text>().text = newValue.ToString();}private void OnDestroy(){// 注銷CounterModel.Count.OnValueChanged -= OnCountChanged;}
}public static class CounterModel
{public static BindableProperty<int> Count = new BindableProperty<int>(){Value = 0};
}
總結:
- 自頂向下的邏輯使用方法調用
- 自底向上的邏輯使用委托或事件,Model和View是底層和上層的關系,所以用委托或事件更合適
Command
實際的開發中交互邏輯的代碼是很多的,隨著功能需求越來越多,Controller的代碼會越來越臃腫,解決辦法是引入命令模式(Command),命令模式參考另一篇博客:Unity常用設計模式
先定義一個接口
public interface ICommand
{void Execute();
}
添加一個命令,實現數據加一操作,注意這里是用 struct 實現的,而不是用的 class,這是因為游戲里邊的交互邏輯有很多,如果每一個都用去 new 一個 class 的話,會造成很多性能消耗,比如 new 一個對象所需要的尋址操作、比如對象回收需要的 gc 等等,而 struct 內存管理效率要高很多
public struct AddCountCommand : ICommand
{public void Execute(){CounterModel.Count.Value++;}
}
實現數據減一操作
public struct SubCountCommand : ICommand
{public void Execute(){CounterModel.Count.Value--;}
}
更新交互邏輯的代碼
public class CounterViewController : MonoBehaviour{void Start(){// 注冊CounterModel.Count.OnValueChanged += OnCountChanged;transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯new AddCountCommand().Execute();});transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>{// 交互邏輯new SubCountCommand().Execute();});OnCountChanged(CounterModel.Count.Value);}// 表現邏輯private void OnCountChanged(int newValue){transform.Find("CountText").GetComponent<Text>().text = newValue.ToString();}private void OnDestroy(){// 注銷CounterModel.Count.OnValueChanged -= OnCountChanged;}}public static class CounterModel{public static BindableProperty<int> Count = new BindableProperty<int>(){Value = 0};}
使用 Command 符合讀寫分離原則(Comand Query Responsibility Segregation),簡寫為 CQRS ,這個概念在 StrangeIOC、uFrame、PureMVC、Loxodon Framework 都有實現,而在微服務領域比較火的 DDD(領域驅動設計)的實現一般也會實現 CQRS。它是一種軟件架構模式,旨在將應用程序的讀取和寫入操作分離為不同的模型。在CQRS中,寫操作通常由命令模型(Command Model)來處理,它負責處理業務邏輯和狀態更改。而讀操作則由查詢模型(Query Model)來處理,它專門用于支持數據查詢和讀取展示。
Command 模式就是邏輯的調用和執行是分離的,我們知道一個方法的調用和執行是不分離的,因為一旦你調用方法了,方法也就執行了,而 Command 模式能夠做到調用和執行在空間和時間上是能分離的。
空間分離的方法就是調用的地方和執行的地方放在兩個文件里。
時間分離的方法就是調用的之后,Command 過了一點時間才被執行。
Command 分擔 Controller 的交互邏輯,由于有了調用和執行分離這個特點,所以我們可以用不同的數據結構去組織 Command 調用,比如列表,隊列,棧
底層系統層是可以共享給別的展現層使用的,切換表現層非常方便,表現層到系統層用 Command 改變底層系統的狀態(數據),系統層通過事件或者委托通知表現層,在通知的時候可以推送數據,也可以讓表現層收到通知后自己去查詢數據。
模塊化
使用單例
單例比靜態類好一點就是其生命周期相對可控,而且訪問單例對象比訪問靜態類多了一點限制,也就是需要通過 Instance 獲取
每個模塊繼承 Singleton
public class Singleton<T> where T : class
{public static T Instance{get{if (mInstance == null){// 通過反射獲取構造var ctors = typeof(T).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic);// 獲取無參非 public 的構造var ctor = Array.Find(ctors, c => c.GetParameters().Length == 0);if (ctor == null){throw new Exception("Non-Public Constructor() not found in " + typeof(T));}mInstance = ctor.Invoke(null) as T;}return mInstance;}}private static T mInstance;
}
問題:單例沒有訪問限制,容易造成模塊之間互相引用,關系混亂
IOC容器
IOC 容器可以理解為是一個字典,這個字典以 Type 為 key,以對象即 Instance 為 value,IOC 容器最少有兩個核心的 API,即根據 Type 注冊實例,根據 Type 獲取實例
public class IOCContainer
{/// <summary>/// 實例/// </summary>public Dictionary<Type, object> mInstances = new Dictionary<Type, object>();/// <summary>/// 注冊/// </summary>/// <param name="instance"></param>/// <typeparam name="T"></typeparam>public void Register<T>(T instance){var key = typeof(T);if (mInstances.ContainsKey(key)){mInstances[key] = instance;}else{mInstances.Add(key,instance);}}/// <summary>/// 獲取/// </summary>public T Get<T>() where T : class{var key = typeof(T);object retObj;if(mInstances.TryGetValue(key,out retObj)){return retObj as T;}return null;}
}
下面是一個簡單的示例,IOC 容器創建,注冊實際應當寫在游戲初始化時,這里為了方便演示都寫在一起了
public class IOCExample : MonoBehaviour
{void Start(){// 創建一個 IOC 容器var container = new IOCContainer();// 注冊一個藍牙管理器的實例container.Register(new BluetoothManager());// 根據類型獲取藍牙管理器的實例var bluetoothManager = container.Get<BluetoothManager>();//連接藍牙bluetoothManager.Connect();}public class BluetoothManager{public void Connect(){Debug.Log("藍牙連接成功");}}
}
為了避免樣板代碼,這里創建一個抽象類
/// <summary>
/// 架構
/// </summary>
public abstract class Architecture<T> where T : Architecture<T>, new()
{#region 類似單例模式 但是僅在內部課訪問private static T mArchitecture = null;// 確保 Container 是有實例的static void MakeSureArchitecture(){if (mArchitecture == null){mArchitecture = new T();mArchitecture.Init();}}#endregionprivate IOCContainer mContainer = new IOCContainer();// 留給子類注冊模塊protected abstract void Init();// 提供一個注冊模塊的 APIpublic void Register<T>(T instance){MakeSureArchitecture();mArchitecture.mContainer.Register<T>(instance);}// 提供一個獲取模塊的 APIpublic static T Get<T>() where T : class{MakeSureArchitecture();return mArchitecture.mContainer.Get<T>();}
}
子類注冊多個模塊
public class PointGame : Architecture<PointGame>
{// 這里注冊模塊protected override void Init(){Register(new GameModel1());Register(new GameModel2());Register(new GameModel3());Register(new GameModel4());}
}
使用 IOC 容器的目的是增加模塊訪問的限制
除了可以用來注冊和獲取模塊,IOC 容器一般還會有一個隱藏的功能,即:注冊接口模塊
public class IOCExample : MonoBehaviour
{void Start(){// 創建一個 IOC 容器var container = new IOCContainer();// 根據接口注冊實例container.Register<IBluetoothManager>(new BluetoothManager());// 根據接口獲取藍牙管理器的實例var bluetoothManager = container.Get<IBluetoothManager>();//連接藍牙bluetoothManager.Connect();}/// <summary>/// 定義接口/// </summary>public interface IBluetoothManager{void Connect();}/// <summary>/// 實現接口/// </summary>public class BluetoothManager : IBluetoothManager{public void Connect(){Debug.Log("藍牙連接成功");}}
}
抽象-實現 這種形式注冊和獲取對象的方式是符合依賴倒置原則的。
依賴倒置原則(Dependence Inversion Principle):程序要依賴于抽象接口,不要依賴于具體實現。依賴倒置原則是 SOLID 中的字母 D。
這種設計的好處:
- 接口設計與實現分成兩個步驟,接口設計時可以專注于設計,實現時可以專注于實現。
- 實現是可以替換的,比如一個接口叫 IStorage,其實現可以是 PlayerPrefsStorage、EdtiroPrefsStorage,等切換時候只需要一行代碼就可以切換了。
- 比較容易測試(單元測試等)
- 降低耦合。
接口的顯式實現
public interface ICanSayHello
{void SayHello();void SayOther();
}public class InterfaceDesignExample : MonoBehaviour, ICanSayHello
{/// <summary>/// 接口的隱式實現/// </summary>public void SayHello(){Debug.Log("Hello");}/// <summary>/// 接口的顯式實現,不能寫訪問權限關鍵字/// </summary>void ICanSayHello.SayOther(){Debug.Log("Other");}void Start(){// 隱式實現的方法可以直接通過對象調用this.SayHello();// 顯式實現的接口不能通過對象調用// this.SayOther() // 會報編譯錯誤// 顯式實現的接口必須通過接口對象調用(this as ICanSayHello).SayOther();}
}
當需要實現多個簽名一致的方法時,可以通過接口的顯式聲明來區分到底哪個方法是屬于哪個接口的
利用接口的顯示實現,子類想要調用必須先轉成接口,這樣就增加了調用顯式實現的方法的成本,所以可以理解為這個方法被閹割了
分層
前面使用 Command 分擔了 Controller 的交互邏輯的部分邏輯,并不是所有的交互邏輯都適合用 Command 來分擔的,還有一部分交互邏輯是需要交給 System 層來分擔。這里 System 層在概念等價于游戲的各個管理類 Manager。
Command 是沒有狀態的,有沒有狀態我們可以理解為這個對象需不需要維護數據,因為 Command 類似于是一個方法,只要調用然后執行一次就可以不用了,所以 Command 是沒有狀態的
梳理一下當前的架構
- 表現層:即 ViewController 或者 MonoBehaviour 腳本等,負責接受用戶的輸入,當狀態變化時更新表現
- System 層:系統層,有狀態,在多個表現層共享的邏輯,負責即提供 API 又有狀態的對象,比如網絡服務、藍牙服務、商城系統等,也支持分數統計、成就系統這種硬編碼比較多又需要把代碼放在一個位置的需求。
- Model 層:管理數據,有狀態,提供數據的增刪改查。
- Utility 層:工具層,無狀態,提供一些必備的基礎工具,比如數據存儲、網絡鏈接、藍牙、序列化反序列化等。
表現層改變 System、Model 層級的狀態用 Command,System 層 和 Model 層 通知 表現層用事件,委托或 BindableProeprty,表現層查詢狀態時可以直接獲取 System 和 Model 層
每個層級都有一些規則:
表現層
- 可以獲取 System
- 可以獲取 Model
- 可以發送 Command
- 可以監聽 Event
系統層
- 可以獲取其他 System
- 可以獲取 Model
- 可以監聽,發送 Event
- 可以獲取 Utility
數據層
- 可以獲取 Utility
- 可以發送 Event
工具層
- 啥都干不了,可以集成第三方庫,或者封裝 API
除了四個層級,還有一個核心概念就是 Command
Command
- 可以獲取 System
- 可以獲取 Model
- 可以獲取 Utility
- 可以發送 Event
- 可以發送其他 Command
貧血模型和充血模型
我們有一個 User 對象,偽代碼如下
public class User
{public string Name {get;set;}public int Age {get;set;}public string Id {get;set;}public string NickName {get;set;public float Weight {get;set;}
}
總共有五個屬性,但是在表現層的界面中,只需要顯示三個屬性,即:姓名 Name、年齡 Age、和 Id。
表現層查詢一個用戶數據的時候,返回了一個 完整的 User 對象,這種數據流向模型叫做貧血模型。就是表現層需要用到的數據結果給了一整個未經過篩選的數據對象過來。
定義了一個 UserInfo 類,偽代碼如下;
public class UserInfo
{public string Name {get;set;}public int Age {get;set;}public string Id {get;set;}
}
充血模型就是表現層需要哪些數據,就剛好返回哪些數據
充血模型 比 貧血模型 需要做跟多的工作,寫更多的代碼,甚至還有跟多的性能消耗。
但是在越大規模的項目中 充血模型 的好處就會更加明顯。因為充血模型,可以讓我們的代碼更精確地描述業務,會提高代碼的可讀性,而貧血模型,會讓我們的數據逐漸趨于混亂。
參考
涼鞋 《框架搭建 決定版》