這篇隨筆是一篇記錄性的隨筆,記錄了從http://www.sikiedu.com/my/course/304,這門課程中學到的內容,附帶了一些自己的思考。
一.單例模式的應用
首先假想一種情況,現在需要有一個按鈕和一個Text,當按下按鈕時,Text上顯示“你好”兩個字。
一個最常見的方法是在按鈕下掛載一個腳本BtnClick,它持有一個Text組件,它由外部的Text拖入來賦值。
在初始化時BtnClick會獲取當前游戲物體下的Button組件並為其添加監聽,當按下按鈕時會修改Text組件中的文本內容。
具體的效果圖和代碼如下:


using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization public Text myText; void Awake () { GetComponent<Button>().onClick.AddListener(()=> { myText.text = "你好"; }); } }
BtnClick中為Button組件添加的監聽的方法是用lambda表達式寫的,不懂的自行查閱資料。
這種方式有兩個問題,一是耦合度過高,假如Text組件不小心被刪除,點擊按鈕會因為找不到Text組件而報錯。 二是只能作一對一的操作,無法實現復雜的交互,舉個例子,假如現在有3個如上圖所示的按鈕,同時只有1個Text,現在要實現一個“累加”的功能,任意一個按鈕被按下時,計數會加1,當累計計數到3時,讓按鈕顯示“你好”兩個字。
假如修改代碼如下:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization public Text myText; private int number; void Awake () { GetComponent<Button>().onClick.AddListener(()=> { number++; if (number >= 3) { myText.text = "你好"; } }); } }
此時對一個按鈕按3下可以讓按鈕顯示“你好”兩個字,那如果我們復制3個相同的按鈕,將Text組件拖放到3個按鈕的BtnClick腳本中,是否可以滿足要求?很顯然,不行,因為定義的number屬於類本身,3個腳本各自有屬於自己的number,所以3個按鈕的點擊會分別疊加,沒有累加效果。
那怎么辦?難道要定義個全局變量來累加嗎?那太蠢了,而且會很混亂。
我們先來解決問題二:
一個最常見的解決方法是為要操作的組件添加交互腳本,並在腳本中使用單例模式:
我們可以在Text組件下掛載一個腳本ShowText,腳本有一個計數器number,並提供一個Show方法,每次調用Show方法會增加計數,當計數滿足條件時會獲取Text組件並修改上面的內容。同時在按鈕下掛載另一個腳本BtnClick,它在初始化時會獲取Button組件並為其添加監聽,當按下按鈕時會調用ShowText腳本中的Show方法,這樣可以起到累加的作用。
具體的效果圖和代碼如下:

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class ShowText : MonoBehaviour { public static ShowText Instance; private int number; private void Awake() { Instance = this; } public void Show(string str) { number++; if (number >= 3) { GetComponent<Text>().text = str; } } }
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization void Awake () { GetComponent<Button>().onClick.AddListener(()=> { ShowText.Instance.Show("你好"); }); } }
你可能會有疑惑,為什么要用單例模式?
要知道,我們這里之所以能實現累加,是因為3個Button的BtnClick都調用了同一個ShowText腳本中的Show方法,從而使計數能被累加。單例模式是為了保證3個BtnClick獲取到的都是同一個ShowText實例對象。假如我們不用單例模式,BtnClick中點擊事件的回調方法要想調用ShowText中的Show方法,就只能實例化一個對象,先不說Unity下繼承自MonoBehaviour的類無法通過new來實例化,就算它可以,我們就只能這樣做:
ShowText myShowText = new ShowText(); myShowText.Show();
顯然,這種情況下3個按鈕下BtnClick獲取到的是3個不同的新創建的ShowText腳本,里面的number屬性自然也無法一起累加,只能各自累加了。
雖然使用單例模式為我們解決了問題二,但仍沒有解決問題一,若Text意外被刪除,BtnClick會因為獲取不到ShowText的單例對象而報錯。
此外,即使不考慮問題一,我們考慮一種情況:假若現在不是3個按鈕共同控制1個Text,而是1個按鈕控制3個Text,當按下按鈕時,需要讓3個Text同時顯示出"你好"。若用單例模式處理,在BtnClick的添加的監聽方法中,需要獲取每一個Text的單例對象,假若這一個按鈕不只有這些Text要控制,還要控制許多其他的UI控件,那會使代碼變得十分臃腫。
如何解決這兩個問題?
其實這兩個問題主要的症結在於當按鈕被按下時,我們在按鈕下掛載的腳本中直接去訪問了Text組件,大大增加耦合度的同時使代碼變得十分臃腫。那為了使按鈕按下時不去直接訪問Text組件,我們必須設計一層中間的過渡腳本,按鈕按下時會到過渡腳本中訪問Text組件中的方法,而且在過渡腳本中也不能直接訪問Text組件下的腳本,否則一旦Text被刪,一樣會導致報錯,但這是不可能的,過渡腳本若不訪問Text組件下的腳本,怎么調用腳本中的方法呢?
我們可以退一步思考,若在Text被刪的時候自動讓過渡腳本知道,Text下掛載的腳本中的方法已經不存在,從而不再訪問,而在Text被創建時也能讓過渡腳本知道,此方法已經可以被調用了,是不是可以解決這兩個問題呢?顯然是的,這么做的話耦合度被大大降低,BtnClick只負責訪問過渡腳本,過渡腳本中自己可以識別Text下掛載的腳本的方法是否存在,不存在就不調用,即使Text被意外刪除,調用BtnClick也不會報錯。
用什么機制來實現呢?用委托與監聽來解決,使用委托是為了解決BtnClick關聯到許多Text時需要獲取很多單例帶來的代碼臃腫問題,委托可以看作一種函數指針,C#中可以把某些方法作為參數進行傳遞,參數的類型就是委托,而委托有一種很關鍵的特性,就是可以相加,一個委托+另一個委托,相當於同時關聯了兩個方法,調用這個委托時相當於同時對這兩個方法進行調用。(當然,委托必須是同類型的才能疊加,比如方法的參數個數,類型要相等),我們可以利用委托的這種特性完成一個BtnClick對許多ShowText腳本下方法的同時調用。
委托的簡要介紹可以參考:http://www.runoob.com/csharp/csharp-delegate.html
二.利用委托與監聽解耦合
現在來描述具體實現思路:
過渡腳本中維護一個字典,字典中存放事件碼(作為鍵)和對應的委托(作為值),事件碼是一個枚舉類型,由一個文件單獨定義,每個事件碼對應一種監聽事件,比如按鈕的點擊,當有新的交互事件要定義時,可以手動添加新的事件碼定義。字典中的值是委托類型,但委托對應的方法可以有參數,可以沒參數,參數不同的委托無法相加,因此需要一個文件專門聲明不同類型的委托,在過渡腳本中需要暴露3個方法,1個用來添加委托,這時就要作委托類型匹配的判斷。另外一個用來移除委托,同樣要類型驗證,還要有一個廣播方法,用來供按鈕點擊后調用,按鈕點擊時傳遞事件碼,廣播方法在字典中找到相應的委托並調用。
那什么時候進行委托的添加和移除呢?肯定是在Text初始化和銷毀時,繼承自MonoBehavior的類由Unity負責初始化和銷毀,Unity初始化它時會調用Awake方法,銷毀時會調用Destroy方法,我們在Awake時把供外部調用的方法添加到過渡腳本中的字典中,Destroy時從字典移除此委托,這就相當於讓Unity來幫我們維護它們,這樣,萬一發生意外情況,Text被銷毀了,Unity一定會調用Destroy,相應的委托就會從字典中消失,這樣即使廣播方法在字典中找不到對應的委托,也不至於報錯,因為它並沒有直接去獲取並使用Text上的組件
這樣,我們就需要3個文件,1個用來定義事件碼,1個用來定義不同參數的委托,1個用來維護管理事件碼委托的字典,並提供外部調用。
先來最簡單的,事件碼的文件的代碼,文件名:EventType.cs,代碼如下:
public enum EventType { ShowText }
當前只添加了一個事件碼,需要時可手動添加
隨后是定義不同參數委托的文件,文件名:CallBack.cs,代碼如下:
public delegate void CallBack(); public delegate void CallBack<T>(T arg); public delegate void CallBack<T, X>(T arg1, X arg2); public delegate void CallBack<T, X, Y>(T arg1, X arg2, Y arg3); public delegate void CallBack<T, X, Y, Z>(T arg1, X arg2, Y arg3, Z arg4); public delegate void CallBack<T, X, Y, Z, W>(T arg1, X arg2, Y arg3, Z arg4, W arg5);
這里重載了6個同名的委托,使用泛型使委托可以適應更多的情況,這些委托覆蓋了參數個數從0到5的所有情況,相應地,在添加委托到字典時,就要設計多個重載的AddListener和RemoveListener函數,以便在添加和刪除委托時使用相應的方法。
下面是過渡腳本,文件名:EventCenter.cs,代碼如下:
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class EventCenter { private static Dictionary<EventType, Delegate> m_EventTable = new Dictionary<EventType, Delegate>(); private static void OnListenerAdding(EventType eventType, Delegate callBack) { if (!m_EventTable.ContainsKey(eventType)) { m_EventTable.Add(eventType, null); } Delegate d = m_EventTable[eventType]; if (d != null && d.GetType() != callBack.GetType()) { throw new Exception(string.Format("嘗試為事件{0}添加不同類型的委托,當前事件所對應的委托是{1},要添加的委托類型為{2}", eventType, d.GetType(), callBack.GetType())); } } private static void OnListenerRemoving(EventType eventType, Delegate callBack) { if (m_EventTable.ContainsKey(eventType)) { Delegate d = m_EventTable[eventType]; if (d == null) { throw new Exception(string.Format("移除監聽錯誤:事件{0}沒有對應的委托", eventType)); } else if (d.GetType() != callBack.GetType()) { throw new Exception(string.Format("移除監聽錯誤:嘗試為事件{0}移除不同類型的委托,當前委托類型為{1},要移除的委托類型為{2}", eventType, d.GetType(), callBack.GetType())); } } else { throw new Exception(string.Format("移除監聽錯誤:沒有事件碼{0}", eventType)); } } private static void OnListenerRemoved(EventType eventType) { if (m_EventTable[eventType] == null) { m_EventTable.Remove(eventType); } } //no parameters public static void AddListener(EventType eventType, CallBack callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack)m_EventTable[eventType] + callBack; } //Single parameters public static void AddListener<T>(EventType eventType, CallBack<T> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T>)m_EventTable[eventType] + callBack; } //two parameters public static void AddListener<T, X>(EventType eventType, CallBack<T, X> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X>)m_EventTable[eventType] + callBack; } //three parameters public static void AddListener<T, X, Y>(EventType eventType, CallBack<T, X, Y> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y>)m_EventTable[eventType] + callBack; } //four parameters public static void AddListener<T, X, Y, Z>(EventType eventType, CallBack<T, X, Y, Z> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z>)m_EventTable[eventType] + callBack; } //five parameters public static void AddListener<T, X, Y, Z, W>(EventType eventType, CallBack<T, X, Y, Z, W> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z, W>)m_EventTable[eventType] + callBack; } //no parameters public static void RemoveListener(EventType eventType, CallBack callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //single parameters public static void RemoveListener<T>(EventType eventType, CallBack<T> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //two parameters public static void RemoveListener<T, X>(EventType eventType, CallBack<T, X> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //three parameters public static void RemoveListener<T, X, Y>(EventType eventType, CallBack<T, X, Y> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //four parameters public static void RemoveListener<T, X, Y, Z>(EventType eventType, CallBack<T, X, Y, Z> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //five parameters public static void RemoveListener<T, X, Y, Z, W>(EventType eventType, CallBack<T, X, Y, Z, W> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z, W>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //no parameters public static void Broadcast(EventType eventType) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack callBack = d as CallBack; if (callBack != null) { callBack(); } else { throw new Exception(string.Format("廣播事件錯誤:事件{0}對應委托具有不同的類型", eventType)); } } } //single parameters public static void Broadcast<T>(EventType eventType, T arg) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T> callBack = d as CallBack<T>; if (callBack != null) { callBack(arg); } else { throw new Exception(string.Format("廣播事件錯誤:事件{0}對應委托具有不同的類型", eventType)); } } } //two parameters public static void Broadcast<T, X>(EventType eventType, T arg1, X arg2) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X> callBack = d as CallBack<T, X>; if (callBack != null) { callBack(arg1, arg2); } else { throw new Exception(string.Format("廣播事件錯誤:事件{0}對應委托具有不同的類型", eventType)); } } } //three parameters public static void Broadcast<T, X, Y>(EventType eventType, T arg1, X arg2, Y arg3) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X, Y> callBack = d as CallBack<T, X, Y>; if (callBack != null) { callBack(arg1, arg2, arg3); } else { throw new Exception(string.Format("廣播事件錯誤:事件{0}對應委托具有不同的類型", eventType)); } } } //four parameters public static void Broadcast<T, X, Y, Z>(EventType eventType, T arg1, X arg2, Y arg3, Z arg4) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X, Y, Z> callBack = d as CallBack<T, X, Y, Z>; if (callBack != null) { callBack(arg1, arg2, arg3, arg4); } else { throw new Exception(string.Format("廣播事件錯誤:事件{0}對應委托具有不同的類型", eventType)); } } } //five parameters public static void Broadcast<T, X, Y, Z, W>(EventType eventType, T arg1, X arg2, Y arg3, Z arg4, W arg5) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X, Y, Z, W> callBack = d as CallBack<T, X, Y, Z, W>; if (callBack != null) { callBack(arg1, arg2, arg3, arg4, arg5); } else { throw new Exception(string.Format("廣播事件錯誤:事件{0}對應委托具有不同的類型", eventType)); } } } }
這里有很多要注意的點:
1.先看AddListener系列的方法,6個方法分別對應傳入6種不同參數個數的委托,之所以每個里面要分兩步完成,是為了進行代碼精簡,OnListenerAdding將6種AddListener的相同部分抽取了出來,注意為了實現抽取使用了多態,用Delegate容納不同CallBack參數。
簡單講述下AddListener的邏輯:
1.先判斷字典中有無對應事件碼,沒有就添加新的,新的事件碼對應的的委托方法設為null
2.取出事件碼對應的委托,如果這個委托不是null,且它的類型和傳入的CallBack類型不同,則拋出異常。
3.經歷了以上兩步,顯然此時事件碼對應的委托要不就是null,要不就是和傳入的CallBack類型相同,此時根據AddListener的不同將事件碼對應的委托轉換 成相同的類型並進行相加,重新賦回到字典中的對應項。
這里有一點很關鍵:仔細想,為什么我們要把AddListener分成那么多類型,傳入不同的CallBack,能不能利用多態,第二個參數能不能直接傳入Delegate?
答案是不行,因為上面的第2步會將字典中的相應Delegate與傳入的CallBack進行類型比較,在同類型的情況下,第3步會把事件碼對應的委托轉換成傳入的CallBack的類型並加回到字典中,假如傳入的就是Delegate,那在第3步根本不存在類型轉換,那存入字典的就一定是Delegate類型,那就根本沒法在下一個第2步找出類型不同的情況。
同理,敘述一下RemoveListener的邏輯:
1.先判斷字典中有無對應事件碼,沒有就直接拋出異常。
2.有的話取出事件碼對應的委托,如果這個委托為null,拋出異常。如果不為null且它的類型和傳入的CallBack類型不同,也拋出異常。
3.經歷了以上兩步,顯然此時事件碼對應的委托一定和傳入的CallBack類型相同,此時根據AddListener的不同將事件碼對應的委托轉換成相同的類型並進行相減。
4.減完后要判斷事件碼對應的委托是不是null,是的話移除事件碼及對應的委托。
BroadCast也一樣要分多種參數類型,因為有可能要傳入不同數量的參數,BroadCast的邏輯:
1.用TryGet獲取對應事件碼的委托,若獲取失敗不做任何操作(這一條保證了不會報錯)
2.若獲取成功,將此委托強轉為對應的CallBack,這一條很重要,這就是為什么BroadCast也要分多種參數類型原因,不分的話,不知道要把委托轉換成哪種具體的CallBack,而以Delegate為類型,自然無法調用相應的參數。
3.判斷強轉的CallBack是不是null,是代表強轉失敗,這是因為事件碼對應的委托的參數並不是此類BroadCast能處理的,此時要拋出異常。
4.若CallBack不是null,代表強轉成功,直接調用方法即可。
接下來給出測試用的ShowText腳本(掛載在Text組件上)和BtnClick腳本(掛載在按鈕上)
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class ShowText : MonoBehaviour { private void Awake() { gameObject.SetActive(false); EventCenter.AddListener<string, string, float, int, int>(EventType.ShowText, Show); } private void OnDestroy() { EventCenter.RemoveListener<string, string, float, int, int>(EventType.ShowText, Show); } private void Show(string str,string str1,float a, int b, int c) { gameObject.SetActive(true); GetComponent<Text>().text = str + str1 + a + b + c; } }
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization private void Awake () { GetComponent<Button>().onClick.AddListener(()=> { EventCenter.Broadcast(EventType.ShowText,"你好","呀",1.0f,1,2); }); } }
這個沒什么好說的,ShowText在Awake時把Show方法(這里用5個參數的作演示)添加到字典,在Destroy中作移除。
BtnClick在被點擊時調用BroadCast並傳入相應的參數。
有一點要注意:AddListener的調用要明確指明使用的泛型,<string,string,float,int,int>,若直接
EventCenter.AddListener(EventType.ShowText, Show);
會報錯,這是因為不同版本的AddListener參數個數是相同的,因此調用時若不指定調用的AddListener版本,程序不知道調用哪個。RemoveListener也是同理。但是,對BroadCast的調用則不必指定版本,因為不同版本的BroadCast參數個數不同,根據傳入的參數個數,程序能自動識別調用哪個版本的BroadCast。
這種利用委托和監聽解耦合的思路也是有缺點的,就是調用時順序必須匹配,若ShowText以<string,string,float,int,int>的方式添加了委托,則調用時也必須以同樣的順序傳入參數,而我們事先無法得知具體的順序是怎樣的,要在BtnClick實現調用,就只能通過跳轉代碼,跳轉到ShowText的源代碼中去了解調用順序。
另外,我自己的看法是,這種方式可能會破壞封裝性,因為ShowText中的Show方法,在ShowText中是被定義為private的,而這個方法又在ShowText被初始化的時候作為委托被添加到了EventCenter中的字典里,按下按鈕時根據事件碼從字典中找到對應的委托並調用,相當於在ShowText類外部可以隨意對類內的私有private方法進行隨意調用,安全性似乎有點問題。但這不是由這種思路帶來的,而是委托機制本身帶來的缺陷,拋開這門課不講,一個方法可以通過委托傳遞可以讓人接受,但一個類中的私有方法可以通過委托被傳遞,從而供外部函數隨意調用,就有點奇怪了,在我看來,至少也應該設計成只有類中的公有方法才能通過委托傳遞才對啊,C#里的這種委托機制真的不會帶來安全性的隱患嗎?這只是我的一點小疑惑,可能是目前對委托的理解不夠深,等以后明白了再回來填坑吧。
