标准 .NET 事件模式

上一篇

.NET 事件通常遵循几种已知模式。 对这些模式进行标准化意味着开发人员可以应用这些标准模式的知识,这些模式可应用于任何 .NET 事件程序。

让我们浏览这些标准模式,以便你具备创建标准事件源所需的全部知识,并在代码中订阅和处理标准事件。

事件委托签名

.NET 事件委托的标准签名是:

void EventRaised(object sender, EventArgs args);

此标准签名提供了有关何时使用事件的见解:

  • 返回类型为 void。 事件可以包含零到多个侦听器。 引发事件会通知所有侦听器。 通常,侦听器不提供值来响应事件。
  • 事件指示发送方:事件签名包括引发该事件的对象。 这为任何侦听器提供了与发送方通信的机制。 sender 的编译时类型为 System.Object,即使有一个始终正确的更底层派生的类型亦是如此。 按照惯例使用 object
  • 事件将在单个结构中打包更多信息args 参数是从 System.EventArgs 类型派生出的, 包含任何必需信息。 (下 一部分 将看到此约定不再强制执行。如果事件类型不需要更多参数,则仍必须同时提供这两个参数。 有一个特殊值, EventArgs.Empty 应该用来表示事件不包含任何其他信息。

让我们生成一个类,它在目录或遵循模式的任何子目录中列出文件。 此组件为每个找到的与模式相匹配的文件引发事件。

使用事件模型有一些设计优势。 可以创建多个事件侦听器,用于在找到查找的文件时执行不同的操作。 合并不同的侦听器可以创建更可靠的算法。

下面是查找所查找文件的初始事件参数声明:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

尽管这种类型看上去是小型的仅限数据的类型,但仍应按约定将其设为引用 (class) 类型。 这意味着参数对象通过引用传递,所有订阅者都会查看对数据的任何更新。 第一版是不可变对象。 应优先将事件参数类型中的属性设为不可变。 这样,一个订阅者无法在另一个订阅者看到值之前更改这些值。 (这种做法有例外情况,如稍后所示)。

接下来,我们要在 FileSearcher 类中创建事件声明。 使用 System.EventHandler<TEventArgs> 类型意味着你不需要创建另一个类型定义。 只需使用泛型专业化即可。

让我们通过填充 FileSearcher 类来搜索与模式匹配的文件,并在发现匹配时引发正确的事件。

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;

看起来它是在声明一个公共字段,这似乎是一个面向对象的不良实践。 你希望通过属性或方法来保护数据访问。 虽然此代码看起来可能很糟糕,但编译器生成的代码确实会创建包装器,以便只能以安全的方式访问事件对象。 类似字段的事件上唯一可用的操作是添加删除处理程序:

var fileLister = new FileSearcher();
int filesFound = 0;

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    filesFound++;
};

fileLister.FileFound += onFileFound;
fileLister.FileFound -= onFileFound;

处理程序有一个局部变量。 如果使用了 lambda 的正文,则 remove 处理程序无法正常进行。 它将成为不同的委托实例,并静默地不执行任何操作。

类外部的代码无法引发事件,也不能执行任何其他作。

从 C# 14 开始,事件可以声明为 部分成员。 部分事件声明必须包括 定义声明实现声明。 定义声明必须使用类似于字段的事件语法。 实现声明必须声明 addremove 处理程序。

从事件订阅服务器返回值

你的简单版本当前运行正常。 让我们添加另一项功能:取消。

在引发找到的事件时,如果此文件是最后查找到的文件,则侦听器应能够停止进一步的处理。

事件处理程序不返回值,因此需要以另一种方式进行通信。 标准事件模式使用 EventArgs 对象来包含字段,事件订阅服务器使用这些字段进行通信取消。

根据“取消”协定的语义,可使用两种不同的模式。 在两种情况下,你都将为找到的文件事件向 EventArguments 添加布尔字段。

