Unity 遊戲框架:UI 管理神器 UI Kit
- 2020 年 3 月 28 日
- 筆記
UI Kit 快速入門
首先我們來進行 UI Kit 的快速入門
製作一個介面的,步驟如下:
- 準備
- 生成程式碼
- 邏輯編寫
- 運行
1. 準備
- 先創建一個場景 TestUIHomePanel。
- 刪除 Hierarchy 其他的 GameObject。
- 搜索 UIRoot.prefab,拖入 Hierarchy。
- 在 UIRoot / Design GameObject 下創建 Panel ( 右擊 Design -> UI -> Panel )。
- 將該 Panel 改名為 UIHomePanel。
- 添加按鈕 BtnStart、BtnAbout。
- 對 BtnStart、BtnAbout 添加 UIMark Component。
- 將 UIHomePanel 做成 prefab,再進行 AssetBundle 標記。
2. 生成程式碼
- 右擊 UIHomePanel Prefab 選擇 @UI Ki t- Create UI Code,生成 UI 腳本。
- 此時,生成的腳本自動掛到了 UIHomePanel 上,並且進行 UIMark 標記的控制項,自動綁定到 prefab 上,如圖所示:
3. 邏輯編寫
- 打開 UIHomePanel ,在 ResgisterUIEvent 上編寫 Button 事件綁定,編寫後程式碼如下:
/* 2018.7 涼鞋的MacBook Pro (2) */ using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using QFramework; namespace QFramework.UIKitExample { public class UIHomePanelData : UIPanelData { // TODO: Query Mgr's Data } public partial class UIHomePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIHomePanelData ?? new UIHomePanelData(); //please add init code here } protected override void ProcessMsg (int eventId,QMsg msg) { throw new System.NotImplementedException (); } protected override void RegisterUIEvent() { BtnStart.onClick.AddListener(() => { Log.E("BtnStart Clicked !!!"); }); BtnAbout.onClick.AddListener(() => { Log.E("BtnAbout Clicked !!!"); }); } protected override void OnShow() { base.OnShow(); } protected override void OnHide() { base.OnHide(); } protected override void OnClose() { base.OnClose(); } void ShowLog(string content) { Debug.Log("[ UIHomePanel:]" + content); } } }
4. 運行
- 創建一個 空 GameObject,命名為 TestUIHomePanel,掛上 UIPanelTester 腳本。
- UIPanelTester 腳本的 PanelName 填寫為 UIHomePanel。
- 運行!
- 運行之後,點擊對應的按鈕則會有對應的輸出。
UI Kit 的快速入門就介紹到這裡。其中的 UI Kit 程式碼生成和 Res Kit 的 Simulation Mode 為日常開發節省了大量的時間。
UI Kit 層級管理
我們的 UIHomePanel 是怎麼打開的呢?
答案在 UIPanelTester 中。程式碼如下:
namespace QFramework { using System.Collections; using System.Collections.Generic; using UnityEngine; [System.Serializable] public class UIPanelTesterInfo { /// <summary> /// 頁面的名字 /// </summary> public string PanelName; /// <summary> /// 層級名字 /// </summary> public UILevel Level; } public class UIPanelTester : MonoBehaviour { /// <summary> /// 頁面的名字 /// </summary> public string PanelName; /// <summary> /// 層級名字 /// </summary> public UILevel Level; [SerializeField] private List<UIPanelTesterInfo> mOtherPanels; private void Awake() { ResMgr.Init(); } private IEnumerator Start() { yield return new WaitForSeconds(0.2f); UIMgr.OpenPanel(PanelName, Level); mOtherPanels.ForEach(panelTesterInfo => { UIMgr.OpenPanel(panelTesterInfo.PanelName, panelTesterInfo.Level); }); } } }
在 Start 方法中,有一句 UIMgr.OpenPanel ( PanelName, Level );
其中 PanelName 是我們填寫的 UIHomePanel。Level 則是用來顯示層級的枚舉。
枚舉定義如下:
public enum UILevel { AlwayBottom = -3, //如果不想區分太複雜那最底層的UI請使用這個 Bg = -2, //背景層 UI AnimationUnder = -1, //動畫層 Common = 0, //普通層 UI AnimationOn = 1, // 動畫層 PopUI = 2, //彈出層 UI Guide = 3, //新手引導層 Const = 4, //持續存在層 UI Toast = 5, //對話框層 UI Forward = 6, //最高UI層用來放置UI特效和模型 AlwayTop = 7, //如果不想區分太複雜那最上層的UI請使用這個 }
默認為 UILevel.Common。
層級管理的核心實現比較簡單,程式碼如下:
switch (uiLevel) { case UILevel.Bg: ui.Transform.SetParent(mBgTrans); break; case UILevel.AnimationUnderPage: ui.Transform.SetParent(mAnimationUnderTrans); break; case UILevel.Common: ui.Transform.SetParent(mCommonTrans); break; case UILevel.AnimationOnPage: ui.Transform.SetParent(mAnimationOnTrans); break; case UILevel.PopUI: ui.Transform.SetParent(mPopUITrans); break; case UILevel.Const: ui.Transform.SetParent(mConstTrans); break; case UILevel.Toast: ui.Transform.SetParent(mToastTrans); break; case UILevel.Forward: ui.Transform.SetParent(mForwardTrans); break; }
這種是最通用最常見的層級管理方式。只要將 UIPanel 放到對應層級的 GameObject 下面就好了。
不過這種層級管理會有一點問題。當這個 UIRoot 只有一個 Canvas 的時候,頁面的打開或關閉都會進行 Canvas 網格的重新排序,也就是網格重建,所以很多方案都建議,每個 UIPanel 掛一個 Canvas。然後根據 Canvas Sorting Order 進行層級管理。
使用 Canvas 的 Sorting Order 可以更精細地進行層級管理,而 UI Kit 目前的方式相對粗糙一些。
使用 Canvas 的方式是 UI Kit 的一個方向,等 QFramework 團隊找到比較簡潔的實現之前,UI Kit 還是默認使用當前方式。
UI Kit 最佳實踐 (一) 介面跳轉及數據展示
接下來,我們來製作兩個介面。一個是 UILevelPanel,一個是 UIGamePanel。
UILevelPanel 功能定義:
- 有若干個關卡,這裡暫時定義為九個關卡。
- 點擊任意關卡則跳轉到 UIGamePanel,並在 UIGamePanel 中顯示當前關卡資訊。比如,在 UILevelPanel 點擊第一關,則打開的 UIGamePanel 則展示 第一關,同理第二關。
UIGamePanel 功能定義:
- 遊戲暫停按鈕。
關卡資訊展示,這裡只展示第幾關就夠了。
這裡 UILevelPanel 和 UIGamePanel 的製作的詳細過程和 UI Kit 快速入門類似。簡單展示下兩個頁面的布局,和相關的邏輯程式碼。
UIGamePanel.prefab:
- BtnPause 是一個 Button。
- Level 是一個 Text,主要是用來表示第幾關的。
其程式碼如下:
UIGamePanel.cs
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePanelData : UIPanelData { public int LevelIndex = 1; } public partial class UIGamePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePanelData ?? new UIGamePanelData(); //please add init code here Level.text = "第" + mData.LevelIndex + "關"; } protected override void ProcessMsg (int eventId,QMsg msg) { throw new System.NotImplementedException (); } protected override void RegisterUIEvent() { BtnPause.onClick.AddListener(() => { Log.E("BtnPause Clicked"); }); } } }
在 InitUI 方法中,會接收到一個 UIData,然後轉為 UIGamePanelData 後賦值給 MData。
那麼這個 UIData 從哪裡傳過來呢?
答案是 UIMgr.Open 這個 API,這個 API 提供傳入相應介面的 UIData。這部分在下一小結詳細說。
現在重點是,在 UIMgr.Open 不傳入 UIData 時,UIGamePanel, UIData 有可能是空的,那麼當 UIData 為空時,則會默認創建一個 UIGamePanelData()。
默認創建一個 UIGamePanelData 這樣做有什麼好處呢?
答案是 方便測試,可以在 UIGamePanelData 里給數據設置一些默認值,這裡 LevelIndex 默認是 1。當然這也不是本小節的重點,這個也下一小節詳細說。
由於可以設置默認值,所以 TestUIGamePanel.scene 運行之後如下:
默認就是第一關。這樣負責編寫 UIGamePanel 和遊戲戰鬥模組的同學們,主要改自己部分的 UIGamePanelData 就可以進行一些測試了,而不是從頭開始運行遊戲手點直到打開 UIGamePanel 才看到測試結果,重點是 UIGamePanel 不依賴任何其他數據,只是作為數據的一個展示而已。反正這不是本節重點。。。不好意思實在忍不住:)
接下來 UILevelPanel
UILevelPanel.prefab:
- LevelProtoype 是一個 Button,代表關卡 item 的原型。
- LevelsContainer 則是一個 普通的 GameObject,不過掛上了 GridLayoutGroup 了。
- BtnBack 則是左上角的的返回按鈕。
UILevelPanel 可能會複雜一點了,程式碼如下:
/* 2018.7 涼鞋的MacBook Pro (2) */ using UnityEngine.UI; namespace QFramework.Example { public class UILevelPanelData : UIPanelData { // TODO: Query Mgr's Data } public partial class UILevelPanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UILevelPanelData ?? new UILevelPanelData(); //please add init code here for (var i = 0; i < 10; i++) { LevelPrototype.Instantiate() .Parent(LevelsContainer) .LocalScaleIdentity() .LocalPositionIdentity() .Show() .ApplySelfTo(btnLevel=> { var levelIndex = i + 1; btnLevel.onClick.AddListener(() => { CloseSelf(); UIMgr.OpenPanel<UIGamePanel>(uiData: new UIGamePanelData() { LevelIndex = levelIndex }); }); }) .transform.Find("Text").GetComponent<Text>().text = "第" + (i + 1) + "關"; } } protected override void ProcessMsg (int eventId,QMsg msg) { throw new System.NotImplementedException (); } protected override void RegisterUIEvent() { BtnBack.onClick.AddListener(() => { CloseSelf(); UIMgr.OpenPanel<UIMainPanel>(); }); } } }
核心程式碼:
for (var i = 0; i < 10; i++) { LevelPrototype.Instantiate() // var BtnLevel = GameObject.Instantiate(LevelPrototype) .Parent(LevelsContainer) // BtnLevel.transform.SetParent(LevelsContainer) .LocalScaleIdentity() // BtnLevel.localScale = Vector3.one .LocalPositionIdentity() // BtnLevel.localPosition = Vector3.zero .Show() // BtnLevel.gameObject.SetActive(true) .ApplySelfTo(btnLevel=> // 這裡就是為了捕獲上邊的 BtnLevel 然後進行一些操作 { var levelIndex = i + 1; btnLevel.onClick.AddListener(() => { CloseSelf(); UIMgr.OpenPanel<UIGamePanel>(uiData: new UIGamePanelData() { LevelIndex = levelIndex }); }); }) .transform.Find("Text").GetComponent<Text>().text = "第" + (i + 1) + "關"; }
請仔細月底以上注釋部分。
總之,注釋部分則是對鏈式支援的解釋。不是本文重點。
重點是:
UIMgr.OpenPanel<UIGamePanel>(uiData: new UIGamePanelData() { LevelIndex = levelIndex });
打開 UIGamePanel 的時候,可以傳入一個 Data 進去。而這個 Data 就是 UIGamePanel 中 InitUI 接收到的 uiData。非常簡單。
運行結果如下:
功能完成。
UIPanel 之間的數據傳遞
由上一小節介紹的從 UILevelPanel 傳數據到 UIGamePanel 這種傳遞數據的方式有什麼好處呢?
首先在筆者的框架里,推薦將 UI 介面當做只是進行數據展示的工具。是一個 Input 和 Output 模組。輸入數據,然後將數據展示到介面上。在筆者接觸的很多項目中,在介面邏輯中添加了非常多數據訪問相關的程式碼,各種 DataManager、ConfigManager 等等,這裡是非常不推薦的。推薦所有要展示的初始數據,都是通過 uiData 傳入進來,這要每個介面或模組都可以進行獨立測試。
獨立測試、團隊成員之間互不干擾、並且可以自己寫一些測試數據這僅僅是第一個好處,還不夠。
第二個好處則是為了方便完成 UIPanelStack 功能,這個功能是主要完成一個 返回上一頁這個功能。舉個例子。
假如 UIGamePanel 可以從 UILevelPanel 打開,那麼 UILevelPanel 打開的 UIGamePanel 返回上一頁應該是 UILevelPanel。
同理,通過 UIOtherPanel 打開的 UIGamePanel 返回的上一個頁面應該是 UIOtherPanel。
這個功能如果用一堆判斷 + UIGamePanelData 來實現很容易,但是程式碼不是很優雅,假如 UIGamePanel 可以從十多個頁面打開,那是不是要寫十多個條件判斷? 而且要在每個打開 UIGamePanel 的地方都要把 上一個頁面資訊記錄到 UIGamePanelData 里。這樣很麻煩。要完成這個最簡單的還是一個 UIPanelStack,用一個堆棧來記錄頁面打開關閉的資訊。
有了 UIPanelData 這個概念,那麼很容易就可以完成這個功能。主要抽象出一個 UIPanelInfo 就好了,
namespace QFramework { public class UIPanelInfo { public IUIData UIData; public UILevel Level; public string AssetBundleName; public string PanelName; } }
這是第二個好處了,有了 UIPanelData 這個基礎就可以做更多的事情。UIPanelStack 功能不是本小節重點,在接下來會詳細進行介紹。
總結下來好處就是兩點:
- 獨立測試、團隊成員之間互不干擾、並且可以自己寫一些測試數據。
- 更容易實現 UIPanelStack。
不過,這個 UIPanelData 的設計還有改進的空間,如果將 UIPanelData 做成可序列化的,並且 mData 是一個可賦值的話,那麼做介面測試的時候就不用更改程式碼了,只在 prefab 上就可以進行測試數據的修改了。可以節省大量的程式碼編譯時間。這個功能在筆者自己的項目進行過實驗,是可以實現的,等穩定了會在 UI Kit 未來的版本集成。
UIMgr/UIManager
UIMgr 本身是個 Manager,之前在 ResKit 有介紹過,只要叫 XXXMgr 的本身就是一個容器,是一個對外提供容器元素一系列操作的容器。而 UIMgr 則是 UI 的容器,裡邊維護了一個字典,Dictionary <string,IUIPanel>。所有的 UIMgr.Open/Close/Get 等,都是先進行一次字典查詢,再進行相應操作的。這個不難理解,大部分市面上的方案和 UI Kit 類似,都比較成熟了,這裡不值得浪費篇幅。
UIPanelStack 管理
UIPanelStack 也就是堆棧,一般只是用來完成返回上一頁這個功能的。在沒用堆棧管理頁面資訊之前,都是打開一個頁面,然後在打開的頁面上記錄上一頁面的資訊,然後當頁面點擊返回時,再根據記錄的上一個頁面的資訊打開上一個頁面。這種實現在頁面數量不多、跳轉邏輯不複雜的情況下可以勉強應付。但是跳轉邏輯比較複雜的情況下還是要實現一個,所以就添加了 UIPanelStack 這個功能。
起初的實現是所有的頁面,每當關閉時頁面資訊全部壓到 Stack 里。這裡的頁面資訊定義如下:
public class UIPanelInfo { public IUIData UIData; public UILevel Level; public string AssetBundleName; public string PanelName; }
簡單介紹下:
- UIData 是 在關閉時頁面的數據快照
- Level 則是關閉時所在的層級
- AssetBundleName 則是所載入的 AB 名,如果沒有傳入則為空。
- PanelName 則是打開頁面時傳入的頁面名字。
後來發現筆者參與的項目不需要每個頁面都壓入棧中,只是少數的幾個頁面需要。
所以提供了兩個手動的 API。
UIMgr.Push<T>/UIMgr.Push(string panelName); UIMgr.Back(IUIPanel panel)/UIMgr.Back(string panelName);
Push 很容易理解,就是 UIPanel 的壓棧操作。Back 則是,返回到最近 Push 進棧中的頁面。
實現原理也很簡單。
- Push 時將傳入的 panelName 對應的 Panel 關閉掉,生成 UIPanelInfo 後,將 UIPanelInfo 壓入到棧中。
- Back 則是將傳入的 panelName 對應的 Panel 關閉掉,從 棧中彈出一個 UIPanelInfo,之後根據資訊打開頁面並傳入 Info 中的數據。
核心程式碼:
UIManager.cs
public void Push(IUIPanel view) { if (view != null) { mUIStack.Push(view.PanelInfo); view.Close(); mAllUI.Remove(view.Transform.name); } } public void Back(string currentPanelName) { var previousPanelInfo = mUIStack.Pop(); CloseUI(currentPanelName); OpenUI(previousPanelInfo.PanelName, previousPanelInfo.Level, previousPanelInfo.UIData, previousPanelInfo.AssetBundleName); }
關於 UIPanelStack 介紹到這裡。
這個功能目前還比較簡陋,在 QFramework 文檔中還沒有進行介紹。不過相比把所有的頁面資訊都進行壓棧操作,按需手動的這種方式更合適一些。
小結 (一)
事實上,獨立測試的介面完全替代了 QFramework 初期所提供的 QApp 模組化的方式。這個不理解沒關係,每個介面的開發已經是模組化了,不過只是從業務進行橫向的模組化。已經夠用了,畢竟大部分的項目都是進行 UI 介面的製作和修改。其他一些戰鬥系統等等,比較大的模組,也多少會依賴於 UI 部分,那麼這種的推薦用一個 UI 介面進行一個模組的入口。總之在業務邏輯以及介面角度來看,已經支援了模組化的架構。
UI Kit 最佳實踐 (二) 暫停介面與通訊
接下來我們接著進行 UI Kit 最佳實踐,之前我們完成了 UILevelPanel 和 UIGamePanel。
而 UIGamePanel 中的暫停功能還沒有完成。點擊暫停功能則應該打開暫停介面。
所以我們先完成暫停介面的製作:
UIGamePausePanel.prefab:
- Image: 是對話框的白色背景
- BtnContinue: 是繼續按鈕
- BtnReplay: 是重玩按鈕
- BtnMain: 是主頁按鈕
其程式碼如下:
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePausePanelData : UIPanelData { // TODO: Query Mgr's Data } public partial class UIGamePausePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePausePanelData ?? new UIGamePausePanelData(); //please add init code here } protected override void ProcessMsg (int eventId,QMsg msg) { throw new System.NotImplementedException (); } protected override void RegisterUIEvent() { BtnMain.onClick.AddListener(() => { CloseSelf(); UIMgr.ClosePanel<UIGamePanel>(); UIMgr.OpenPanel<UIMainPanel>(); }); BtnReplay.onClick.AddListener(() => { Log.E("BtnPlay Clicked"); }); BtnContinue.onClick.AddListener(() => { CloseSelf(); }); } } }
程式碼比較簡單,主要是三個按鈕的事件註冊。
而 UIGamePanel.cs 進行暫停按鈕的註冊:
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePanelData : UIPanelData { public int LevelIndex = 1; } public partial class UIGamePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePanelData ?? new UIGamePanelData(); //please add init code here Level.text = "第" + mData.LevelIndex + "關"; } protected override void ProcessMsg (int eventId,QMsg msg) { } protected override void RegisterUIEvent() { BtnPause.onClick.AddListener(() => { UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI); }); } } }
原來的 BtnPause 點擊之後進行一些日誌的輸出,現在則改為了打開 UIGamePausePanel 頁面。
這裡層級為 UILevel.PopUI。
這樣一個打開暫停頁面的功能就完成了。
結果如下:
但是一般的暫停頁面沒有這麼簡單。
當打開暫停頁面的時候,正在進行的遊戲應該全部暫停。這裡最容易的實現就是 Time.timeScale = 0.0f;
不過這不是重點,當繼續遊戲時候,還要通知 UIGamePanel 或者對應的 GameManager 進行暫停的恢復。
這就涉及到了對象之間通訊的問題。
最簡單的方式就是,在 UIGamePausePanel 中獲取 UIGamePanel ,然後進行相應恢復操作。
UIGamePanel.cs 先增加幾個方法:
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePanelData : UIPanelData { public int LevelIndex = 1; } public partial class UIGamePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePanelData ?? new UIGamePanelData(); //please add init code here Level.text = "第" + mData.LevelIndex + "關"; } protected override void ProcessMsg (int eventId,QMsg msg) { } protected override void RegisterUIEvent() { BtnPause.onClick.AddListener(() => { GamePause(); UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI); }); } void GameStart() { } void GamePause() { Log.E("GamePause"); } public void GameResume() { Log.E("GameResume"); } void GameOver() { } } }
由於 GameResume 需要交給 UIGamePausePanel 調用。所以要做成 public 方法。
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePausePanelData : UIPanelData { // TODO: Query Mgr's Data } public partial class UIGamePausePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePausePanelData ?? new UIGamePausePanelData(); //please add init code here } protected override void ProcessMsg (int eventId,QMsg msg) { throw new System.NotImplementedException (); } protected override void RegisterUIEvent() { BtnMain.onClick.AddListener(() => { CloseSelf(); UIMgr.ClosePanel<UIGamePanel>(); UIMgr.OpenPanel<UIMainPanel>(); }); BtnReplay.onClick.AddListener(() => { Log.E("BtnPlay Clicked"); }); BtnContinue.onClick.AddListener(() => { CloseSelf(); UIMgr.GetPanel<UIGamePanel>().GameResume(); }); } } }
在 BtnContinue 中獲取,這是最簡單的實現。
但是從子 Panel 對象獲取 父 Panel 對象,不太優雅。
這裡筆者介紹一個原則:
- 自底向上,發消息或提供委託。
- 自頂向下,直接快取引用。
- 同級之間,發消息。
應用到當前的例子,則為 子 Panel 應該對 父 Panel 提供委託或者發消息。
我們先實現提供委託的方式。
這個比較簡單,由於 BtnContinue 其實是 public 的,所以在 UIGamePanel 打開 UIGamePausePanel 的同時就可以進行一個按鈕點擊事件的註冊。
程式碼如下:
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePanelData : UIPanelData { public int LevelIndex = 1; } public partial class UIGamePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePanelData ?? new UIGamePanelData(); //please add init code here Level.text = "第" + mData.LevelIndex + "關"; } protected override void ProcessMsg (int eventId,QMsg msg) { } protected override void RegisterUIEvent() { BtnPause.onClick.AddListener(() => { GamePause(); UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI).BtnContinue.onClick.AddListener(GameResume); }); } void GameStart() { } void GamePause() { Log.E("GamePause"); } void GameResume() { Log.E("GameResume"); } void GameOver() { } } }
程式碼很簡單,這要在 BtnPause 按鈕點擊時,進行 BtnContinue 的點擊事件註冊。
Button 的 onClick 事件,不用擔心會被覆蓋,因為用的是 AddListener 這個 API,是增加,實質上類似於 += 操作。這樣就可以把事件從外部注入到 UIGamePausePanel 中。
這也是比較方便的一種方式,尤其是在 Dialog 或者 Prompt 通用的時候,其標記過的控制項都可以在外邊訪問。
這樣有利有弊,有點不符合面向對象的設計原則,不過問題不大。
這樣第二種方式就完成了。
第三種方式則是通過發送消息,UIGamePausePanel 可以把事件發送給 UIGamePanel。
首先要在 UIGamePanel 中進行事件的定義:
public enum UIGamePanelEvent { Start = QMgrID.UI, GameResume, End }
事件的註冊:
public partial class UIGamePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePanelData ?? new UIGamePanelData(); //please add init code here Level.text = "第" + mData.LevelIndex + "關"; RegisterEvent(UIGamePanelEvent.GameResume); } ... }
事件的處理:
public partial class UIGamePanel : UIPanel { ... protected override void ProcessMsg (int eventId,QMsg msg) { if (eventId == (int) UIGamePanelEvent.GameResume) { GameResume(); } } ... }
接收消息之後,馬上調用 GameResume 方法就好了。
完整 UIGamePanel.cs 程式碼如下:
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePanelData : UIPanelData { public int LevelIndex = 1; } public enum UIGamePanelEvent { Start = QMgrID.UI, GameResume, End } public partial class UIGamePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePanelData ?? new UIGamePanelData(); //please add init code here Level.text = "第" + mData.LevelIndex + "關"; RegisterEvent(UIGamePanelEvent.GameResume); } protected override void ProcessMsg (int eventId,QMsg msg) { if (eventId == (int) UIGamePanelEvent.GameResume) { GameResume(); } } protected override void RegisterUIEvent() { BtnPause.onClick.AddListener(() => { GamePause(); UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI); }); } void GameStart() { } void GamePause() { Log.E("GamePause"); } void GameResume() { Log.E("GameResume"); } void GameOver() { } } }
接下來是在 UIGamePausePanel 中發送事件的部分。
非常簡單,程式碼如下:
/* 2018.7 涼鞋的MacBook Pro (2) */ namespace QFramework.Example { public class UIGamePausePanelData : UIPanelData { // TODO: Query Mgr's Data } public partial class UIGamePausePanel : UIPanel { protected override void InitUI(IUIData uiData = null) { mData = uiData as UIGamePausePanelData ?? new UIGamePausePanelData(); //please add init code here } protected override void ProcessMsg (int eventId,QMsg msg) { throw new System.NotImplementedException (); } protected override void RegisterUIEvent() { BtnMain.onClick.AddListener(() => { CloseSelf(); UIMgr.ClosePanel<UIGamePanel>(); UIMgr.OpenPanel<UIMainPanel>(); }); BtnReplay.onClick.AddListener(() => { Log.E("BtnPlay Clicked"); }); BtnContinue.onClick.AddListener(() => { SendEvent(UIGamePanelEvent.GameResume); CloseSelf(); }); } } }
在 BtnContinue 按鈕點擊之後進行事件的發送就好了。
這就是事件機制的方式。
目前有三種方式:
- 獲取父 Panel 調用方法。
- 對父 Panel 提供委託。
- 發送消息。
這裡筆者比較推薦使用第二種和第三種。
其中最推薦的還是,第二種,提供委託的方式。這樣事件能夠很好地進行接收,保持單向的引用,可以通過程式碼了解兩者之間的關係,所以這通常是很緊實的設計。
而第三種雖然推薦,但是多少會有一些風險。可能造成消息滿天飛的情況,萬不得已的情況下慎用,唯一的好處就是松耦合。在跨模組之間通訊,或者同級的對象之間建議用消息,這種情況已經是萬不得已的情況下了。
而第一種方式呢,造成了雙向引用,是完全違背常識的,所以不推薦,不過項目緊的時候先完成需求為先。
ok,三種方式的利弊介紹到這裡。
觀察者模式與事件機制
觀察者模式是對象/模組之間解耦的利器
觀察者模式是什麼?筆者看了很多官方和書上定義後也是一頭霧水,我們先拋棄 Observer/Subscriber 等技術概念,直接來段簡單易讀的程式碼更容易理解些。
class Person { public string Name; public void Say(string msg) { Log.E ("Person:{0} Say:{1}", Name, msg); } public void ReceiveMsg(string msg) { Say (msg); } }
程式碼不難理解,主要是 ReceiveMsg 方法用來接收消息。
class Me { List<Person> mContacts = new List<Person>(); public void RegisterContact(Person person) { mContacts.Add(person); } public void SendMsgToContancts() { mContacts.ForEach(person => person.ReceiveMsg("Hi")); } }
Me 就是,也就是發送者,將消息發送出去。
- mContacts,就是聯繫人的意思。
- RegisterContact,需要在我的聯繫人名單里註冊好 Person,Person 才能接收到我發出去的消息。
- SendMsgToContacts,則是我向聯繫人發送消息。
測試程式碼:
public class ObserverPatternExample : MonoBehaviour { // Use this for initialization IEnumerator Start () { Me me = new Me (); me.RegisterPerson(new Person (){ Name = "張三" }); me.RegisterPerson(new Person (){ Name = "李四" }); me.RegisterPerson(new Person (){ Name = "王五 "}); yield return new WaitForSeconds (1.0f); me.SendMsgToContancts (); } }
輸出結果為:
Person:張三 Say:Hi Person:李四 Say:Hi Person:王五 Say:Hi
這是一種一對多的消息廣播。
當然多對一,多對多很容易實現,原理都是通過觀察者模式。
有了以上例子為基礎,再去看設計模式中的觀察者模式就會更簡單了。
觀察者模式的經典實現,都有一個消息註冊列表,一般是在,Subject 中,這個 Subject 對應的是 以上例子中的 Me,而 Observer 則對應的是 Person。
接下來介紹下觀察者模式的應用,消息機制 QEventSystem。
QEventSystem 提供了如下的 API:
QEventSystem.RegisterEvent(eventKey,onEventCallback); QEventSystem.UnRegisterEvent(eventKey,onEventCallback); QEventSystem.SendEvent(eventKey,params object[] args);
很簡單,就是註冊,註銷,和發送事件。
而 QEventSystem 本身就是一個消息註冊列表,相當於上個例子中的 List<Person> 封裝到了 QEventSystem。
所以如果將以上例子改成使用 QEventSystem,則程式碼會變成如下:
using System.Collections; using System.Collections.Generic; using UnityEngine; using QFramework; namespace CourseExample { class Person { public const int PersonEvent = 0; public string Name; public Person() { QEventSystem.RegisterEvent (PersonEvent, OnReceiveEvent); } void OnReceiveEvent(int eventKey,object[] args) { var receivedMsg = args [0] as string; Say (receivedMsg); } public void Say(string msg) { Log.E ("Person:{0} Say:{1}", Name, msg); } } class Me { public void SendMsgToContancts() { QEventSystem.SendEvent (Person.PersonEvent, "Hi"); } } public class ObserverPatternExample : MonoBehaviour { // Use this for initialization IEnumerator Start () { Me me = new Me (); new Person (){ Name = "張三" }; new Person (){ Name = "李四" }; new Person (){ Name = "王五 "}; yield return new WaitForSeconds (1.0f); me.SendMsgToContancts (); } } }
QEventSystem 與觀察者模式就介紹到這裡。
中介者模式與事件機制
這裡不多說,主要比較下觀察者模式和中介者模式就理解了。
觀察者 (observer)模式
- 通過 訂閱 – 發布 (subscribe-publish) 模型,消除組件之間雙向依賴
- 消息的 發布者 (subject) 不需要知道 觀察者 (observer) 的存在
- 兩者只需要約定消息的格式(如何訂閱、如何發布),就可以通訊
- 對應的是以上的不用 QEventSystem 的 Person/Me 的例子。
中介者 (mediator) 模式
- 通過設置 消息中心 (message center),避免組件之間直接依賴
- 所有的 協同者 (colleague) 只能通過 中介者 (mediator) 進行通訊,
而相互之間不知道彼此的存在 - 當各個組件的消息出現循環時,消息中心可以消除組件之間的依賴混亂
- 對應的是以上的使用 QEventSystem 的 Person/Me 的例子。
所以 QEventSystem 與其說是觀察者模式,不如說是中介者模式的實現。
以上關於設計模式的描述,僅僅是表達個人的理解,方便初學者更容易理解。
小結 (二)
在這個小結簡單了解了 觀察者模式和中介者模式,以及 UI Kit 所支援的事件機制。
Manager Of Managers 簡介
UI Kit 內置了一個 Manager Of Managers 的支援。
主要提供兩個功能:
- 模組化的腳本管理
- 模組化的消息系統
而模組化的腳本管理,對我們來說不是很陌生。其中 UIMgr 與 UIPanel 則是其一種實現。
UIMgr 為腳本管理器,而 UIPanel 則是腳本單元。
對應的可以推測出, CharacterManager 則有對應的 Character,Enemy/Weapon Manager 有對應的 Weapon 和 Manager 腳本。這是在一個業務邏輯上劃分模組的一種方式。不難理解。
UI Kit 提供了兩個基類:
QMonoBehaviour 和 QMgrBehaviour:
- QMonoBehaviour 則是腳本的基類,比如 UIPanel 則是繼承了 QMonoBehaivour。
- QMgrBhavriour 則是管理類的基類,比如 UIMgr 則是繼承了 QMgrBehaviour。
不難理解,而 UIMgr 裡邊維護了一個關於 UIPanel 的容器。很好地表明了 QMgrBehaviour 與 QMonoBehaviour 之間的關係。
從而想實現其他模組也變得容易,比如 CharacterManager 只要繼承 QMgrBehaviour 而 Character 腳本則繼承 QMonoBehaviour 就可以實現角色模組了。
以上則是模組化的腳本管理的介紹。
另一個利器則是模組化的消息系統。
大家可能發下 UIGamePanel 與 UIGamePausePanel 實現的事件機制 與 後來的 QEventSystem 有一點區別。
在 UIGamePanelEvent 中,事件的定義如下:
public enum UIGamePanelEvent { Start = QMgrID.UI, GameResume, End }
為什麼 Start 要等於 QMgrID.UI 呢?
我們先來看看 QMgrID 是什麼?
public class QMsgSpan { public const int Count = 3000; } public partial class QMgrID { public const int Framework = 0; public const int UI = Framework + QMsgSpan.Count; // 3000 public const int Audio = UI + QMsgSpan.Count; // 6000 public const int Network = Audio + QMsgSpan.Count; public const int UIFilter = Network + QMsgSpan.Count; public const int Game = UIFilter + QMsgSpan.Count; public const int PCConnectMobile = Game + QMsgSpan.Count; public const int FrameworkEnded = PCConnectMobile + QMsgSpan.Count; public const int FrameworkMsgModuleCount = 7; } }
是一個長度為 3000 的區間,那麼從 3000 ~ 5999 則是 UI 模組的消息。
而 6000 ~ 8999 之間的則是 Audio 消息。不難理解。
這樣就實現了一個模組化的消息頻段。
UIGamePausePanel 發送消息的順序為:
UIGamePausePanel -> UIMgr -> UIGamePanel。
這裡還沒有介紹 UIMgr 為什麼可以中轉消息。
我們看了 QMgrBehaviour 與 QMonoBehaivour 實現就知道了:
QMgrBehaviour.cs
/* Copyright (c) 2018.3 liangxie */ namespace QFramework { using System; using System.Collections.Generic; /// <summary> /// manager基類 /// </summary> public abstract class QMgrBehaviour : QMonoBehaviour,IManager { private readonly QEventSystem mEventSystem = NonPublicObjectPool<QEventSystem>.Instance.Allocate(); #region IManager public virtual void Init() {} #endregion protected int mMgrId = 0; protected abstract void SetupMgrId (); protected override void SetupMgr () { mCurMgr = this; } protected QMgrBehaviour() { SetupMgrId (); } public void RegisterEvents<T>(IEnumerable<T> eventIds,OnEvent process) where T: IConvertible { foreach (var eventId in eventIds) { RegisterEvent(eventId,process); } } public void RegisterEvent<T>(T msgId,OnEvent process) where T:IConvertible { mEventSystem.Register (msgId, process); } public void UnRegisterEvents(List<ushort> msgs,OnEvent process) { for (int i = 0;i < msgs.Count;i++) { UnRegistEvent(msgs[i],process); } } public void UnRegistEvent(int msgEvent,OnEvent process) { mEventSystem.UnRegister (msgEvent, process); } public override void SendMsg(QMsg msg) { if (msg.ManagerID == mMgrId) { Process(msg.EventID, msg); } else { QMsgCenter.Instance.SendMsg (msg); } } public override void SendEvent<T>(T eventId) { SendMsg(QMsg.Allocate(eventId)); } // 來了消息以後,通知整個消息鏈 protected override void ProcessMsg(int eventId,QMsg msg) { mEventSystem.Send(msg.EventID,msg); } } }
QMonoBehaviour.cs
/* Copyright (c) 2017 liangxie */ namespace QFramework { using UnityEngine; using System; using System.Collections.Generic; public abstract class QMonoBehaviour : MonoBehaviour { protected bool mReceiveMsgOnlyObjActive = true; public void Process (int eventId, params object[] param) { if (mReceiveMsgOnlyObjActive && gameObject.activeInHierarchy || !mReceiveMsgOnlyObjActive) { QMsg msg = param[0] as QMsg; ProcessMsg(eventId, msg); msg.Processed = true; if (msg.ReuseAble) { msg.Recycle2Cache(); } } } protected virtual void ProcessMsg (int eventId,QMsg msg) {} protected abstract void SetupMgr (); private QMgrBehaviour mPrivateMgr = null; protected QMgrBehaviour mCurMgr { get { if (mPrivateMgr == null ) { SetupMgr (); } if (mPrivateMgr == null) { Debug.LogError ("not set mgr yet"); } return mPrivateMgr; } set { mPrivateMgr = value; } } public virtual void Show() { gameObject.SetActive (true); OnShow (); } protected virtual void OnShow() {} public virtual void Hide() { OnHide (); gameObject.SetActive (false); Log.I("On Hide:{0}",name); } protected virtual void OnHide() {} protected void RegisterEvents<T>(params T[] eventIDs) where T : IConvertible { foreach (var eventId in eventIDs) { RegisterEvent(eventId); } } protected void RegisterEvent<T>(T eventId) where T : IConvertible { mEventIds.Add(eventId.ToUInt16(null)); mCurMgr.RegisterEvent(eventId, Process); } protected void UnRegisterEvent<T>(T eventId) where T : IConvertible { mEventIds.Remove(eventId.ToUInt16(null)); mCurMgr.UnRegistEvent(eventId.ToInt32(null), Process); } protected void UnRegisterAllEvent() { if (null != mPrivateEventIds) { mCurMgr.UnRegisterEvents(mEventIds, Process); } } public virtual void SendMsg(QMsg msg) { mCurMgr.SendMsg(msg); } public virtual void SendEvent<T>(T eventId) where T : IConvertible { mCurMgr.SendEvent(eventId); } private List<ushort> mPrivateEventIds = null; private List<ushort> mEventIds { get { if (null == mPrivateEventIds) { mPrivateEventIds = new List<ushort>(); } return mPrivateEventIds; } } protected virtual void OnDestroy() { OnBeforeDestroy(); mCurMgr = null; if (Application.isPlaying) { UnRegisterAllEvent(); } } protected virtual void OnBeforeDestroy(){} } }
QMgrBehaivour 維護了一個 QEventSytem 成員。用來在模組內進行消息的中轉的。
那麼如何實現跨模組之間的消息發送呢?
這裡看下 QMsgCenter.cs 就理解了
/* Copyright (c) 2017 [email protected] * Copyright (c) 2017 liangxie */ namespace QFramework { using UnityEngine; [QMonoSingletonPath("[Event]/QMsgCenter")] public partial class QMsgCenter : MonoBehaviour, ISingleton { public static QMsgCenter Instance { get { return MonoSingletonProperty<QMsgCenter>.Instance; } } public void OnSingletonInit() { } public void Dispose() { MonoSingletonProperty<QMsgCenter>.Dispose(); } void Awake() { DontDestroyOnLoad(this); } public void SendMsg(QMsg tmpMsg) { // Framework Msg switch (tmpMsg.ManagerID) { case QMgrID.UI: QUIManager.Instance.SendMsg(tmpMsg); return; case QMgrID.Audio: AudioManager.Instance.SendMsg(tmpMsg); return; } // ForwardMsg(tmpMsg); } } }
主要是 SendMsg 這個方法。
每個消息都提供了一個 ManagerID。
什麼時候消息會傳遞到 SendMsg 呢?
就是當消息的 Id 不在自己模組的頻段時候,核心程式碼如下:
QMgrBehaivour.cs
public override void SendMsg(QMsg msg) { if (msg.ManagerID == mMgrId) { Process(msg.EventID, msg); } else { QMsgCenter.Instance.SendMsg (msg); } }
不難理解。
跨模組之間的消息發送順序為:
UIPanel -> UIMgr -> MsgCenter -> CharacterManager -> Character。
關於模組化消息的介紹就到這裡。
小結 (三)
UI Kit 有 Manager Of Managers 框架強力支援。
而 Manager Of Managers 不僅提供了模組實現的工具 QMgrBehaivour 和 QMonoBehaviour,
還提供了緊實的模組化消息支援。
而 Manager Of Manager 貫穿整個 QFramework。
從 框架層的 UIMgr、AudioManager、NetworkManager 到 GamePlay 層的 CharacterManager/GameManager (需自己實現) 等,都可以使用。
本 Chat 的兩個重點,一個是 UI 的管理,和一個是事件系統。
- UI 管理
- 層級管理
- 介面生命周期管理
- 事件系統
- 全局消息機制
- 基於模組的消息機制
除了以上,其實還有一個系統,就是組件系統,整個組件系統的前提是強大的程式碼生成支援。
- UI 組件系統
- UIMark 標記
- UIDefaultComponent: 默認的 Button、Image 等 Unity UGUI 提供的空間,自動識別。
- UIElement: 不可復用的自定義控制項。
- UIComponent: 可復用的自定義控制項。
- UIMark 標記
以上整個系統呢在後續的 GitChat 里介紹。
搭建自己的 UI 框架
之前都是手把手的教大家如何去搭建,不如給大家一個執行清單,大家跟著一下的步驟試著自己實現一下。實現完了之後歡迎跟筆者進行更多的探討。
MyUIKit v0.0.1 開始
-
UIRoot Prefab 結構
- UIRoot(Canvas)
- EventSystem
- UICamera
- UIRoot(Canvas)
-
UIManager
- 實現容器
- 提供動態載入方式
- Resources
- 提供 API
- 載入 UIRoot
- Open
- Close
- Show
- Hide
-
UIPanel
- 提供生命周期支援
- OnOpen
- OnEnter
- OnExit
- OnShow
- OnHide
- 提供生命周期支援
MyUIKit v0.0.2 層級管理
-
UIRoot
- 添加常用層級
-
UIManager
- 增加層級管理
- 提供 Open API 關於層級管理的重載支援
MyUIKit v0.0.3 UIData
-
UIData 基類定義
-
UIPanel
- 提供 UIData 接收參數
-
UIManager
- Open API 提供 UIData 傳入參數
MyUIKit v0.0.4 堆棧支援
-
UIPanelInfo 支援
- 記錄層級資訊
- 記錄PanelName
- 記錄 UIData
-
UIManager
- 提供 Stack 容器
- 提供 Push/Back API
MyUIKit v0.0.5 事件機制實現
MyUIKit v0.0.6 事件機制集成到基類
- UIPanel 集成消息註冊列表,在 OnDestory 或者關閉時自動進行卸載操作。
MyUIKit v0.1.1 簡易 UI Kit
到這裡已經實現了簡易的 UI Kit,包含了 UI 層級管理、堆棧、介面管理、事件系統等核心功能。
在執行以上清單的過程中遇到問題歡迎隨時與我交流。
如果完成,想知道接下來的開發方向,也請與我交流。
轉載請註明地址:涼鞋的筆記:liangxiegame.com
更多內容
-
QFramework 地址:https://github.com/liangxiegame/QFramework
-
QQ 交流群:623597263
-
Unity 進階小班:
- 主要訓練內容:
- 框架搭建訓練(第一年)
- 跟著案例學 Shader(第一年)
- 副業的孵化(第二年、第三年)
- 權益、授課形式等具體詳情請查看《小班產品手冊》:https://liangxiegame.com/master/intro
- 主要訓練內容:
-
關注公眾號:liangxiegame 獲取第一時間更新通知及更多的免費內容。