1. 程式人生 > >Unity/C#基礎複習(5) 之 淺析觀察者、中介者模式在遊戲中的應用與delegate原理

Unity/C#基礎複習(5) 之 淺析觀察者、中介者模式在遊戲中的應用與delegate原理

參考資料

【1】 《Unity 3D指令碼程式設計 使用C#語言開發跨平臺遊戲》陳嘉棟著
【2】 @張子陽【C#中的委託和事件 - Part.1】 http://www.tracefact.net/tech/009.html
【3】 @張子陽【C#中的委託和事件 - Part.2】 http://www.tracefact.net/tech/029.html
【4】 @毛星雲【《Effective C#》提煉總結】提高Unity中C#程式碼質量的22條準則 https://zhuanlan.zhihu.com/p/24553860
【5】 《遊戲程式設計模式》 Robert Nystrom著

基礎知識

  1. C#中使用delegate關鍵字來便捷地完成類似回撥函式的機制。

疑難解答

  1. 觀察者模式是什麼?它在遊戲中的應用場景有哪些?
  2. 中介者模式的應用場景?
  3. delegate關鍵字為我們做了什麼?
  4. event與delegate的關係是?

觀察者模式

概述

觀察者模式是一種定義了物件之間一對多關係的模式。當被觀察者改變狀態時,它的所有觀察者都會收到通知並做出響應。有時也可以將這種關係理解為釋出/訂閱的模式,即我們所關心的物件(即被觀察者)釋出訊息時,所有訂閱該訊息的物件都會進行響應,從而做出某些操作。

在《Unity3D指令碼程式設計》一書中有一個很棒的例子來解釋這個模式,它是這樣描述的[1]

  • 報刊的任務是出版報紙。
  • 顧客可以向報刊訂閱報紙。
  • 當報刊出版報紙後,所有訂閱該報紙的人都會收到。
  • 當顧客不需要時,也可以取消訂閱,取消後,顧客就不會收到報社出版的報紙。
  • 報刊和顧客是兩個不同的主體,只要報社存在,不同的訂閱者可以訂閱或取消訂閱。

其中的報社就是我們說的被觀察者(Subject),而訂閱者則是觀察者(Observer)。

何時使用觀察者模式?

設計模式不是銀彈,所有設計模式都有一個最適合他們的應用場景,濫用設計模式會造成程式碼冗餘嚴重,後續想要修改更是會力不從心。顯然,觀察者模式應該也是有他最佳的應用場景的。

感覺目前網上介紹觀察者模式在遊戲開發中的應用的解釋都顯得不那麼明朗,這裡博主結合自己的經驗來試著談談在哪些場合下,使用觀察者模式可以得到不錯的效果。當然,目前up還是個初學C#和設計模式的小萌新,可能存在說錯或者紕漏的情況,如果大家發現了還請不吝賜教,我會超級感激不盡的!!!

在我看來,觀察者模式就是一個將A依賴B的關係轉變為B依賴A的關係的模式。所以是否使用觀察者模式的第一點,我認為應該是,判斷AB之間的依賴關係,並判斷出對於哪個物件來說,他更不能容忍在程式碼中出現依賴多個物件的情況。

這麼說可能有點抽象,下面結合具體的例子來看看。

應用場景1--判斷AB依賴關係

當遊戲中的某個單位HP下降時,它表現在UI上的生命條也應該按比例改變寬度(或高度等屬性)。而單位的生命值改變在遊戲中是一件非常常見的事情。比如,當單位收到傷害時,甚至是單位釋放技能時(點名DNF大紅神)。

那麼,如果不使用觀察者模式,我們可能會寫出下面的程式碼。

import UI.HpUI;
// 遊戲單位物件
class Character{

    // Character物件依賴於hp的UI,因為要主動通知該UI更新血條寬度
    HpUI hpView;

    int hp;
    // 收到傷害時執行的方法
    public void Damaged(int damage){
        this.hp -= damage;
        // 主動通知hpUI更新
        hpView.update();
    }

    // 釋放技能時執行的方法
    public void SkillExecute(Skill skill){
        // 某些特殊技能需要消耗HP
        if(skill == xxx){
            Damaged(xxxx);
        }
    }
}

這樣能不能完成目標呢,也可以,但是我們可以發現在遊戲物件Character上,他依賴了Hp的UI,這顯得特別突兀,如果後續還有MP的UI,人物屬性(攻擊力防禦力等)的UI,那麼Character物件全部都要引用一遍。

