Unity 遊戲框架搭建 2019 (四十六) 簡易消息機制 & 集成到 MonoBehaviourSimplify 里

在上一篇,我們接觸了單例,使用單例解決了我們腳本之間訪問的問題。

腳本之間訪問其實有更好的方式。

我們先分下腳本訪問腳本的幾種形式。

第一種,A GameObject 是 B GameObject 的 Parent,或者是中間隔著幾個層級的 Parent。

那這種情況下,如果 A 腳本想調用 B 腳本的方法,直接通過 transform.Find(「XXX/YYY/ZZZ」).GetComponent<B>().DoSomething() 就可以了。

但是如果是 B 腳本想調用 A 腳本的方法,比較好的方式呢,是在 B 腳本中聲明委託,然後在 A 中註冊特定方法。當 B 想調用 A 腳本的方法的時候,通過委託通知就好。

除了使用委託,也可以使用消息機制,Unity 本身實現了一套消息機制,比如在 B 腳本中可以使用, this.SendMessageUpward(「MethedName」) 這樣的方式。不過這種方式由於是使用字元串,並且可能用到了反射,所以網上大部分部落格都不太推薦使用,但是也算是個不錯的方式。

第二種情況呢是,A GameObject 和 B GameObject 是同級的,比如他們有共同的 Parent。這種情況下,筆者還是推薦用消息機制,不過不是 Unity 自帶的消息機制,而是自己實現的消息機制。

第三種情況是,A GameObject 和 B GameObject 不在同一個 GameObject 樹下。那麼這種情況很可能就是跨模組通訊了,這種情況下,還是推薦用消息機制。

所以,我們可以試試使用消息機制來解決我們的問題。

可是我們目前手裡沒有消息機制…

那就造一個吧。

消息機制要用到的知識:

  1. List 或 LinkedList 或者自己實現的鏈表。
  2. Dictionary
  3. 委託

關於第一條,我們選擇 List 就好了,不過為了有更高的效率,我們最後會升級成鏈表。第三條,我們選擇 Action,因為這是我們接觸過的,以後也是用的比較多的。

而一般的消息機制會提供三個 API。

  1. 註冊事件
  2. 註銷事件
  3. 發送事件

我們先試著設計一下,假如我們想這樣使用我們的 API

MsgDispatcher.Register("消息名",(obj)=>{  /* 處理消息 */ });
MsgDispatcher.Send("消息名","消息內容");
MsgDispatcher.UnRegister("消息名");

首先事件名,是一個字元串類型的,而事件名要對應一個委託。我們聲明一個靜態的字典變數就好了。

private static Dictionary<string, Action<object>> RegisteredMsgs = new Dictionary<string, Action<object>>();

為什麼是靜態的呢?因為,我們的消息機制呢不需要創建實例,而消息是要在整個項目內之間通訊的,也就是全局的消息。全局的消息就需要放在唯一容器里註冊。而這個容器就是我們的這個字典變數。

我們先實現註冊事件功能。

public static void Register(string msgName, Action<object> onMsgReceived)
{
	RegisteredMsgs.Add(msgName, onMsgReceived);
}

非常簡單。

我們再實現註銷功能。

public static void UnRegister(string msgName)
{
	RegisteredMsgs.Remove(msgName);
}

也非常簡單。

再實現發送功能。

public static void Send(string msgName, object data)
{
	RegisteredMsgs[msgName](data);
}

非常簡單。

第十二個示例程式碼如下:

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

namespace QFramework
{
    public class MsgDispatcher
    {
        private static Dictionary<string, Action<object>> RegisteredMsgs = new Dictionary<string, Action<object>>();

        public static void Register(string msgName, Action<object> onMsgReceived)
        {
            RegisteredMsgs.Add(msgName, onMsgReceived);
        }

        public static void UnRegister(string msgName)
        {
            RegisteredMsgs.Remove(msgName);
        }

        public static void Send(string msgName, object data)
        {
            RegisteredMsgs[msgName](data);
        }

#if UNITY_EDITOR
        [UnityEditor.MenuItem("QFramework/12.簡易消息機制", false, 13)]
#endif
        private static void MenuClicked()
        {
            Register("消息1", data => { Debug.LogFormat("消息1:{0}", data); });

            Send("消息1", "hello world");

            UnRegister("消息1");

            Send("消息1", "hello");
        }
    }
}

菜單執行結果如下
image.png

哈哈哈,報錯啦,不過我們發現,第一次消息發送成功了,但是第二次發送的時候報錯了。是因為消息進行註銷了,也就是字典里沒有消息名了,這時候直接從字典里取值當然會報錯。

