詳解Unity中的委託與事件
0x00 前言
由於工作繁忙所以距離上一篇部落格已經過去一個多月的時間了,因此決心這個週末無論如何也得寫點東西出來,既是總結也是分享。那麼本文主要的內容集中在了委託的使用以及內部結構(當然還有事件了,但是受制於篇幅故分為兩篇文章)以及結合一部分Unity3D的設計思考。當然由於時間倉促,文中難免有一些疏漏和不準確,也歡迎各位指出,共同進步。
回到目錄0x01 從觀察者模式說起
在設計模式中,有一種我們常常會用到的設計模式——觀察者模式。那麼這種設計模式和我們的主題“如何在Unity3D中使用委託”有什麼關係呢?別急,先讓我們來聊一聊什麼是觀察者模式。
首先讓我們來看看報紙和雜誌的訂閱是怎麼一回事:
- 報社的任務便是出版報紙。
- 向某家報社訂閱他們的報紙,只要他們有新的報紙出版便會向你發放。也就是說,只要你是他們的訂閱客戶,便可以一直收到新的報紙。
- 如果不再需要這份報紙,則可以取消訂閱。取消之後,報社便不會再送新的報紙過來。
- 報社和訂閱者是兩個不同的主體,只要報社還一直存在著,不同的訂閱者便可以來訂閱或取消訂閱。
如果各位讀者能看明白我上面所說的報紙和雜誌是如何訂閱的,那麼各位也就瞭解了觀察者模式到底是怎麼一回事。除了名稱不大一樣,在觀察者模式中,報社或者說出版者被稱為“主題”(Subject),而訂閱者則被稱為“觀察者”(Observer)。將上面的報社和訂閱者的關係移植到觀察者模式中,就變成了如下這樣:主題(Subject)物件管理某些資料,當主題內的資料改變時,便會通知已經訂閱(註冊)的觀察者,而已經註冊主題的觀察者此時便會收到主題資料改變的通知並更新,而沒有註冊的物件則不會被通知。
當我們試圖去勾勒觀察者模式時,可以使用報紙訂閱服務,或者出版者和訂閱者來比擬。而在實際的開發中,觀察者模式被定義為了如下這樣:
觀察者模式:定義了物件之間的一對多依賴,這樣一來,當一個物件改變狀態時,它的所有依賴者都會收到通知並自動更新。
那麼介紹了這麼多觀察者模式,是不是也該說一說委託了呢?是的,C#語言通過委託來實現回撥函式的機制,而回調函式是一種很有用的程式設計機制,可以被廣泛的用在觀察者模式中。
那麼Unity3D本身是否有提供這種機制呢?答案也是肯定的,那麼和委託又有什麼區別呢?下面就讓我們來聊一聊這個話題。
回到目錄0x02 向Unity3D中的SendMessage和BroadcastMessage說拜拜
當然,不可否認Unity3D遊戲引擎的出現是遊戲開發者的一大福音。但不得不說的是,Unity3D的遊戲指令碼的架構中是存在一些缺陷的。一個很好的例子就是本節要說的圍繞SendMessage和BroadcastMessage而構建的訊息系統。之所以說Unity3D的這套訊息系統存在缺陷,主要是由於SendMessage和BroadcastMessage過於依賴反射機制(reflection)來查詢訊息對應的回撥函式。頻繁的使用反射自然會影響效能,但是效能的損耗還並非最為嚴重的問題,更加嚴重的問題是使用這種機制之後程式碼的維護成本。為什麼說這樣做是一個很糟糕的事情呢?因為使用字串來標識一個方法可能會導致很多隱患的出現。舉一個例子:假如開發團隊中某個開發者決定要重構某些程式碼,很不巧,這部分程式碼便是那些可能要被這些訊息呼叫的方法定義的程式碼,那麼如果方法被重新命名甚至被刪除,是否會導致很嚴重的隱患呢?答案是yes。這種隱患的可怕之處並不在於可能引發的編譯時錯誤,恰恰相反,這種隱患的可怕之處在於編譯器可能都不會報錯來提醒開發者某些方法已經被改名甚至是不存在了,面對一個能夠正常的執行程式而沒有警覺是最可怕的,而什麼時候這個隱患會爆發呢?就是觸發了特定的訊息而找不到對應的方法的時候 ,但這時候發現問題所在往往已經太遲了。
另一個潛在的問題是由於使用了反射機制因而Unity3D的這套訊息系統也能夠呼叫宣告為私有的方法的。但是如果一個私有方法在宣告的類的內部沒有被使用,那麼正常的想法肯定都認為這是一段廢程式碼,因為在這個類的外部不可能有人會呼叫它。那麼對待廢程式碼的態度是什麼呢?我想很多開發者都會選擇消滅這段廢程式碼,那麼同樣的隱患又會出現,可能在編譯時並沒有問題,甚至程式也能正常執行一段時間,但是隻要觸發了特定的訊息而沒有對應的方法,那便是這種隱患爆發的時候。因而,是時候向Unity3D中的SendMessage和BroadcastMessage說拜拜了,讓我們選擇C#的委託來實現自己的訊息機制吧。
回到目錄0x03 認識回撥函式機制----委託
在非託管程式碼C/C++中也存在類似的回撥機制,但是這些非成員函式的地址僅僅是一個記憶體地址。而這個地址並不攜帶任何額外的資訊,例如函式的引數個數、引數型別、函式的返回值型別,因而我們說非託管C/C++程式碼的回撥函式不是型別安全的。而C#中提供的回撥函式的機制便是委託,一種型別安全的機制。為了直觀的瞭解委託,我們先來看一段程式碼:
using UnityEngine; using System.Collections; public class DelegateScript : MonoBehaviour { //宣告一個委託型別,它的例項引用一個方法 internal delegate void MyDelegate(int num); MyDelegate myDelegate; void Start () { //委託型別MyDelegate的例項myDelegate引用的方法 //是PrintNum myDelegate = PrintNum; myDelegate(50); //委託型別MyDelegate的例項myDelegate引用的方法 //DoubleNum myDelegate = DoubleNum; myDelegate(50); } void PrintNum(int num) { Debug.Log ("Print Num: " + num); } void DoubleNum(int num) { Debug.Log ("Double Num: " + num * 2); } }
下面我們來看看這段程式碼做的事情。在最開始,我們可以看到internal委託型別MyDelegate的宣告。委託要確定一個回撥方法簽名,包括引數以及返回型別等等,在本例中MyDelegate委託制定的回撥方法的引數型別是int型,同時返回型別為void。
DelegateScript類還定義了兩個私有方法PrintNum和DoubleNum,它們的分別實現了列印傳入的引數和列印傳入的引數的兩倍的功能。在Start方法中,MyDelegate類的例項myDelegate分別引用了這兩個方法,並且分別呼叫了這兩個方法。
看到這裡,不知道各位讀者是否會產生一些疑問,為什麼一個方法能夠像這樣myDelegate = PrintNum; “賦值”給一個委託呢?這便不得不提C#2為委託提供的方法組轉換。回溯C#1的委託機制,也就是十分原始的委託機制中,如果要建立一個委託例項就必須要同時指定委託型別和要呼叫的方法(執行的操作),因而剛剛的那行程式碼就要被改為:
new MyDelegate(PrintNum);
即便回到C#1的時代,這行建立新的委託例項的程式碼看上去似乎並沒有讓開發者產生什麼不好的印象,但是如果是作為較長的一個表示式的一部分時,就會讓人感覺很冗繁了。一個明顯的例子是在啟動一個新的執行緒時候的表示式:
Thread th = new Thread(new ThreadStart(Method));
這樣看起來,C#1中的方式似乎並不簡潔。因而C#2為委託引入了方法組轉換機制,即支援從方法到相容的委託型別的隱式轉換。就如同我們一開始的例子中做的那樣。
//使用方法組轉換時,隱式轉換會將 //一個方法組轉換為具有相容簽名的 //任意委託型別 myDelegate = PrintNum; Thread th = new Thread(Method);
而這套機制之所以叫方法組轉換,一個重要的原因就是由於過載,可能不止一個方法適用。例如下面這段程式碼所演示的那樣:
using UnityEngine; using System.Collections; public class DelegateScript : MonoBehaviour { //宣告一個委託型別,它的例項引用一個方法 delegate void MyDelegate(int num); //宣告一個委託型別,它的例項引用一個方法 delegate void MyDelegate2(int num, int num2); MyDelegate myDelegate; MyDelegate2 myDelegate2; void Start () { //委託型別MyDelegate的例項myDelegate引用的方法 //是PrintNum myDelegate = PrintNum; myDelegate(50); //委託型別MyDelegate2的例項myDelegate2引用的方法 //PrintNum的過載版本 myDelegate2 = PrintNum; myDelegate(50, 50); } void PrintNum(int num) { Debug.Log ("Print Num: " + num); } void PrintNum(int num1, int num2) { int result = num1 + num2; Debug.Log ("result num is : " + result); } }
這段程式碼中有兩個方法名相同的方法:
void PrintNum(int num)
void PrintNum(int num1, int num2)
那麼根據方法組轉換機制,在向一個MyDelegate或一個MyDelegate2賦值時,都可以使用PrintNum作為方法組(此時有2個PrintNum,因而是“組”),編譯器會選擇合適的過載版本。
當然,涉及到委託的還有它的另外一個特點——委託引數的逆變性和委託返回型別的協變性。這個特性在很多文章中也有過介紹,但是這裡為了使讀者更加加深印象,因而要具體的介紹一下委託的這種特性。
在為委託例項引用方法時,C#允許引用型別的協變性和逆變性。協變性是指方法的返回型別可以是從委託的返回型別派生的一個派生類,也就是說協變性描述的是委託返回型別。逆變性則是指方法獲取的引數的型別可以是委託的引數的型別的基類,換言之逆變性描述的是委託的引數型別。
例如,我們的專案中存在的基礎單位類(BaseUnitClass)、士兵類(SoldierClass)以及英雄類(HeroClass),其中基礎單位類BaseUnitClass作為基類派生出了士兵類SoldierClass和英雄類HeroClass,那麼我們可以定義一個委託,就像下面這樣:
delegate Object TellMeYourName(SoldierClass soldier);
那麼我們完全可以通過構造一個該委託型別的例項來引用具有以下原型的方法:
string TellMeYourNameMethod(BaseUnitClass base);
在這個例子中,TellMeYourNameMethod方法的引數型別是BaseUnitClass,它是TellMeYourName委託的引數型別SoldierClass的基類,這種引數的逆變性是允許的;而TellMeYourNameMethod方法的返回值型別為string,是派生自TellMeYourName委託的返回值型別Object的,因而這種返回型別的協變性也是允許的。但是有一點需要指出的是,協變性和逆變性僅僅支援引用型別,所以如果是值型別或void則不支援。下面我們接著舉一個例子,如果將TellMeYourNameMethod方法的返回型別改為值型別int,如下:
int TellMeYourNameMethod(BaseUnitClass base);
這個方法除了返回型別從string(引用型別)變成了int(值型別)之外,什麼都沒有被改變,但是如果要將這個方法繫結到剛剛的委託例項上,編譯器會報錯。雖然int型和string型一樣,都派生自Object類,但是int型是值型別,因而是不支援協變性的。這一點,各位讀者在實際的開發中一定要注意。
好了,到此我們應該對委託有了一個初步的直觀印象。在本節中我帶領大家直觀的認識了委託如何在程式碼中使用,以及通過C#2引入的方法組轉換機制為委託例項引用合適的方法以及委託的協變性和逆變性。那麼本節就到此結束,接下來讓我們更進一步的探索委託。
回到目錄0x04 委託是如何實現的
讓我們重新定義一個委託並建立它的例項,之後再為該例項繫結一個方法並呼叫它:
internal delegate void MyDelegate(int number); MyDelegate myDelegate = new MyDelegate(myMethod1); myDelegate = myMethod2; myDelegate(10);
從表面看,委託似乎十分簡單,讓我們拆分一下這段程式碼:用C#中的delegate關鍵字定義了一個委託型別MyDelegate;使用new操作符來構造一個MyDelegate委託的例項myDelegate,通過建構函式建立的委託例項myDelegate此時所引用的方法是myMethod1,之後我們通過方法組轉換為myDelegate繫結另一個對應的方法myMethod2;最後,用呼叫方法的語法來呼叫回撥函式。看上去一切都十分簡單,但實際情況是這樣嗎?
事實上編譯器和Mono執行時在幕後做了大量的工作來隱藏委託機制實現的複雜性。那麼本節就要來揭開委託到底是如何實現的這個謎題。
下面讓我們把目光重新聚焦在剛剛定義委託型別的那行程式碼上:
internal delegate void MyDelegate(int number);
這行對開發者們來說十分簡單的程式碼背後,編譯器為我們做了哪些幕後的工作呢?
讓我們使用Refactor反編譯C#程式,可以看到如下圖的結果:
可以看到,編譯器實際上為我們定義了一個完整的類MyDelegate:
internal class MyDelegate : System.MulticastDelegate { //構造器 [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)] public MyDelegate(object @object, IntPtr method); // Invoke這個方法的原型和原始碼指定的一樣 [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)] public virtual void Invoke(int number); //以下的兩個方法實現對繫結的回撥函式的一步回撥 [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)] public virtual IAsyncResult BeginInvoke(int number, AsyncCallback callback, object @object); [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)] public virtual void EndInvoke(IAsyncResult result); }
可以看到,編譯器為我們的MyDelegate類定義了4個方法:一個構造器、Invoke、BeginInvoke以及EndInvoke。而MyDelegate類本身又派生自基礎類庫中定義的System.MulticastDelegate型別,所以這裡需要說明的一點是所有的委託型別都派生自System.MulticastDelegate。但是各位讀者可能也會了解到在C#的基礎類庫中還定義了另外一個委託類System.Delegate,甚至System.MulticastDelegate也是從System.Delegate派生而來,而System.Delegate則繼承自System.Object類。那麼為何會有兩個委託類呢?這其實是C#的開發者留下的歷史遺留問題,雖然所有我們自己建立的委託型別都繼承自MulticastDelegate類,但是仍然會有一些Delegate類的方法會被用到。最典型的例子便是Delegate類的兩個靜態方法Combine和Remove,而這兩個方法的引數都是Delegate型別的。
public static Delegate Combine( Delegate a, Delegate b ) public static Delegate Remove( Delegate source, Delegate value )
由於我們定義的委託類派生自MulticastDelegate而MulticastDelegate又派生自Delegate,因而我們定義的委託型別可以作為這兩個方法的引數。
再回到我們的MyDelegate委託類,由於委託是類,因而凡是能夠定義類的地方,都可以定義委託,所以委託類既可以在全域性範圍中定義,也可以巢狀在一個型別中定義。同樣,委託類也有訪問修飾符,既可以通過指定委託類的訪問修飾符例如:private、internal、public等等來限定訪問許可權。
由於所有的委託型別都繼承於MulticastDelegate類,因而它們也繼承了MulticastDelegate類的欄位、屬性以及方法,下面列出三個最重要的非公有欄位:
欄位 |
型別 |
作用 |
_target |
System.Object |
當委託的例項包裝一個靜態方法時,該欄位為null;當委託的例項包裝的是一個例項方法時,這個欄位引用的是回撥方法要操作的物件。也就是說,這個欄位的值是要傳遞給例項方法的隱式引數this。 |
_methodPtr |
System.IntPtr |
一個內部的整數值,執行時用該欄位來標識要回調的方法。 |
_invocationList |
System.Object |
該欄位的值通常為null。當構造委託鏈時它引用一個委託陣列。 |
需要注意的一點是,所有的委託都有一個獲取兩個引數的構造方法,這兩個引數分別是對物件的引用以及一個IntPtr型別的用來引用回撥函式的控制代碼(IntPtr 型別被設計成整數,其大小適用於特定平臺。 即是說,此型別的例項在 32 位硬體和作業系統中將是 32 位,在 64 位硬體和作業系統上將是 64 位。IntPtr 物件常可用於保持控制代碼。 例如,IntPtr 的例項廣泛地用在 System.IO.FileStream 類中來保持檔案控制代碼)。程式碼如下:
public MyDelegate(object @object, IntPtr method);
但是我們回去看一看我們構造委託型別新例項的程式碼:
MyDelegate myDelegate = new MyDelegate(myMethod1);
似乎和構造器的引數對不上呀?那為何編譯器沒有報錯,而是讓這段程式碼通過編譯了呢?原來C#的編譯器知道要建立的是委託的例項,因而會分析程式碼來確定引用的是哪個物件和哪個方法。分析之後,將物件的引用傳遞給object引數,而方法的引用被傳遞給了method引數。如果myMethod1是靜態方法,那麼object會傳遞為null。而這個兩個方法實參被傳入建構函式之後,會分別被_target和_methodPtr這兩個私有欄位儲存,並且_ invocationList欄位會被設為null。
從上面的分析,我們可以得出一個結論,即每個委託物件實際上都是一個包裝了方法和呼叫該方法時要操作的物件的包裝器。
假設myMethod1是一個MyClass類定義的例項方法。那麼上面那行建立委託例項myDelegate的程式碼執行之後,myDelegate內部那三個欄位的值如下:
_target |
MyClass的例項 |
_methodPtr |
myMethod1 |
_ invocationList |
null |
假設myMethod1是一個MyClass類定義的靜態方法。那麼上面那行建立委託例項myDelegate的程式碼執行之後,myDelegate內部那三個欄位的值如下:
_target |
null |
_methodPtr |
myMethod1 |
_ invocationList |
null |
這樣,我們就瞭解了一個委託例項的建立過程以及其內部結構。那麼接下來我們繼續探索一下,是如何通過委託例項來呼叫回撥方法的。首先我們還是通過一段程式碼來開啟我們的討論。
using UnityEngine; using System.Collections; public class DelegateScript : MonoBehaviour { delegate void MyDelegate(int num); MyDelegate myDelegate; void Start () { myDelegate = new MyDelegate(this.PrintNum); this.Print(10, myDelegate); myDelegate = new MyDelegate(this.PrintDoubleNum); this.Print(10, myDelegate); myDelegate = null; this.Print(10, myDelegate); } void Print(int value, MyDelegate md) { if(md != null) { md(value); } else { Debug.Log("myDelegate is Null!!!"); } } void PrintNum(int num) { Debug.Log ("Print Num: " + num); } void PrintDoubleNum(int num) { int result = num + num; Debug.Log ("result num is : " + result); } }
編譯並且執行之後,輸出的結果如下:
Print Num:10 result num is : 20 myDelegate is Null!!!
我們可以注意到,我們新定義的Print方法將委託例項作為了其中的一個引數。並且首先檢查傳入的委託例項md是否為null。那麼這一步是否是多此一舉的操作呢?答案是否定的,檢查md是否為null是必不可少的,這是由於md僅僅是可能引用了MyDelegate類的例項,但它也有可能是null,就像程式碼中的第三種情況所演示的那樣。經過檢查,如果md不是null,則呼叫回撥方法,不過程式碼看上去似乎是呼叫了一個名為md,引數為value的方法:md(value);但事實上並沒有一個叫做md的方法存在,那麼編譯器是如何來呼叫正確的回撥方法的呢?原來編譯器知道md是引用了委託例項的變數,因而在幕後會生成程式碼來呼叫該委託例項的Invoke方法。換言之,上面剛剛呼叫回撥函式的程式碼md(value);被編譯成了如下的形式:
md.Invoke(value);
為了更深一步的觀察編譯器的行為,我們將編譯後的程式碼反編譯為CIL程式碼。並且擷取其中Print方法部分的CIL程式碼:
// method line 4 .method private hidebysig instance default void Print (int32 'value', class DelegateScript/MyDelegate md) cil managed { // Method begins at RVA 0x20c8 // Code size 29 (0x1d) .maxstack 8 IL_0000: ldarg.2 IL_0001: brfalse IL_0012 IL_0006: ldarg.2 IL_0007: ldarg.1 IL_0008: callvirt instance void class DelegateScript/MyDelegate::Invoke(int32) IL_000d: br IL_001c IL_0012: ldstr "myDelegate is Null!!!" IL_0017: call void class [mscorlib]System.Console::WriteLine(string) IL_001c: ret } // end of method DelegateScript::Print
分析這段程式碼,我們可以發現在IL_0008這行,編譯器為我們呼叫了DelegateScript/MyDelegate::Invoke(int32)方法。那麼我們是否可以顯式的呼叫md的Invoke方法呢?答案是Yes。所以,Print方法完全可以改成如下的定義:
void Print(int value, MyDelegate md) { if(md != null) { md.Invoke(value); } else { Debug.Log("myDelegate is Null!!!"); } }
而一旦呼叫了委託例項的Invoke方法,那麼之前在構造委託例項時被賦值的欄位_target和_methodPtr在此時便派上了用場,它們會為Invoke方法提供物件和方法資訊,使得Invoke能夠在指定的物件上呼叫包裝好的回撥方法。OK,本節討論了編譯器如何在幕後為我們生成委託類、委託例項的內部結構以及如何利用委託例項的Invoke方法來呼叫一個回撥函式,那麼我們接下來繼續來討論一下如何使用委託來回調多個方法。
回到目錄0x05 委託是如何呼叫多個方法的?
為了方便,我們將用委託呼叫多個方法簡稱為委託鏈。而委託鏈是委託物件的集合,可以利用委託鏈來呼叫集合中的委託所代表的全部方法。為了使各位能夠更加直觀的瞭解委託鏈,下面我們通過一段程式碼來作為演示:
using UnityEngine; using System; using System.Collections; public class DelegateScript : MonoBehaviour { delegate void MyDelegate(int num); void Start () { //建立3個MyDelegate委託類的例項 MyDelegate myDelegate1 = new MyDelegate(this.PrintNum); MyDelegate myDelegate2 = new MyDelegate(this.PrintDoubleNum); MyDelegate myDelegate3 = new MyDelegate(this.PrintTripleNum); MyDelegate myDelegates = null; //使用Delegate類的靜態方法Combine myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1); myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate2); myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate3); //將myDelegates傳入Print方法 this.Print(10, myDelegates); } void Print(int value, MyDelegate md) { if(md != null) { md(value); } else { Debug.Log("myDelegate is Null!!!"); } } void PrintNum(int num) { Debug.Log ("1 result Num: " + num); } void PrintDoubleNum(int num) { int result = num + num; Debug.Log ("2 result num is : " + result); } void PrintTripleNum(int num) { int result = num + num + num; Debug.Log ("3 result num is : " + result); } }