想象一下,當你開開心心建立了一個新的單位,想要讓他去打怪時,發現,報錯了。原因是沒有給這個新單位新增UI的依賴,這時,就可以發現,每次新建遊戲單位物件都需要為他新增所有UI的依賴。

實際上,遊戲物件是不是必須要依賴UI呢,感覺也不是,單位就是單位,就算沒有UI,他受傷了也會扣血也會死亡,不會說沒有了UI就會報錯,這個單位就死不了了(滑稽)。

那麼,可以判斷實際上如果是UI依賴遊戲物件是不是更合理呢?UI沒有了他繫結的遊戲物件,當然就無法表現出特定的效果啦。

下面是使用觀察者模式之後的程式碼。

// nowHp表示受傷後的血量,damage表示此次傷害數值
public delegate OnCharacterDamageHandler(int nowHp,int damage);

// 遊戲單位物件
class Character{
    // 委託,當單位受傷時,向所有訂閱這個事件的訂閱者傳送訊息
    public OnCharacterDamageHandler onDamage;

    int hp;
    // 收到傷害時執行的方法
    public void Damaged(int damage){
        this.hp -= damage;
        if(onDamage!=null)
            // 釋出訊息——這個物件受傷了
            onDamage(hp,damage);
    }

    // 釋放技能時執行的方法
    public void SkillExecute(Skill skill){
        // 某些特殊技能需要消耗HP
        if(skill == xxx){
            Damaged(xxxx);
        }
    }
}

class HpUI{
    // hp條,單位hp改變時,自動調整寬度
    Image hpbar;

    // UI依賴於遊戲物件
    Character character;

    // 根據當前hp改變血條UI的方法
    private void update(int hp,int damage){...}

    // UI初始化的方法
    public void Init(){
        // 血條UI訂閱單位受傷事件
        character.onDamage += update;
    }
}

這樣一來,依賴關係就從Character依賴HpUI,變成了HPUI依賴Character,HpUI監聽了Character的受傷事件,這樣好不好呢?感覺見仁見智把,還是那句話,要判斷AB物件誰更不能容忍依賴於其他物件。

應用場景1簡單總結

大家可以發現,經過上面的操作,實際上物件之間的耦合並沒有消失,只是從一種形式(A依賴B)變為了另一種形式(B依賴A)。

應用場景2——是否具備一對多的關係?

判斷是否使用觀察者模式,我認為,第二個要點是,訊息的釋出者和訂閱者是否具備一對多的關係。

依舊以前面的受傷事件舉例(onDamge),如果此時又有一個需求要求我們做一個成就係統,當單位第一次死亡時,顯示出成就特效(類似於各類MOBA遊戲的First Blood),那麼我們還是可以不讓Character物件依賴這個成就係統。

為什麼呢?依舊是用onDamage釋出訊息,成就係統訂閱這個訊息唄。當單位受傷時,成就係統訂閱受傷訊息,從而判斷單位的HP是否減少到了0,如果為0,那麼就判斷單位死亡,此時再判斷單位是否是第一次死亡,再使用相應方法。

下面上虛擬碼。

class AchievementSystem{
    // 成就係統依賴於遊戲物件
    Character character;

    // 根據對應事件觸發first blood成就
    public void update(int nowhp,int damage){...}

    public void Init(){
        character.onDamage = update;
    }
}

可以看到我們甚至沒有改動Character類一行,因為這本來就跟這個類沒有太大關係~~~

如果接下來又有一個類似的需求呢?繼續訂閱就行。舉個例子,如果策劃此時要求做一個單位血量減少到20%以下,增加防禦力的被動技能,怎麼做呢?

依舊是由被動技能訂閱受傷事件,判斷生命值是否到達20%以下,如果是,那麼觸發技能效果。

簡單總結

上述情況就是我所認為的一對多情況,也就是一個訊息釋出出來,他會有多個訂閱者,每個訂閱者都會做出不同的響應,這時就可以考慮使用觀察者模式消除一部分耦合(並不能完全消除)。

中介者模式

概述

如果說觀察者模式只能消除一部分耦合,那麼中介者模式就是可以完全消除兩個物件的依賴情況。在《遊戲程式設計模式》一書中,對中介者模式(也常被稱為服務定位型模式)的定義如下[5]