這個問題我們留在下一篇解決,在下一篇,我們要講解關於這個消息機制的完善。

第十二個示例還沒有完成。

集成到 MonoBehaviourSimplify 里。

還記得我們的簡易消息機制是為了解決什麼問題誕生的嘛?

是為了解決腳本間訪問的問題。

我們回過頭再看下 A 腳本如果想訪問 B 腳本,使用消息機制,如何實現。

程式碼如下:

public class A : MonoBehaviour
{
	void Update()	
	{
		if(Input.GetMouseButtonDown(0))
		{
			MsgDispatcher.Send("DO","ok");
		}
	}
}

public class B : MonoBehaviour
{
	void Awake()
	{
		MsgDispatcher.Register("DO",DoSomething);
	}

	void DoSomething(object data)
	{
		// do something
	}
	
	void OnDestroy()
	{
		MsgDispatcher.UnRegiter("DO",DoSomething);
	}
}

用法還是很簡單的。

不過假如我們的 B 腳本註冊了非常多的消息,程式碼會變成如下:

public class B : MonoBehaviour
{
	void Awake()
	{
		MsgDispatcher.Register("DO",DoSomething);
		MsgDispatcher.Register("DO1",MsgReceiver);
		MsgDispatcher.Register("DO2",MsgReceiver1);
		MsgDispatcher.Register("DO3",MsgReceiver2);
	}

	void DoSomething(object data)
	{
		// do something
	}

	...
	
	void OnDestroy()
	{
		MsgDispatcher.UnRegiter("DO",DoSomething);
		MsgDispatcher.UnRegiter("DO1",MsgReceiver);
		MsgDispatcher.UnRegiter("DO2",MsgReceiver1);
		MsgDispatcher.UnRegiter("DO3",MsgReceiver2);
	}
}

每次註冊一個消息,對應地,在 OnDestroy 操作的時候就要註銷一個事件。這個非常像我們寫 C++ 的時候遵循的一個記憶體管理法則,每次申請記憶體就要在析構方法里進行釋放。

而這樣使用消息機制,初學者非常容易忘記消息的註銷,從而導致引用異常等等。

那麼如何解決呢?

用一個 Dictionary 記錄這個腳本中已經註冊過的消息,以及消息名對應的回調。

程式碼如下:

public class B : MonoBehaviour
{
	Dictionary<string,Action<object>> mMsgRegisterRecorder = new Dictionary<string,Action<object>>();

	void Awake()
	{
		MsgDispatcher.Register("DO",DoSomething);
		mMsgRegisterRecorder.Add("DO",DoSomething);

		MsgDispatcher.Register("DO1",MsgReceiver);
		mMsgRegisterRecorder.Add("DO1",MsgReceiver);

		MsgDispatcher.Register("DO2",MsgReceiver1);
		mMsgRegisterRecorder.Add("DO2",MsgReceiver1);

		MsgDispatcher.Register("DO3",MsgReceiver2);
		mMsgRegisterRecorder.Add("DO3",MsgReceiver2);

	}

	void DoSomething(object data)
	{
		// do something
	}

	...
	
	void OnDestroy()
	{
		foreach (var keyValuePair in mMsgRegisterRecorder)
		{
			MsgDispatcher.UnRegister(keyValuePair.Key,keyValuePair.Value);                
		}
            
		mMsgRegisterRecorder.Clear();
	}
}	

這樣,不管註冊了多少個消息,只要在 OnDestroy 的時候, 進行一個遍歷,這樣消息就全部註銷掉了。

但是這樣寫的話註冊,就變得麻煩了,每次註冊要先兩行程式碼。

MsgDispatcher.Register("DO3",MsgReceiver2);
mMsgRegisterRecorder.Add("DO3",MsgReceiver2);

把兩行提取成一個方法就好了。
提取的方法,程式碼如下:

private void RegisterMsg(string msgName, Action<object> onMsgReceived)
{
	MsgDispatcher.Register(msgName, onMsgReceived);
	mMsgRegisterRecorder.Add(msgName, onMsgReceived);
}

而註冊消息的程式碼就會變成如下:

private void Awake()
{
	RegisterMsg("Do",DoSomething);
	RegisterMsg("DO1",MsgReceiver);
	RegisterMsg("DO2", _=>{ });
	RegisterMsg("DO3", _=>{ });
}

是不是精簡了很多,而且也可以註冊 Lambda 表達式了。

