UNITY_委托和事件
參考資料: Unity3D腳本編程-使用C#語言開發跨平台游戲-陳嘉棟
觀察者模式
主題(Subject)管理某些數據,當主題的數據發生改變時,會通知已經注冊(Register)的觀察者(Observer),而這些已經注冊的觀察者會受到數據改變的通知並作出相應反應。
觀察者模式定義了對象之間的一對多依賴,當一個對象改變狀態時,它的所有依賴者都會受到通知並自動更新。
Unity提供的機制:SendMessage()和BroadcastMessage()
缺點:
過於依賴反射機制(reflection)來查找消息對應的被調用函數
1. 頻繁使用反射會影響性能
2. 更會大大增加代碼的維護成本 -- 字符串標識對應方法
3. 能夠調用private的方法 -- 若有一個是有方法在聲明的類中沒有被使用,那正常情況下都會把它認為是廢代碼從而刪除,這時隱患就出現了
C#的委托機制
c#中提供的回調函數的機制便是委托(類型安全)
委托的使用
public class DelegateScript: MonoBehaviour{ internal delegate void MyDelegate(int num); // 聲明委托類型(參數列表+返回類型)\ MyDelegate myDelegate; // 聲明變量 void Start(){ myDelegate = PrintNum; // 給委托類型MyDelegate的實例賦值引用的方法 myDelegate(50); myDelegate = DoubleNum; myDelegate(50); } void PrintNum(int num){ ... } void DoubleNum(int num){ ... } }
這里myDelegate = PrintNum; 將一個方法"賦值"給了一個委托
在c#2中為委托引入了方法組轉換機制,支持從方法到兼容的委托類型的隱式轉換
之所以成為方法"組"轉換,則是因為方法的重載
若有delegate void Delegate1(int num)
和delegate void Delegate2(int num, int num2)
且有方法 void PrintNum(int num)
和 void PrintNum(int num, int num2)
則 myDelegate1 = PrintNum;
myDelegate2 = PrintNum;
向 myDelegate1或myDelegate2賦值時,都可以使用PrintNum作為方法組(因為重載了多個方法)
而編譯器會自動選擇合適的重載
委托參數的逆變性
逆變性: 可以是類型的基類
即委托對應方法的參數可以是委托的參數類型的基類
委托返回類型的協變性
協變性: 可以是類型派生出來的一個派生類
即委托對應方法的返回類型可以是委托的返回類型的一個派生類
逆變性和協變性僅針對引用類型,若是值類型或void則不支持
委托的編譯器內部實現機理
(略) -- p154~164
委托鏈
委托調用多個方法 -- 委托鏈
委托鏈是委托對象的集合 -- 可以利用委托鏈來調用集合中的委托所代表的全部方法
public class DelegateScript : MonoBehaviour { delegate void MyDelegate(int num); void Start(){ MyDelegate myDelegate1 = new MyDelegate(PrintNum1); MyDelegate myDelegate2 = new MyDelegate(PrintNum2); MyDelegate myDelegate3 = new MyDelegate(PrintNum3); MyDelegate myDelegates = null; myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1); myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate2); myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate3); Print(10, myDelegates); } void Print(int num, MyDelegate md){ if(md != null){ md(value); } } void PrintNum1(int num) { Debug.Log("1 result Num: " + num); } void PrintNum2(int num) { Debug.Log("2 result Num: " + num); } void PrintNum3(int num) { Debug.Log("3 result Num: " + num); } }
剛開始時myDelegates = null; 表示沒有對應要回調的方法
第一次myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1);時myDelegates引用了myDelegate1所引用的委托實例
第二次時,myDelegates內部實現的_invocationList字段被初始化,並且_invocationList[0]指向和_invocationList[1]分別指向兩個委托實例myDelegate1和myDelegate2
第三次是將一個委托實例myDelegate3合並到一個委托鏈中。編譯器內部發生的與第二次的大同小異。需要注意的是,第二次得到的委托鏈中的_invocationList所引用的委托實例數組不再需要,被垃圾回收
將myDelegates變量(委托鏈)作為參數傳入Print(),Print方法中的代碼會隱式調用myDelegates所引用的委托實例的Invoke()方法,
此時會執行一個循環來遍歷_invocationList中的所有委托實例並按順序調用每個委托實例中包裝的回調方法,即PrintNum1(), PrintNum2()和PrintNum3()
對應Delegate.Combine(), 也提供了Remove()方法用於移除委托實例
Remove()每次僅僅移除一個匹配的委托實例,而不是所有和目標委托實例匹配的委托實例
myDelegates = (MyDelegate)Delegate.Remove(myDelegates, new MyDelegate(PrintNum2));
當Remove方法被調用時,會從后向前掃描myDelegates中的委托實例數組,並對比委托實例的_target和_methodPtr的值是否與需要Remove的對應字段值相同。
若匹配,則刪除。如果委托實例數組為空,則返回null;如果委托實例數組長度為1,則直接返回那個委托實例;
否則,會創建一個新的委托實例(與Combine出委托鏈類似),對應的_invocationList會引用由刪除了目標委托實例后剩余委托實例組成的委托實例數組。
C#編譯器為委托類型的實例重載了 += 和 -= 操作符,對應Delegate.Combine()和Delegate.Remove()
事件
觀察者模式可以通過事件機制來實現
C#中的事件機制是以委托作為基礎的
一個定義了事件成員的類型需要提供這些功能來實現交互機制
1. 方法能夠訂閱對某事件的關注
2. 方法能夠取消訂閱對該事件的關注
3. 事件發生時,訂閱了該事件的方法會收到通知
實例:
一個游戲單位(BaseUnit類)被攻擊而掉血,那么掉血(OnSubHp事件)就可以被作為一個事件。
訂閱了該事件的對象在游戲單位掉血時,會收到游戲單位掉血的通知。
具體需求:掉血時,需要顯示掉血信息,掉血信息中有多個值。
思路:為了區分開游戲單位和顯示信息的邏輯(降低邏輯的耦合性),將掉血信息的顯示邏輯交給模塊BattleInfoComponent
--> 即游戲單位的掉血事件OnSubHp發生時,通知BattleInfoComponent模塊來處理顯示功能。
實現:
1. 定義委托類型(回調方法原型) -- 事件是以委托為基礎的
public delegate void SubHpHandler(BaseUnit source, float subHp, DamageType damageType, HpShowType showType);
-- source: 受傷害的單位;subHp: 傷害;damageType: 傷害方式;showType: 顯示方式
2. 定義事件成員
使用關鍵字event定義事件成員
public event SubHpHandler OnSubHp;
表示事件OnSubHp的類型為SubHpHandler,意味着事件OnSubHp的所有訂閱者都必須提供和SubHpHandler委托類型所確定的方法原型相匹配的回調方法,即void Method(BaseUnit .., float .., DamageType .., HpShowType ..);
3. 事件的觸發
這里的BaseUnit可以視為一個基類,派生出比如英雄類、士兵類等。
因此,觸發事件的方法可以定義為一個虛方法
本例中,OnSubHp事件是受到攻擊而導致的,因此
protected virtual void OnBeAttacked(float damage, bool isCritical, bool isMissed){ DamageType damageType = DamageType.Normal; HpShowType showType = HpShowType.Damege; if(isCritical) damageType = DamageType.Critical; if(isMissed) showType = HpShowType.Miss; // 如果有方法訂閱了OnSubHp事件,則調用(通知) if(OnSubHp != null) { OnSubHp(this, damage, damageType, showType); } }
而BaseUnit的派生類可以通過重寫OnBeAttack()來控制事件的觸發
優化:業務單一原則
OnBeAttack()方法應該僅僅用來觸發事件
因此
BeAttack()方法用來將敵人的攻擊傷害轉化為掉血事件的觸發
public void BeAttack() { bool isCritical = Random.value > 0.5f; bool isMissed = Random.value > 0.5f; float damage = 10000f; OnBeAttacked(damage, isCritical, isMissed); }
4. 事件的訂閱和觀察者的回調方法
之前提到的BattleInfoComponent類是用來進行傷害信息顯示的
而傷害信息顯示的時機是在BaseUnit受到傷害的時候
因此BattleInfoComponent需要訂閱BaseUnit.OnSubHp事件。
public class BattleInfoComponent: MonoBehaviour { public BaseUnit baseUnit; ... private void AddListener () { // 訂閱事件this.unit.OnSubHp this.unit.OnSubHp += new BaseUnit.SubHpHandler(this.OnSubHp); // 可簡寫為 unit.OnSubHp += OnSubHp; } private void RemoveListener () { // 不要忘記取消事件的訂閱 this.unit.OnSubHp -= new BaseUnit.SubHpHandler(this.OnSubHp); } private void OnSubHp(BaseUnit source, float damage, DamageType dmgType, HpShowType showType) { // 實現傷害信息的顯示功能 Debug.Log(source.name + ....); } }
c#的+=操作符可用於注冊事件
this.unit.OnSubHp += new BaseUnit.SubHpHandler(Method);
可簡寫為
this.unit.OnSubHp += Method;
上述兩行代碼的內部在編譯器內部其實都等效於
this.unit.add_OnSubHp(new BaseUnit.SubHpHandler(this.OnSubHp));
在訂閱事件時,編譯器內部需要調用事件的add_OnSubHp方法來向事件內部添加新的委托對象
取消回調事件的訂閱也相似,使用-=操作符
在編譯器內部等效於
this.unit.remove_OnSubHp(new BaseUnit.SubHpHandler(this.OnSubHp));
總結:
當UnitBase受到攻擊時,執行UnitBase.BeAttack()
--> UnitBase.OnBeAttacked()被調用
--> 觸發UnitBase.OnSubHp事件
由於BattleInfoComponent.OnSubHp()方法訂閱了UnitBase.OnSubHp事件
--> 因此在UnitBase.OnSubHp事件觸發時,BattleInfoComponent.OnSubHp()方法被調用
而OnSubHp()方法會受到來自UnitBase.OnSubHp事件傳來的參數source等
基於這些參數,OnSubHp()方法得以將傷害信息顯示出來
事件的編譯器內部實現機理
(略) -- p169~172、175
優點:
在事件機制中,事件成員(OnSubHp)才擁有數據,
這些數據不屬於觀察者,但是觀察者需要依賴Subject的這些數據做出響應
如果有很多不同的觀察者通過訂閱同一個Subject,Subject的數據變化而觸發了事件時,所有觀察者會受到相應通知
通過事件機制可以將對象之間的相互依賴降到最低 -- 松耦合
觀察者模式的意義也在於此,讓主題和觀察者之間實現松耦合的設計模式
當兩個對象之間松耦合,即使不清楚彼此的細節,也可以進行交互
在有新類型的觀察者出現時,主題(Subject)的代碼無需修改,而新類型只需要實現匹配的回調方法即可注冊成觀察者