為某服務提供一個全域性訪問入口來避免使用者與該服務的具體實現類之間產生耦合。

在我看來,中介者模式的主要作用就是將蜘蛛網式的引用關係變為N個物件依賴於中介者,中介者為這N個物件提供服務,也就是將原本多對多的關係變成了多對一,一對多的關係。這樣說起來可能有點抽象,下面舉一個具體的例子來說明一下。

在遊戲中,我們經常要製作一種提示型的UI,它經常要做以下這幾件事:

  1. 在玩家購買物品時,判斷玩家的資源是否足夠,如果不夠,提示玩家
  2. 玩家釋放技能時,判斷玩家mp是否足夠,如果不夠,提示玩家
  3. 當玩家想要進行某種被禁止的操作時,提示玩家,如攻擊無敵的敵人,對友軍釋放技能等等
    .....
    諸如此類,就不一一列舉了,這種UI還是挺常見的。他會零散的分佈在遊戲系統的各個角落,可能在另一個UI上操作時,採取了某種操作,他就要跳出來提示你。

如果不使用中介者模式,那麼可能程式碼中就會出現如下引用關係:

如果使用中介者模式,那麼依賴關係就可以轉變成下面這樣。

新增中介者後,所有原本直接引用(或間接引用)提示UI的物件,全都變成了直接引用中介者,當他們有訊息要釋出時(比如玩家做了某個不允許的操作),就直接向中介者釋出訊息。此時訂閱了這個訊息的tipsUI就可以自動獲得這個訊息並處理他。從而解耦了各個物件與TipsUI。

何時使用中介者模式?

前面說到,中介者模式可以最大限度的消除耦合,使物件的依賴關係之間變小。那麼,是不是遊戲裡所有地方有依賴關係的地方,都可以用中介者模式呢?比如將所有觀察者模式都替換成中介者模式。答案應該是:不能。前面說到,設計模式不是萬能的,中介者模式有它的優點自然就會有它的缺點。

中介者模式本質上其實是與單例模式極其相似,仔細觀察前面的案例,其實我們也可以用靜態方法(類)或單例模式來解決。不過對於Unity的MonoBehavior類來說有點特殊,這個繼承自MonoBehavior的TipsUI物件並不能設定成靜態,不過我們也能將TipsUI設定為單例模式來解決它零散地分佈在遊戲系統這一問題。

中介者模式的一大缺點就在於:他將耦合變得不直觀。閱讀使用中介者模式的程式碼,往往難以理解誰依賴他,這也是他的性質決定的,中介者並不知道誰會發布訊息(即並不知道服務被誰定位),我們需要滿程式碼的找誰向中介者釋出了訊息,誰又處理了這個訊息。這與直接的物件引用比起來,更不容易看出來。

根據《遊戲程式設計模式》一書所說,中介者模式的第一個應用場景應該是這樣的[5]

當手動將一個物件傳來傳去顯得毫無理由或者使得程式碼難以閱讀時,可以嘗試考慮使用這個設計模式。畢竟將一個環境屬性傳遞10層函式以便讓一個底層函式能夠訪問,這會使程式碼增加毫無意義的複雜度。

在遊戲開發中,感覺有幾個鮮明的例子可以說明這點,比如音訊管理,音訊需要在任何需要它的地方播放,但是如果把音訊管理器物件在每份程式碼之間傳來傳去就顯得毫無理由,這時,就可以考慮中介者模式(或單例模式)。

除此之外還有許多的例子,比如日誌系統,前面提到的提示UI等等。

中介者模式與單例模式的區別?

前面提到中介者模式與單例模式極其相似,他們都是全域性能夠訪問的物件。當我們使用其中之一的時候,應該考慮哪一個更適合需求。

在《遊戲程式設計模式》中沒有過多的討論如何選擇使用這兩個模式,根據菜雞博主這些年來單薄的專案經驗和淺薄的認知,我認為中介者模式比之單例模式多了一層健壯性。

這是因為,有些在C#中的中介者模式是使用委託進行設計的,當訂閱一個訊息的時候,實際上就是向該委託+=一個響應方法,而釋出訊息時,實際上就是直接呼叫這個委託方法。

