.NET基礎知識快速通關(9)

語言: CN / TW / HK

【.NET 總結 /Edison Zhou

本文為第 篇,我們會對.NET的 事件 相關考點進行基礎複習,全文會以Q/A的形式展現,即以面試題的形式來描述。

Intro 開頭

事件這一名稱對於我們.NET碼農來說肯定不會陌生,各種技術框架例如WindowsForm、ASP.NET WebForm都會有事件這一名詞,並且所有的定義都基本相同。在.NET中,事件和委託在本質上並沒有太多的差異,實際環境下事件的運用卻比委託更加廣泛。

1 能說說事件如何使用嗎?

在Microsoft的產品文件上這樣來定義的事件: 事件是一種使物件或類能夠提供通知的成員 。客戶端可以通過提供事件處理程式為相應的事件新增可執行程式碼。設計和使用事件的全過程大概包括以下幾個步驟:

下面我們來按照規範的步驟來展示一個通過控制檯輸出事件的使用示例:

① 定義一個控制檯事件ConsoleEvent的引數型別ConsoleEventArgs

/// <summary>
/// 自定義一個事件引數型別
/// </summary>
public class ConsoleEventArgs : EventArgs
{
// 控制檯輸出的訊息
private string message;


public string Message
{
get
{
return message;
}
}


public ConsoleEventArgs()
: base()
{
this.message = string.Empty;
}


public ConsoleEventArgs(string message)
: base()
{
this.message = message;
}
}

② 定義一個控制檯事件的管理者,在其中定義了事件型別的私有成員ConsoleEvent,並定義了事件的傳送方法SendConsoleEvent

/// <summary>
/// 管理控制檯,在輸出前傳送輸出事件
/// </summary>
public class ConsoleManager
{
// 定義控制檯事件成員物件
public event EventHandler<ConsoleEventArgs> ConsoleEvent;


/// <summary>
/// 控制檯輸出
/// </summary>
public void ConsoleOutput(string message)
{
// 傳送事件
ConsoleEventArgs args = new ConsoleEventArgs(message);
SendConsoleEvent(args);
// 輸出訊息
Console.WriteLine(message);
}


/// <summary>
/// 負責傳送事件
/// </summary>
/// <param name="args">事件的引數</param>
protected virtual void SendConsoleEvent(ConsoleEventArgs args)
{
// 定義一個臨時的引用變數,確保多執行緒訪問時不會發生問題
EventHandler<ConsoleEventArgs> temp = ConsoleEvent;
if (temp != null)
{
temp(this, args);
}
}
}

③ 定義了事件的訂閱者Log,在其中通過控制檯時間的管理類公開的事件成員訂閱其輸出事件ConsoleEvent

/// <summary>
/// 日誌型別,負責訂閱控制檯輸出事件
/// </summary>
public class Log
{
// 日誌檔案
private const string logFile = @"C:\TestLog.txt";


public Log(ConsoleManager cm)
{
// 訂閱控制檯輸出事件
cm.ConsoleEvent += this.WriteLog;
}


/// <summary>
/// 事件處理方法,注意引數固定模式
/// </summary>
/// <param name="sender">事件的傳送者</param>
/// <param name="args">事件的引數</param>
private void WriteLog(object sender, EventArgs args)
{
// 檔案不存在的話則建立新檔案
if (!File.Exists(logFile))
{
using (FileStream fs = File.Create(logFile)) { }
}


FileInfo fi = new FileInfo(logFile);


using (StreamWriter sw = fi.AppendText())
{
ConsoleEventArgs cea = args as ConsoleEventArgs;
sw.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "|" + sender.ToString() + "|" + cea.Message);
}
}
}

④ 在Main方法中進行測試:

public class Program
{
public static void Main(string[] args)
{
// 控制檯事件管理者
ConsoleManager cm = new ConsoleManager();
// 控制檯事件訂閱者
Log log = new Log(cm);


cm.ConsoleOutput("測試控制檯輸出事件");
cm.ConsoleOutput("測試控制檯輸出事件");
cm.ConsoleOutput("測試控制檯輸出事件");


Console.ReadKey();
}
}

當該程式執行時,ConsoleManager負責在控制檯輸出測試的字串訊息,與此同時,訂閱了控制檯輸出事件的Log類物件會在指定的日誌檔案中寫入這些字串訊息。可以看出,這是一個典型的觀察者模式的應用,也可以說事件為觀察者模式提供了便利的實現基礎。

2 事件 和 委託 有什麼關係?

事件的定義和使用方式與委託極其類似,那麼二者又是何關係呢?

經常聽人說, 委託的本質是一個型別,而事件的本質是一個特殊的委託型別的例項 。關於這個解釋,最好的辦法莫過於通過檢視原始碼和編譯後的IL程式碼進行分析。

① 回顧剛剛的程式碼,在ConsoleManager類中定義了一個事件成員

public event EventHandler<ConsoleEventArgs> ConsoleEvent;

EventHandler 是.NET框架中提供的一種標準的事件模式,它是一個特殊的泛型委託類 型,通過檢視元資料可以驗證這一點:

[Serializable]
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);

正如上面程式碼所示, 我們定義一個事件時,實際上是定義了一個特定的委託成員例項。 該委託沒有返回值,並且有兩個引數:一個事件源和一個事件引數。 當事件的使用者訂閱該事件時,其本質就是 將事件的處理方法加入到委託鏈之中

② 下面通過Reflector來檢視一下事件ConsoleEvent的IL程式碼(中間程式碼),可以更方便地看到這一點:

首先,檢視EventHandler的IL程式碼,可以看到在C#編譯器編譯delegate程式碼時,編譯後是成為了一個class。

其次,當C#編譯器編譯event程式碼時,會首先為型別新增一個EventHandler<T>的委託例項物件,然後為其增加一對add/remove方法用來實現從委託鏈中新增和移除方法的功能。

通過檢視add_ConsoleEvent的IL程式碼,可以清楚地看到 訂閱事件的本質是呼叫Delegate的Combine方法將事件處理方法繫結到委託鏈中

L_0000: ldarg.0 
L_0001: ldfld class [mscorlib]System.EventHandler`1<class ConsoleEventDemo.ConsoleEventArgs> ConsoleEventDemo.ConsoleManager::ConsoleEvent
L_0006: stloc.0
L_0007: ldloc.0
L_0008: stloc.1
L_0009: ldloc.1
L_000a: ldarg.1
L_000b: call class [mscorlib]System.Delegate [mscorlib]System.Delegate::Combine(class [mscorlib]System.Delegate, class [mscorlib]System.Delegate)
L_0010: castclass [mscorlib]System.EventHandler`1<class ConsoleEventDemo.ConsoleEventArgs>
L_0015: stloc.2
L_0016: ldarg.0
L_0017: ldflda class [mscorlib]System.EventHandler`1<class ConsoleEventDemo.ConsoleEventArgs> ConsoleEventDemo.ConsoleManager::ConsoleEvent

總結: 事件是一個特殊的委託例項,提供了兩個供訂閱事件和取消訂閱的方法:add_event 和 remove_event,其本質都是基於委託鏈來實現

3 如何設計一個帶有多個事件的型別?

多事件的型別在實際應用中並不少見,尤其是在一些使用者介面的型別中(例如在WindowsForm中的各種控制元件)。這些型別動輒將包含數十個事件,如果為每一個事件都新增一個事件成員,將導致無論使用者是否用到所有事件,每個型別物件都將佔有很大的記憶體,那麼對於系統的效能影響將不言而喻。事實上,.NET的開發小組運用了一種比較巧妙的方式來避免這一困境。

Solution: 當某個型別具有相對較多的事件時,我們可以考慮 顯示地設計訂閱、取消訂閱事件的方法,並且把所有的委託連結串列儲存在一個集合之中 。這樣做就能避免在型別中定義大量的委託成員而導致型別過大。

下面通過一個具體的例項來說明這一設計:

① 定義包含大量事件的型別之一:使用EventHandlerList成員來儲存所有事件

public partial class MultiEventClass
{
// EventHandlerList包含了一個委託連結串列的容器,實現了多事件存放在一個容器之中的包裝,它使用的是連結串列資料結構
private EventHandlerList events;


public MultiEventClass()
{
// 初始化EventHandlerList
events = new EventHandlerList();
}


// 釋放EventHandlerList
public void Dispose()
{
events.Dispose();
}
}

② 定義包含大量事件的型別之二:申明多個具體的事件

public partial class MultiEventClass
{
#region event1
// 事件1的委託原型
public delegate void Event1Handler(object sender, EventArgs e);
// 事件1的靜態Key
protected static readonly object Event1Key = new object();
// 訂閱事件和取消訂閱
// 注意:EventHandlerList並不提供執行緒同步,所以加上執行緒同步屬性
public event Event1Handler Event1
{
[MethodImpl(MethodImplOptions.Synchronized)]
add
{
events.AddHandler(Event1Key, value);
}
[MethodImpl(MethodImplOptions.Synchronized)]
remove
{
events.RemoveHandler(Event1Key, value);
}
}
// 觸發事件1
protected virtual void OnEvent1(EventArgs e)
{
events[Event1Key].DynamicInvoke(this, e);
}
// 簡單地觸發事件1,以便於測試
public void RiseEvent1()
{
OnEvent1(EventArgs.Empty);
}
#endregion


#region event2
// 事件2的委託原型
public delegate void Event2Handler(object sender, EventArgs e);
// 事件2的靜態Key
protected static readonly object Event2Key = new object();
// 訂閱事件和取消訂閱
// 注意:EventHandlerList並不提供執行緒同步,所以加上執行緒同步屬性
public event Event2Handler Event2
{
[MethodImpl(MethodImplOptions.Synchronized)]
add
{
events.AddHandler(Event2Key, value);
}
[MethodImpl(MethodImplOptions.Synchronized)]
remove
{
events.RemoveHandler(Event2Key, value);
}
}
// 觸發事件2
protected virtual void OnEvent2(EventArgs e)
{
events[Event2Key].DynamicInvoke(this, e);
}
// 簡單地觸發事件2,以便於測試
public void RiseEvent2()
{
OnEvent2(EventArgs.Empty);
}
#endregion
}

③ 定義事件的訂閱者(它對多事件型別內部的構造一無所知)

public class Customer
{
public Customer(MultiEventClass events)
{
// 訂閱事件1
events.Event1 += Event1Handler;
// 訂閱事件2
events.Event2 += Event2Handler;
}


// 事件1的回撥方法
private void Event1Handler(object sender, EventArgs e)
{
Console.WriteLine("事件1被觸發");
}


// 事件2的回撥方法
private void Event2Handler(object sender, EventArgs e)
{
Console.WriteLine("事件2被觸發");
}
}

④ 編寫入口方法來測試多事件的觸發

public class Program
{
public static void Main(string[] args)
{
using(MultiEventClass mec = new MultiEventClass())
{
Customer customer = new Customer(mec);
mec.RiseEvent1();
mec.RiseEvent2();
}


Console.ReadKey();
}
}

最終 執行結果如下圖所示:

總結EventHandlerList的用法,在多事件型別中為每一個事件都定義了一套成員,包括事件的委託原型、事件的訂閱和取消訂閱方法,在實際應用中,可能需要定義事件專用的引數型別。這樣的設計主旨在於改動包含多事件的型別,而訂閱事件的客戶並不會察覺這樣的改動。設計本身不在於減少程式碼量,而在於有效減少多事件型別物件的大小。

4 使用事件模擬:貓叫 -> 老鼠逃跑 & 主人驚醒

這是一個典型的觀察者模式的應用場景,事件的發源在於貓叫這個動作,在貓叫之後,老鼠開始逃跑,而主人則會從睡夢中驚醒。可以發現,主人和老鼠這兩個型別的動作相互之間沒有聯絡,但都是由貓叫這一事件觸發的。

設計的大致思路在於,貓類包含並維護一個貓叫的動作,主人和老鼠的物件例項需要訂閱貓叫這一事件,保證貓叫這一事件發生時主人和老鼠可以執行相應的動作。

(1)設計貓類,為其定義一個貓叫的事件CatCryEvent:

public class Cat
{
private string name;
// 貓叫的事件
public event EventHandler<CatCryEventArgs> CatCryEvent;


public Cat(string name)
{
this.name = name;
}


// 觸發貓叫事件
public void CatCry()
{
// 初始化事件引數
CatCryEventArgs args = new CatCryEventArgs(name);
Console.WriteLine(args);
// 開始觸發事件
CatCryEvent(this, args);
}
}


public class CatCryEventArgs : EventArgs
{
private string catName;


public CatCryEventArgs(string catName)
: base()
{
this.catName = catName;
}


public override string ToString()
{
string message = string.Format("{0}叫了", catName);
return message;
}
}

(2)設計老鼠類,在其構造方法中訂閱貓叫事件,並提供對應的處理方法

public class Mouse
{
private string name;
// 在構造方法中訂閱事件
public Mouse(string name, Cat cat)
{
this.name = name;
cat.CatCryEvent += CatCryEventHandler;
}


// 貓叫的處理方法
private void CatCryEventHandler(object sender, CatCryEventArgs e)
{
Run();
}


// 逃跑方法
private void Run()
{
Console.WriteLine("{0}逃走了:我勒個去,趕緊跑啊!", name);
}
}

(3)設計主人類,在其構造犯法中訂閱貓叫事件,並提供對應的處理方法

public class Master
{
private string name;


// 在構造方法中訂閱事件
public Master(string name, Cat cat)
{
this.name = name;
cat.CatCryEvent += CatCryEventHandler;
}


// 針對貓叫的處理方法
private void CatCryEventHandler(object sender, CatCryEventArgs e)
{
WakeUp();
}


// 具體的處理方法——驚醒
private void WakeUp()
{
Console.WriteLine("{0}醒了:我勒個去,叫個錘子!", name);
}
}

(4)最後在Main方法中進行場景的模擬:

public class Program
{
public static void Main(string[] args)
{
Cat cat = new Cat("假老練");
Mouse mouse1 = new Mouse("風車車", cat);
Mouse mouse2 = new Mouse("米奇妙", cat);
Master master = new Master("李扯火", cat);
// 毛開始叫了,老鼠和主人有不同的反應
cat.CatCry();


Console.ReadKey();
}
}

這裡定義了一隻貓,兩隻老鼠與一個主人,當貓的CatCry方法被執行到時,會觸發貓叫事件CatCryEvent,此時就會通知所有這一事件的訂閱者。

本場景的關鍵之處就在於主人和老鼠的動作應該完全由貓叫來觸發。

下面是場景模擬程式碼的執行結果:

End 總結

本文總結複習了.NET的事件相關的重要知識點,下一篇會總結.NET中反射相關的重要知識點,歡迎繼續關注!

參考資料(全是經典)

朱毅 ,《進入IT企業必讀的200個.NET面試題》

張子陽,《.NET之美:.NET關鍵技術深入解析》

王濤,《你必須知道的.NET(第二版)》