事件簡介
和上一篇一樣,本篇依舊采用半翻譯半總結的方式。沒看過的同學可以看一看上一篇委托
事件也是一種后期綁定機制,並且是基於委托的支持建立的。事件是對象廣播(向系統中所有對該事件感興趣的組件)發生的事情的一種方式。任何其他組件都可以訂閱該事件,並且在該事件發生時得到通知。
比如很多圖形系統都有一個事件模型來報告用戶的動作,比如移動鼠標、按下按鈕等。
訂閱事件會在兩個對象(事件源和事件訂閱者)之間建立耦合。
讓我們首先確定幾個術語:事件源、事件訂閱者、事件源組件。它們的關系如下圖所示:
事件源就是用event
關鍵字定義的事件,它用於引發事件。
事件源組件是事件源定義的地方,通常是一個類,當滿足引發事件的條件時,就可以調用事件源引發事件。
事件訂閱者是引發事件后調用的方法,包含有具體的執行邏輯。
Event的設計目標
- 事件源和事件訂閱者之間耦合度小。
- 訂閱事件、取消訂閱簡單。
- 事件源可以被多個事件訂閱者訂閱。
Event的語言支持
定義事件、訂閱事件、取消訂閱事件的語法都是對委托語法的擴展。
定義事件使用event
關鍵字:
public event EventHandler<FileListArgs> Progress;
事件類型EventHandler<FileListArgs>
必須為委托類型。
事件的定義需要遵循許多約定,比如事件的委托類型需要沒有返回值,事件名應為動詞或動詞短語,使用過去式報告已經發生的事情,使用現在式報告即將發生的事情。
引發事件時,使用委托調用語法調用事件處理程序
Progress?.Invoke(this, new FileListArgs(file));
?.
運算符可以輕松確保在事件沒有訂閱者時不引發事件。
通過使用 +=
運算符訂閱事件:
//1個委托
EventHandler<FileListArgs> onProgress = (sender, eventArgs) =>
Console.WriteLine(eventArgs.FoundFile);
//事件的注冊
lister.Progress += onProgress;
可以看到,事件的注冊對象是委托。發生某件事件進而進行的處理程序,通常帶有前綴On
,如上所示。
使用-=
運算符取消訂閱:
lister.Progress -= onProgress;
可以看到上面聲明了一個局部委托用來訂閱和取消訂閱。如果你使用了lambda表達式來訂閱,那么你將無法刪除該訂閱者。
標准.NET事件模式
.NET事件通常遵循一些已知的模式。
事件委托的簽名
用於事件的委托的標准簽名如下:
//委托的返回值是void,參數是object sender, EventArgs args
void OnEventRaised(object sender, EventArgs args);
返回類型為 void,因為光靠返回值會產生歧義,一個方法的單個返回值不能擴展到多個事件訂閱者。
參數列表包含兩種參數:事件源組件和事件參數。事件源組件的編譯時類型為System.Object
,事件參數通常派生自System.EventArgs
,你可以使用特殊值EventArgs.Empty
來表示事件不包含任何其他信息。
接下來我們創建一個類FileSearcher
,該類的功能是可以列出某個目錄中符合要求的所有文件,該類為符合要求的每個文件都引發一個事件。
下面首先創建用於查找所需文件的事件參數FileFoundArgs
:
public class FileFoundArgs : EventArgs
{
public string FoundFile { get; }
public FileFoundArgs(string fileName)
{
FoundFile = fileName;
}
}
可以看到這雖然是一個只包含數據小型類型,但我們把它設置為類,這意味着這個參數將通過引用傳遞,所有事件訂閱者都將看到該參數的數據更新。上面的例子只能查看不能修改該參數。
接下來需要在FileSearcher
中創建事件聲明,我們使用已經存在的委托類型EventHandler<T>
,然后直接聲明一個event
變量即可。最后我們在發現匹配文件時引發事件。
public class FileSearcher
{
//系統定義的委托,把它用於聲明一個事件
public event EventHandler<FileFoundArgs> FileFound;
public void Search(string directory, string searchPattern)
{
//如果文件符合要求
foreach (var file in Directory.EnumerateFiles(directory,searchPattern))
{
//引發事件
FileFound?.Invoke(this, new FileFoundArgs(file));
}
}
}
定義和引發類似字段的事件
將事件添加到類中的最簡單方法是把該事件聲明為公共字段,如上一節所示:
public event EventHandler<FileFoundArgs> FileFound;
這似乎是在聲明一個公共領域,並不是一個很好的面向對象的設計,但編譯器確實生成了一個包裝器,且只能以安全的方式訪問該事件對象。該類似字段的事件唯一可用的操作是添加/刪除處理程序:
//用lambda的方式定義一個委托
EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) => {
Console.WriteLine(eventArgs.FoundFile);
filesFound++;
};
//fileLister是FileSearcher的一個實例
fileLister.FileFound += onFileFound;
fileLister.FileFound -= onFileFound;
這里雖然有onFileFound
局部變量,但由於是用lambda定義,刪除操作將不會正常工作。
值得一提的是,該類外的代碼無法引發事件和進行其他事件操作。
從事件訂閱者返回值
我們考慮一個新的功能:取消。
這里設定的場景是引發FileFound
事件時,如果被檢查的文件是最后一個符合要求的文件,那么事件源組件應該停止接下來的動作。
由於事件處理程序(事件訂閱者)不返回值,因此你需要以其他的方式進行信息傳遞。標准事件模式使用EventArgs
對象包含某些字段(事件訂閱者可以改變這些字段來傳遞信息)。
基於“取消”的語義,可以使用兩種不同的模式(兩種模式下都需要EventArgs
中的一個bool
字段)。
模式一允許任何事件訂閱者取消操作。這種模式下bool
字段被初始化為false
,任何事件訂閱者都可以將它改為true
,當所有事件訂閱者收到事件發生通知之后,FileSearcher
將檢查這個值並采取進一步動作。
模式二在所有事件訂閱者都希望取消進一步動作時,FileSearcher
才取消接下來的動作。在這種模式下bool
字段被初始化為true
,任何事件訂閱者都可以將它改為false
(表示接下來的動作可以繼續)。當所有事件訂閱者收到事件發生通知之后,FileSearcher
將檢查這個值並采取進一步動作。此模式有一個額外的步驟:事件發起組件需要知道是否有任何訂閱者收到了該事件。如果沒有訂閱者,則該字段也將錯誤地指示。
讓我們看一下模式一的例子,首先需要在EventArgs
中添加一個bool
字段CancelRequested
:
public class FileFoundArgs : EventArgs
{
//文件名
public string FoundFile { get; }
//是否取消動作
public bool CancelRequested { get; set;}
public FileFoundArgs(string fileName)
{
FoundFile = fileName;
}
}
這個字段會自動初始化為false
,該組件需要在引發事件之后檢查該標記以確定是否有任何訂閱者請求取消接下來的動作:
public void List(string directory, string searchPattern)
{
foreach (var file in Directory.EnumerateFiles(directory,searchPattern))
{
//創建參數
var args = new FileFoundArgs(file);
//觸發事件
FileFound?.Invoke(this, args);
//檢查結果
if (args.CancelRequested)
break;
}
}
這種模式的好處是,它不會引起重大改變,即增加一個新的檢查項后,訂閱者以前不要求取消,現在也不對這個新的檢查項進行取消。除非用戶想要訂閱者去支持檢查新的字段。這樣的耦合非常松散。
我們更新一個訂閱者,讓它在找到第一個可執行文件后取消事件源組件接下來的動作。
//訂閱者改變參數
EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
Console.WriteLine(eventArgs.FoundFile);
eventArgs.CancelRequested = true;
};
另一個示例
讓我們再看一個例子,這個例子演示了事件的另一個慣用方法。該例子中我們遍歷所有子目錄。在具有許多子目錄的目錄中,這可能是一個冗長的操作,讓我們添加一個事件,該事件在每次新目錄搜索開始時引發。這使得訂閱者可以跟蹤進度,並向用戶報告進度。這次我們將此事件作為內部事件。這意味着EventArgs
也可設為private
的。
首先創建新的EventArgs
派生類,用於報告新目錄和進度。
//internal關鍵字表示只能在程序集中使用,程序集外部無法訪問
internal class SearchDirectoryArgs : EventArgs
{
internal string CurrentSearchDirectory { get; }
internal int TotalDirs { get; }
internal int CompletedDirs { get; }
internal SearchDirectoryArgs(string dir, int totalDirs, int completedDirs)
{
CurrentSearchDirectory = dir;
TotalDirs = totalDirs;
CompletedDirs = completedDirs;
}
}
定義事件,這次試用不同的語法,除了使用字段語法之外,還可以使用添加和刪除處理程序顯式創建屬性。這些處理程序中不需要額外的代碼,但這顯示了如何創建它們。
//事件定義
internal event EventHandler<SearchDirectoryArgs> DirectoryChanged
{
//事件屬性
add { directoryChanged += value; }
remove { directoryChanged -= value; }
}
//事件聲明
private event EventHandler<SearchDirectoryArgs> directoryChanged;
也就是說上面的代碼是編譯器為你先前看到的字段定義事件而隱式生成的代碼,一般情況下,都是使用先前與屬性非常相似的語法來創建事件。
讓我們一起看Search
方法,它遍歷所有子目錄,並引發兩個事件。
public void Search(string directory, string searchPattern, bool searchSubDirs)
{
//如果需要搜索子目錄
if (searchSubDirs)
{
//獲取所有子目錄
var allDirectories = Directory.GetDirectories(directory, "*.*", SearchOption.AllDirectories);
//當前已完成搜索的目錄
var completedDirs = 0;
//目錄總數
var totalDirs = allDirectories.Length + 1;
foreach (var dir in allDirectories)
{
//每搜索到1個子目錄,觸發事件
directoryChanged?.Invoke(this, new SearchDirectoryArgs(dir,totalDirs,completedDirs++));
// 遞歸搜索子目錄
SearchDirectory(dir, searchPattern);
}
// 當前目錄也觸發事件
directoryChanged?.Invoke(this,new SearchDirectoryArgs(directory,totalDirs,completedDirs++));
//遞歸對當前目錄處理
SearchDirectory(directory, searchPattern);
}
else//如果不需要搜索子目錄
{
SearchDirectory(directory, searchPattern);
}
}
private void SearchDirectory(string directory, string searchPattern)
{
foreach (var file in Directory.EnumerateFiles(directory,searchPattern))
{
var args = new FileFoundArgs(file);
//對於符合要求每個文件,都引發事件
FileFound?.Invoke(this, args);
//如果訂閱者需要取消,則不進行繼續搜索
if (args.CancelRequested)
break;
}
}
如果directoryChanged
沒有訂閱者,使用?.Invoke()
慣用語可保證其工作正常。
下面是訂閱者的具體邏輯,引發事件時,在控制台打印當前檢查到的目錄和完成進度。
lister.DirectoryChanged += (sender, eventArgs) =>
{
Console.Write($"Entering '{eventArgs.CurrentSearchDirectory}'.");
Console.WriteLine($" {eventArgs.CompletedDirs} of {eventArgs.TotalDirs} completed...");
};
事件是C#
中的重要模式,通過學習它,可以快速編寫出慣用的C#
和.NET
代碼。接下來將看到這些模式在最新版本的.NET
的一些更改。
更新的.Net Core中的事件模式
.NET Core
的模式較為寬松,EventHandler<TEventArgs>
定義不再具有TEventArgs
必須是從System.EventArgs
派生的類的約束。
為了增加靈活性並且向后兼容,System.EventArgs
類引入了一個方法MemberwoseClone()
,該方法創建對象的淺克隆副本,該方法必須使用反射,以便為從EventArgs
派生的任何類實現其功能。
你還可以將SearchDirectoryArgs
改為結構體
internal struct SearchDirectoryArgs
{
internal string CurrentSearchDirectory { get; }
internal int TotalDirs { get; }
internal int CompletedDirs { get; }
internal SearchDirectoryArgs(string dir, int totalDirs, int completedDirs) : this()
{
CurrentSearchDirectory = dir;
TotalDirs = totalDirs;
CompletedDirs = completedDirs;
}
}
你不應該將FileFoundArgs
從引用類型改為值類型,否則事件源組件無法觀察到任何訂閱者的修改。
讓我們來一下此修改如何向后兼容,刪除約束不會影響任何現有代碼,任何現有的事件參數類型任然可以從System.EventArgs
派生。向后兼容是它們仍可以繼續從System.EventArgs
派生的主要原因之一。那么現在創建的新類型將不會在已存在的代碼庫中有任何訂閱者。
異步訂閱者的事件
你需要學習最后一種模式:如何正確編寫調用異步代碼的事件訂閱者。這將在之后的async and await
文章中介紹。
區分代理和事件
在基於委托的設計和基於事件的設計之間做出決定,對於.Net Core
平台的新手來說經常會通常費勁。因為兩種語言的功能非常相似。甚至事件是由委托語言來構建的。它們的共同點如下:
- 都提供了后期綁定機制(組件通過調用僅在運行時才知道的方法進行通信)。
- 都支持單個和多個訂閱者。
- 都有相似的添加和刪除處理程序的語法。
- 引發事件和調用委托使用完全相同的語法。
- 都支持
Invoke()
和.?
一起使用。
收聽事件是可選的
確定使用哪種語言功能時,最重要考慮的因素是是否必須有依附的訂閱者。如果你的代碼必須調用訂閱者的代碼,則應該使用基於委托的設計,如果你的代碼可以在不調用任何訂閱者的情況下完成其所有工作,則應該使用基於事件的設計。
結合前面一篇的例子考慮。使用List.Sort()
必須提供一個比較函數以便正確對元素進行排序。LINQ
查詢必須與委托一起提供,以便要確定返回的元素。兩者都要使用基於委托的設計。(這里相當於把委托當做方法的參數,經過嘗試,event確實不能作為方法的參數)
結合本篇上面的例子考慮。Progress
事件用於報告任務進度,無論是否有任何訂閱者,任務都會繼續進行下去。FileSearcher
事件是另一個示例,即使沒有附加事件訂閱者,它仍然會搜索並找到所有要查找的文件。兩者都要使用基於事件的設計。
有返回值需要委托
用於事件的委托類型都具有無效的返回類型,雖然我們可以使用EventArgs
來傳遞參數,但它不如直接從方法中返回結果那樣自然。
所以當事件訂閱者有返回值時,我們選擇基於委托的設計。
事件訂閱者通常有更長的生命周期
這是一個稍弱的理由,但是你可能會發現,當事件源組件將會在很長的一段時間內引發事件時,基於事件的設計將更加自然。你可以在很多系統上看到針對UX控件的示例,訂閱事件后,事件源可能會在程序的整個生命周期內引發事件。
這與許多基於委托的設計相反,在基於委托的設計中,委托被用作方法的參數,而該方法返回后不再使用委托。
謹慎選擇
以上考慮不是硬性規定,相反它們可以作為幫助你選擇的指導。它們都很好地處理了后期綁定方案。選擇那個最能傳達你設計的信息。