這樣一來,如果我們用中介者模式設計音訊管理器,那麼就算此時我們音訊管理器出錯了,無法在遊戲中播放聲音,遊戲也能正常執行,或者說,即使我們將音訊管理器的程式碼全部刪除,再寫一個功能更強大的音訊系統,只要這個新的音訊系統響應的訊息是一樣的(與之前那個一樣),那麼這個中介者模式就依舊沒有出錯,依然能正常執行。

如何在C#中實現中介者模式

前面提到,中介者模式大多依靠委託實現,下面是基本的程式碼框架(參考自網上)。

public delegate void CallBack();
public delegate void CallBack<T>(T arg);
public delegate void CallBack<T, X>(T arg, X arg1);
public delegate void CallBack<T, X, Y>(T arg, X arg1, Y arg2);
public delegate void CallBack<T, X, Y, Z>(T arg, X arg1, Y arg2, Z arg3);
public delegate void CallBack<T, X, Y, Z, W>(T arg, X arg1, Y arg2, Z arg3, W arg4);

/// <summary>
/// 充當所有UI檢視物件的中介者,單例類,具有兩個功能
/// 
/// 1. 訂閱事件:
///     向某種事件發起訂閱,引數是一個delegate委託函式,
///     表示當某個事件發生後,呼叫該函式
/// 2. 釋出事件:
///     釋出事件相當於我們沒有用中介者之前的的OnXXX委託,
///     在某個事件發生時,呼叫該中介者的釋出事件方法,
///     用以呼叫所有訂閱該事件的物件的方法
/// </summary>
public class MessageAggregator {

    // 單例類,使用餓汗模式載入,防止在多執行緒環境下出錯
    public static readonly MessageAggregator Instance = new MessageAggregator();
    private MessageAggregator() { }

    private Dictionary<string, Delegate> _messages;

    public Dictionary<string, Delegate> Messages {
        get {
            if (_messages == null) _messages = new Dictionary<string, Delegate>();
            return _messages;
        }
    }

    /// <summary>
    /// 當訂閱事件時呼叫
    /// </summary>
    /// <param name="string"></param>
    /// <param name="callback"></param>
    private void OnListenerAdding(string string,Delegate callback) {
        //判斷字典裡面是否包含該事件碼
        if (!Messages.ContainsKey(string)) {
            Messages.Add(string, null);
        }
        Delegate d = Messages[string];
        if (d != null && d.GetType() != callback.GetType()) {
            throw new Exception(string.Format("嘗試為事件{0}新增不同型別的委託,當前事件所對應的委託是{1},要新增的委託是{2}", string, d.GetType(), callback.GetType()));
        }
    }


    /// <summary>
    /// 當取消訂閱事件時呼叫
    /// </summary>
    /// <param name="string"></param>
    /// <param name="callBack"></param>
    private void OnListenerRemoving(string string,Delegate callBack) {
        if (Messages.ContainsKey(string)) {
            Delegate d = Messages[string];
            if (d == null) {
                throw new Exception(string.Format("移除監聽事件錯誤:事件{0}沒有對應的委託", string));
            } else if (d.GetType() != callBack.GetType()) {
                throw new Exception(string.Format("移除監聽事件錯誤:嘗試為事件{0}移除不同型別的委託,當前事件所對應的委託為{1},要移除的委託是{2}", string, d.GetType(), callBack.GetType()));
            }
        } else {
            throw new Exception(string.Format("移除監聽事件錯誤:沒有事件碼{0}", string));
        }
    }

    /// <summary>
    /// 無參的監聽事件(即訂閱事件)的方法
    /// </summary>
    /// <param name="string"></param>
    /// <param name="callBack"></param>
    public void AddListener(string string,CallBack callBack) {
        OnListenerAdding(string, callBack);
        Messages[string] = (CallBack)Messages[string] + callBack;
    }

    // 1參的監聽事件(即訂閱事件)的方法
    public void AddListener<T>(string string, CallBack<T> callBack) {...}

    // 2參的監聽事件(即訂閱事件)的方法
    public void AddListener<T,X>(string string, CallBack<T,X> callBack) {...}

    // 3參的監聽事件(即訂閱事件)的方法
    public void AddListener<T,X,V>(string string, CallBack<T,X,V> callBack) {...}

    // 4參的監聽事件(即訂閱事件)的方法
    public void AddListener<T,X,Y,Z>(string string, CallBack<T,X,Y,Z> callBack) {...}