其中一种模式允许任一订阅服务器取消操作。 在此模式下,新字段会初始化为 false。 任何订阅服务器都可将其更改为 true。 当所有订阅服务器引发事件后,FileSearcher 组件将检查布尔值,并执行操作。

在第二种模式下,仅当所有订阅服务器都要取消操作时才可取消操作。 在此模式下,新字段会初始化为指示操作应取消,而任何订阅服务器都可将其更改为指示操作应继续。 当所有订阅服务器处理完引发的事件后,FileSearcher 组件将检查布尔,并执行操作。 此模式中有一个额外的步骤:组件需要知道是否有订阅者响应了事件。 如果没有订阅服务器,字段会错误地指示取消。

让我们来实现此示例的第一版。 需要将名为 CancelRequested 的布尔字段添加到 FileFoundArgs 类型:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }
    public bool CancelRequested { get; set; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

此新字段会自动初始化为false,以防您意外取消。 对组件进行的唯一其它更改是在引发事件后检查标志,查看是否有任何订阅服务器提出了取消请求:

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;
    }
}

此模式的一个优点是不会造成重大更改。 此前没有任何订阅者请求取消,他们至今仍没有提出取消请求。 除非订阅者代码想要支持新的取消协议,否则任何订阅者代码都不需要更新。

让我们来更新订阅服务器,使其在找到第一个可执行文件时请求取消:

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    eventArgs.CancelRequested = true;
};

添加另一个事件声明

让我们再添加一项功能,并演示事件的其它语言习惯用语。 让我们添加搜索文件时遍历所有子目录的 Search 方法的重载。

此方法在包含许多子目录的目录中可能耗时较长。 让我们添加一个在每次新目录搜索开始时引发的事件。 此事件允许订阅者跟踪进度,并通知用户有关进度的更新。 到目前为止创建的所有示例都是公开的。 让我们将此事件设为内部事件。 这意味着您还可以将参数类型设置为内部类型。

首先创建新的 EventArgs 派生类来报告新目录和进度。

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 EventHandler<SearchDirectoryArgs>? _directoryChanged;

在许多方面,此处编写的代码反映了编译器为前面看到的字段事件定义生成的代码。 使用类似于 属性的语法创建事件。 请注意,处理程序的名称各不相同:addremove。 通过调用这些访问器来订阅事件,或取消订阅事件。 请注意,还必须声明一个私有支持字段以存储事件变量。 此变量初始化为 null。

接下来,让我们添加 Search 方法的重载,该重载遍历子目录,并引发这两个事件。 最简单的方法是使用默认参数来指定要搜索所有目录:

public void Search(string directory, string searchPattern, bool searchSubDirs = false)
{
    if (searchSubDirs)
    {
        var allDirectories = Directory.GetDirectories(directory, "*.*", SearchOption.AllDirectories);
        var completedDirs = 0;
        var totalDirs = allDirectories.Length + 1;
        foreach (var dir in allDirectories)
        {
            _directoryChanged?.Invoke(this, new (dir, totalDirs, completedDirs++));
            // Search 'dir' and its subdirectories for files that match the search pattern:
            SearchDirectory(dir, searchPattern);
        }
        // Include the Current Directory:
        _directoryChanged?.Invoke(this, new (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() 成语可确保其正常工作。

让我们通过添加处理程序来编写一行,用于在控制台窗口显示进度。

fileLister.DirectoryChanged += (sender, eventArgs) =>
{
    Console.Write($"Entering '{eventArgs.CurrentSearchDirectory}'.");
    Console.WriteLine($" {eventArgs.CompletedDirs} of {eventArgs.TotalDirs} completed...");
};

你看到了在整个 .NET 生态系统中遵循的模式。 通过学习这些模式和约定,可以快速编写惯用 C# 和 .NET。

另请参阅

接下来,在最新版本的 .NET 中,你将看到这些模式中的一些更改。