事件简介
和上一篇一样,本篇依旧采用半翻译半总结的方式。
事件也是一种后期绑定机制,并且是基于委托的支持建立的。事件是对象广播(向系统中所有对该事件感兴趣的组件)发生的事情的一种方式。任何其他组件都可以订阅该事件,并且在该事件发生时得到通知。
比如很多图形系统都有一个事件模型来报告用户的动作,比如移动鼠标、按下按钮等。
订阅事件会在两个对象(事件源和事件订阅者)之间建立耦合。
让我们首先确定几个术语:事件源、事件订阅者、事件源组件。它们的关系如下图所示:
事件源就是用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控件的示例,订阅事件后,事件源可能会在程序的整个生命周期内引发事件。
这与许多基于委托的设计相反,在基于委托的设计中,委托被用作方法的参数,而该方法返回后不再使用委托。
谨慎选择
以上考虑不是硬性规定,相反它们可以作为帮助你选择的指导。它们都很好地处理了后期绑定方案。选择那个最能传达你设计的信息。