    // 5參的監聽事件(即訂閱事件)的方法
    public void AddListener<T, X, Y, Z,W>(string string, CallBack<T, X, Y, Z,W> callBack) {...}

    // 無參的移除監聽事件的方法
    public void RemoveListener(string string,CallBack callBack) {
        OnListenerRemoving(string, callBack);
        Messages[string] = (CallBack)Messages[string] - callBack;
    }

    // 1參的移除監聽事件的方法
    public void RemoveListener<T>(string string, CallBack<T> callBack) {...}


    // 2參的移除監聽事件的方法
    public void RemoveListener<T, X>(string string, CallBack<T, X> callBack) {...}

    // 3參的移除監聽事件的方法
    public void RemoveListener<T, X, V>(string string, CallBack<T, X, V> callBack) {...}

    // 4參的移除監聽事件的方法
    public void RemoveListener<T, X, Y, Z>(string string, CallBack<T, X, Y, Z> callBack) {...}

    // 5參的移除監聽事件的方法
    public void RemoveListener<T, X, Y, Z,W>(string string, CallBack<T, X, Y, Z,W> callBack) {...}

    // 無參的廣播監聽事件
    public void Broadcast(string string) {
        Delegate d;
        if (Messages.TryGetValue(string, out d)) {
            CallBack callBack = d as CallBack;
            if (callBack != null)
                callBack();
            else
                throw new Exception(string.Format("廣播事件錯誤:事件{0}對應委託有不同的型別", string));
        }
    }

    // 1參的廣播監聽事件
    public void Broadcast<T>(string string,T arg0) {...}

    // 2參的廣播監聽事件
    public void Broadcast<T,V>(string string, T arg0,V arg1) {...}

    // 3參的廣播監聽事件
    public void Broadcast<T,V,X>(string string, T arg0,V arg1,X arg2) {...}

    // 4參的廣播監聽事件
    public void Broadcast<T, V, X,Z>(string string, T arg0, V arg1, X arg2,Z arg3) {...}

    // 5參的廣播監聽事件
    public void Broadcast<T, V, X, Z,W>(string string, T arg0, V arg1, X arg2, Z arg3,W arg4) {...}
}

以前面的TipsUI為例子,我們下面實現在人物釋放技能時如果mp不夠,對玩家進行提示。

// 單位物件
class Character{
    int mp;
    // 釋放技能
    public void ExecuteSkill(Skill skill){
        if(this.mp < skill.mp)
            // 向中介者釋出施法mp不夠的訊息
            MessageAggregator.Instance.Broadcast< int,int >("ExecuteSkill",mp,skill.mp);
    }
}

// 提示UI物件
Class TipsUI{
    // 要提示的字串資訊
    private string tips;
    
    public void update(int nowmp,int skillmp){
        tips = string.format("當前mp為:%d,技能要消耗的mp:%d,mp不夠,不能釋放技能",nowmp,skillmp);
    }

    // 對各類訊息進行訂閱的方法
    public void Bind(){
        // 訂閱施法的訊息
        MessageAggregator.Instance.AddListener<int,int>("ExecuteSkill",update);
    }
}

這個時候,當玩家釋放技能時,如果mp不夠,就會觸發提示UI出來提示不能釋放技能。

delegate做了什麼?

在C#中delegate是一個非常方便的關鍵字,它一般用於回撥函式機制,利用它我們很容易就能寫出低耦合的觀察者模式。他有些類似與C++中的函式指標,但是相比於函式指標,C#中的委託天然支援多播委託(即有多個回撥方法,也就是委託鏈),以及型別安全,寫起來相當方便舒服。

那麼delegate到底為我們做了什麼呢?delegate關鍵字後面宣告的到底是型別還是變數呢?老實說,博主初學委託的時候,經常會寫出像下面這樣傻傻的程式碼。

// 錯誤程式碼
class Main{

    delegate void Func();

    public void init(){
        // 為委託添加回調方法
        Func+=Func1;
    }

    void Func1(){}
}

大家發現了把~
菜鳥博主sjm經常把委託看成是函式指標一樣的東西了,然後以為宣告的是一個類似指標的變數,所以就直接指向目標方法了。這樣當然就報錯啦~~

這就是不瞭解delegate背後工作惹的禍~C#編譯器在後面為我們做了相當多的工作,要理解委託,就得去看看由C#程式碼生成的中間程式碼IL。