不過我們看下現在的 B 腳本全部程式碼:

    public class B : MonoBehaviour
    {
        Dictionary<string, Action<object>> mMsgRegisterRecorder = new Dictionary<string, Action<object>>();

        private void Awake()
        {
            RegisterMsg("Do",DoSomething);
            RegisterMsg("DO1",_=>{ });
            RegisterMsg("DO2", _=>{ });
            RegisterMsg("DO3", _=>{ });
        }

        private void RegisterMsg(string msgName, Action<object> onMsgReceived)
        {
            MsgDispatcher.Register(msgName, onMsgReceived);
            mMsgRegisterRecorder.Add(msgName, onMsgReceived);
        }

        void DoSomething(object data)
        {
            // do something
        }

        private void OnDestroy()
        {
            foreach (var keyValuePair in mMsgRegisterRecorder)
            {
                MsgDispatcher.UnRegister(keyValuePair.Key,keyValuePair.Value);                
            }
            
            mMsgRegisterRecorder.Clear();
        }
    }

目前,每個要使用相同消息策略的腳本,都實現如上的程式碼,會產生很多的重複程式碼。所以這裡我們要開始考慮如何讓這個消息註冊/註銷的策略進行復用。首先用靜態方法是不可能了,因為這個策略是有狀態的(成員變數)。所以以我們目前掌握的知識來看,只能用繼承的方式了。

繼承也有兩種,一種是繼承一個新類,另一種是繼承到 MonoBehaviourSimplify 里。

筆者選擇後者,這樣我們的腳本只要繼承 MonoBehaviourSimplify 就會獲得 API 簡化和消息功能了,一舉多得,而且很方便。

集成後的程式碼,也就是第十三個示例的程式碼如下:

using System;
using System.Collections.Generic;

namespace QFramework
{
    public abstract partial class MonoBehaviourSimplify
    {
        Dictionary<string, Action<object>> mMsgRegisterRecorder = new Dictionary<string, Action<object>>();

        protected void RegisterMsg(string msgName, Action<object> onMsgReceived)
        {
            MsgDispatcher.Register(msgName, onMsgReceived);
            mMsgRegisterRecorder.Add(msgName, onMsgReceived);
        }

        
        private void OnDestroy()
        {
            OnBeforeDestroy();
            
            foreach (var keyValuePair in mMsgRegisterRecorder)
            {
                MsgDispatcher.UnRegister(keyValuePair.Key,keyValuePair.Value);                
            }
            
            mMsgRegisterRecorder.Clear();
        }

        protected abstract void OnBeforeDestroy();
    }

    public class B : MonoBehaviourSimplify
    {
        private void Awake()
        {
            RegisterMsg("Do", DoSomething);
            RegisterMsg("DO1", _ => { });
            RegisterMsg("DO2", _ => { });
            RegisterMsg("DO3", _ => { });
        }

        void DoSomething(object data)
        {
            // do something
        }

        protected override void OnBeforeDestroy()
        {
            
        }
    }
}

在以上程式碼里,筆者把 MonoBehaviourSimplify 添加了 abstract 關鍵字,這樣用戶在使用 MonoBehaviourSimplify 的時候就不能自己創建出來實例了。

而又添加了如下抽象方法:

protected abstract void OnBeforeDestroy();

做這步的目的呢,是為了提醒子類不要覆寫了 OnDestroy。提醒是怎麼做到的呢。

我們通過分析可以得出,使用 MonoBehaviourSimplify 的情況有兩種。
一種是,在寫腳本之前就想好了這個腳本要繼承 MonoBehaviourSimplify,但是繼承之後,編譯會報錯,因為有一個抽象方法,必須實現,也就是 OnBeforeDestroy。那麼實現了這個,用戶就會知道設計 MonoBehaviourSimplify 的人,是推薦用 OnBeforeDestroy 來做卸載邏輯的,並不推薦用 OnDestroy。這是第一種。

第二種呢,腳本本來就有了,但是在中途想要換成繼承 MonoBehaviourSimplify,繼承了之後,同樣報錯了,報錯了之後發現 MonoBehaviourSimplify 推薦用 OnBeforeDestroy 來做卸載邏輯,這時候如果以前的腳本已經有了 OnDestroy 邏輯,用戶就會把 OnDestroy 的邏輯遷移到 OnBeforeDestroy 里。這樣也算達到了一個提醒的作用。

這就是 OnBeforeDestroy 的設計初衷,而 abstract 關鍵字,就應該這樣用。

但是到這裡呢,這套策略還是有一點小問題的。這個小問題就留在下一篇講了。

今天的內容就這些,我們下一篇再見。

轉載請註明地址:涼鞋的筆記:liangxiegame.com

更多內容