下面是一個簡單的使用委託的程式碼示例。

using System;
public delegate void Func(int a);

public class C {
    Func func;
    public void M() {
        func += aa;
        
        func(11);
    }
    void aa(int a){
        Console.WriteLine(a);
    }
}

我們可以去 https://sharplab.io 中檢視C#程式碼生成的中間程式碼是什麼樣的。

由上面程式碼生成的IL(中間)程式碼大概是下面這樣的。

.class private auto ansi '<Module>'
{
} // end of class <Module>

.class public auto ansi sealed Func
    extends [mscorlib]System.MulticastDelegate
{
    // Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor (
            object 'object',
            native int 'method'
        ) runtime managed 
    {
    } // end of method Func::.ctor

    .method public hidebysig newslot virtual 
        instance void Invoke (
            int32 a
        ) runtime managed 
    {
    } // end of method Func::Invoke

    .method public hidebysig newslot virtual 
        instance class [mscorlib]System.IAsyncResult BeginInvoke (
            int32 a,
            class [mscorlib]System.AsyncCallback callback,
            object 'object'
        ) runtime managed 
    {
    } // end of method Func::BeginInvoke

    .method public hidebysig newslot virtual 
        instance void EndInvoke (
            class [mscorlib]System.IAsyncResult result
        ) runtime managed 
    {
    } // end of method Func::EndInvoke

} // end of class Func

.class public auto ansi beforefieldinit C
    extends [mscorlib]System.Object
{
    // Fields
    .field private class Func func

    // Methods
    .method public hidebysig 
        instance void M () cil managed 
    {
        .....
        IL_0014: call class [mscorlib]System.Delegate [mscorlib]System.Delegate::Combine(class [mscorlib]System.Delegate, class [mscorlib]System.Delegate)
        .....
        IL_002b: callvirt instance void Func::Invoke(int32)
        ....
    } // end of method C::M

    .method private hidebysig 
        instance void aa (
            int32 a
        ) cil managed 
    {...} // end of method C::aa
} // end of class C

大家可以看到,神奇的事情發生了,在我們用delegate宣告Func時,一個名為Func的類在本地被聲明瞭!由此,我們也可以發現delegate的祕密,那就是,在delegate定義的地方,編譯器會自動幫我們宣告一個繼承於MulticastDelegate(多播委託)的型別。事實上,如果繼續追溯,我們還可以發現MulticastDelegate繼承於Delegate,這個Delegate類有個Combine方法,主要用於將兩份委託連線起來形成委託鏈。

因此,當我們以後看到delegate xxx時,可以自動將其等價為class xxx : MulticastDelegate。因為delegate是用於宣告型別的,所以型別能用的修飾符他理論上也能用,如public、private、internal等等。

而對於這個新宣告的型別,有兩個方法是值得注意的,分別是他的構造方法和Invoke方法。

它的構造方法的簽名如下:

.method public hidebysig specialname rtspecialname 
        instance void .ctor (
            object 'object',
            native int 'method'
        )

可以看到這裡有兩個引數,分別是一個object物件和一個整型,當委託添加了一個例項方法時,這個object就是該例項方法所操作的物件,而如果是靜態方法,那麼這個引數就為null。
而整型method可以看作是一個指標,指向了我們要呼叫的函式,即儲存了該函式的地址。在《Unity3D指令碼程式設計》中介紹該引數是執行時用來標識要回調的方法,是一個引用回撥函式的控制代碼~

而Invoke方法顧名思義,就是用來呼叫回撥函式的方法。

除此之外,delegate還有很多語法糖,比如說,當我們初始化時,不必填寫object引數,編譯器會自動幫我們完成。還有我們可以像呼叫一個正常方法一樣呼叫委託,就像上面程式碼一樣,我們聲明瞭一個Func型別的委託變數func,可以像呼叫正常的方法一樣通過func()來呼叫它。這其中編譯器會將這種程式碼翻譯為func.invoke(xxx);

event是什麼?它與delegate關係是?

除了delegate外,我們還經常會看到像下面這樣的程式碼。

public delegate OnCharacterDamageHandler(int nowHp,int damage);

// 遊戲單位物件
class Character{
    // 委託,當單位受傷時,向所有訂閱這個事件的訂閱者傳送訊息
    public event OnCharacterDamageHandler onDamage;

    int hp;
    // 收到傷害時執行的方法
    public void Damaged(int damage){
        this.hp -= damage;
        if(onDamage!=null)
            // 釋出訊息——這個物件受傷了
            onDamage(hp,damage);
    }

    // 釋放技能時執行的方法
    public void SkillExecute(Skill skill){
        // 某些特殊技能需要消耗HP
        if(skill == xxx){
            Damaged(xxxx);
        }
    }
}

class HpUI{
    // hp條,單位hp改變時,自動調整寬度
    Image hpbar;

    // UI依賴於遊戲物件
    Character character;

    // 根據當前hp改變血條UI的方法
    private void update(int hp,int damage){...}

    // UI初始化的方法
    public void Init(){
        // 血條UI訂閱單位受傷事件
        character.onDamage += update;
    }
}

閱讀上述程式碼,可以發現event關鍵字似乎和delegate能達到同樣的效果。那麼這兩個關鍵字到底有什麼區別呢?

event的由來

這要從event的由來說起。我們已經知道了delegate關鍵字實際上是聲明瞭一個型別,而Character類的內部則是聲明瞭一個OnCharacterDamageHandler型別的變數,可以向這個變數新增或消除回撥方法。

那麼,onDamage作為類中的欄位,就要考慮他訪問修飾符的問題,我們知道類中的屬性並不應該都是public,有些屬性不應該暴露在外面。因為外部有可能會對這個屬性做出一些奇怪的改動,比如將其賦值為null。

對於委託型別來說,實際上我們在外部所需要的操作只是+=和-=而已。

然而,當你真的想把onDamage改成private或internal時候,你會發現,這個變數根本不能改成除了public以外的訪問修飾符。為啥呢?因為把它改成非public後,外部就不能該這個委託型別新增方法了啊!而我們設計委託,不就是為了能在外部向他註冊方法麼。如果把他設為private,那就相當於他完全失效了~

但是,我們又不想將onDamge設為public,因為"在客戶端可以對它進行隨意的賦值等操作,嚴重破壞物件的封裝性"[2]

為此,C#提供event來解決這個問題。當我們使用event封裝委託型別的變數時,該委託變數在外部只接受Add和Remove操作,而不能被隨意的賦值,增強了安全性。

試著將上面的程式碼

character.onDamage += update;

更改為

character.onDamage = update;

可以發現編譯錯誤~~

event做了什麼?

public event OnCharacterDamageHandler onDamage;

那麼,在上面這行語句中,到底發生了什麼我們不知道的事呢?

依舊是老方法,看看生成的中間程式碼。

為了防止生成的中間程式碼過長,下面是一個簡單的使用event的示例。

using System;
public delegate void Func(int a);

public class C {
    public event Func func;

    public void M() {
        func += aa;
        
        func(11);
    }
    void aa(int a){
        Console.WriteLine(a);
    }
}

他生成的中間程式碼如下所示。

.class private auto ansi '<Module>'
{
} // end of class <Module>

.class public auto ansi sealed Func
    extends [mscorlib]System.MulticastDelegate
{...} // end of class Func

.class public auto ansi beforefieldinit C
    extends [mscorlib]System.Object
{
    // Fields
    .field private class Func func
    ...

    // Methods
    .method public hidebysig specialname 
        instance void add_func (
            class Func 'value'
        ) cil managed 
    {...} // end of method C::add_func

    .method public hidebysig specialname 
        instance void remove_func (
            class Func 'value'
        ) cil managed 
    {...} // end of method C::remove_func

    .method public hidebysig 
        instance void M () cil managed 
    {
        ...
        IL_000e: call instance void C::add_func(class Func)
        ...
        IL_0015: ldfld class Func C::func
        IL_001a: ldc.i4.s 11
        IL_001c: callvirt instance void Func::Invoke(int32)
        ...
    } // end of method C::M
    ...
    // Events
    .event Func func
    {
        .addon instance void C::add_func(class Func)
        .removeon instance void C::remove_func(class Func)
    }
} // end of class C

我略去了一部分與本次示例無關的程式碼。大家可以看到,在public event這一行中,雖然我們用的是public來宣告委託變數,但最後編譯器還是將其當做private變數,同時編譯器還為類中增加了兩個方法,分別是add_func和remove_func,用於向委託新增或刪除方法。

這樣,就相當於在類中封裝了Func型別,僅僅暴露了增加和刪除方法的介面給外部,增強了安全