首页 / C# 入门教程 / EventHandler<T> 标准事件模式

C# 入门教程

EventHandler<T> 标准事件模式

本教程共 100 篇 · 第 57 篇 · 更新于 2026-07-31 · 约 8 分钟阅读

C#C# 入门教程编程语言事件EventHandler标准模式

57. EventHandler<T> 标准事件模式

本节目标:学完你能用标准事件模式定义带数据的事件,并知道 sender 和 EventArgs 的分工。

上一章的事件没带任何数据。真实场景里,事件往往要捎带信息:价格变了,得告诉旧价和新价;文件删了,得告诉文件名。.NET 有一套标准事件模式(standard event pattern),让所有事件长一个样,别人一看就懂,工具也能识别。它最大的好处是「约定大于配置」——只要大家都照这个模子写,协作和阅读成本直线下降。

一、标准事件处理方法的签名

无论什么事件,处理方法都遵守同一签名:

void Handler(object? sender, TEventArgs e);

第一个参数是是谁触发的(通常是 this),第二个是携带数据的事件参数。返回值是 void。这套约定让任何 .NET 开发者都能立刻接手你的事件,IDE 也能自动补全、帮你生成正确签名。

Note

为什么返回 void?因为事件可能同时通知很多人,没有「单一的返回值」可言。需要把结果回传给发布者,应当用别的设计(比如事件参数里带一个「回应」字段),而不是靠返回值。

二、自定义 EventArgs

数据装在继承自 EventArgs 的类里,命名惯例是「事件名 + EventArgs」:

namespace CSharpDemo;

class PriceChangedEventArgs : EventArgs
{
    public decimal OldPrice { get; }
    public decimal NewPrice { get; }

    public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
    {
        OldPrice = oldPrice;
        NewPrice = newPrice;
    }
}

属性只读,保证事件数据在传递途中不会被处理方偷偷改掉。这是有意为之的设计——事件参数是一份「快照」,接收方只能读,不能反过来污染发布者的状态。

Note

不传数据时,用非泛型的 EventHandler 搭配 EventArgs.Empty 即可,不必另建类。例如 public event EventHandler? Loaded;,触发写 Loaded?.Invoke(this, EventArgs.Empty);

EventArgs 基类本身提供了 Empty 这个静态字段,以及后续版本加上的 EventArgs() 构造等基础能力。继承它不只是「礼貌」,也让你的类能享受这些通用约定。

三、用 EventHandler<T> 声明事件

泛型 EventHandler<T> 已经把上面那套签名打包好了,不用自己定义委托:

namespace CSharpDemo;

class PriceChangedEventArgs : EventArgs
{
    public decimal OldPrice { get; }
    public decimal NewPrice { get; }
    public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
    {
        OldPrice = oldPrice;
        NewPrice = newPrice;
    }
}

class Product
{
    private decimal _price;

    public decimal Price
    {
        get => _price;
        set
        {
            if (_price != value)
            {
                var old = _price;
                _price = value;
                OnPriceChanged(new PriceChangedEventArgs(old, value));
            }
        }
    }

    public event EventHandler<PriceChangedEventArgs>? PriceChanged;

    protected virtual void OnPriceChanged(PriceChangedEventArgs e) =>
        PriceChanged?.Invoke(this, e);
}

void React(object? sender, PriceChangedEventArgs e) =>
    Console.WriteLine($"价格从 {e.OldPrice} 变到 {e.NewPrice}");

var p = new Product { Price = 9.99m };
p.PriceChanged += React;
p.Price = 12.5m; // 价格从 9.99 变到 12.50
Tip

金额用 decimalm 后缀(9.99m),避免浮点误差。这是金额场景的硬规矩。即便事件里只是传个值,类型也要选对。

四、OnXxx 虚方法的作用

注意 OnPriceChangedprotected virtual。它的好处有两点:一是把「触发事件」收拢到一处,逻辑清晰,将来要加日志、加条件都很方便;二是派生类可以重写它,在事件发出前后插入自己的逻辑,记得调用 base.OnPriceChanged(e) 把链条续上。

class DiscountProduct : Product
{
    protected override void OnPriceChanged(PriceChangedEventArgs e)
    {
        Console.WriteLine("(子类)价格变动,准备促销提醒");
        base.OnPriceChanged(e); // 不漏掉基类的广播
    }
}
Note

标准模式里,事件字段叫 XxxChanged,对应的触发方法叫 OnXxxChanged。这套命名几乎所有 .NET 库都在用,跟着用最省心,也方便别人读你的代码。

为什么 sender 是 object

senderobject 是为了适配「事件可能被不同来源触发」的情况。处理方法只关心数据 e,不依赖具体类型,耦合更低。你也常用 sender as Product 在内部判断来源——比如同一个处理方法同时订阅了多个对象的事件,就能靠 sender 区分是谁触发的。

五、和老写法对比

老代码常自己定义委托:

// 旧写法(现在不推荐)
delegate void PriceChangedHandler(object sender, PriceChangedEventArgs e);
public event PriceChangedHandler? PriceChanged;

新写法直接用 EventHandler<PriceChangedEventArgs>,省掉自定义委托,也和整个生态保持一致。除非你需要独特的签名(比如多返回值,但事件本就不该有返回值),否则一律用 EventHandler<T>。自己定义委托不仅啰嗦,还会让别的开发者多记一套类型名。

写法优点缺点
自定义 delegate签名可任意定制啰嗦、生态不统一、易重复造轮子
EventHandler<T>标准、零定义、工具友好只能是 (object?, T) → void 这一形

六、新手踩坑提醒

第一个坑:在 PriceChanged?.Invoke 之前忘了判空。没有订阅者时会抛空引用,务必用 ?.。并发场景同样建议先存本地副本再调用。

第二个坑:在 EventArgs 里放可变字段,让处理方改了数据。事件数据应当是只读快照,设计成只读属性最安全。如果确实需要回传结果,显式加一个「回应」字段并说明语义,而不是把输入字段做成可写。

第三个坑:在属性 setter 里每次都触发,哪怕值没变。上面的例子用 if (_price != value) 拦住了「设成原值也触发」的浪费——否则订阅者会被无意义的通知刷屏。

Tip

想让事件「只触发一次就自动退订」,可以在处理方法里写 source.Event -= Handler;,但要用具名方法才能引用自身。注意这步最好放在方法开头,避免重入时重复退订或重复处理。

七、什么时候该用标准模式

  • 事件需要携带数据:价格、文件名、进度百分比……凡是处理方法要用到的信息,都该放 XxxEventArgs 里。
  • 你写的是供他人使用的类 / 库:标准签名让使用者零学习成本。
  • 需要派生类能插手触发逻辑protected virtual OnXxx 正是为此存在。

如果事件纯信号、不带任何数据,直接用非泛型 EventHandler + EventArgs.Empty 即可,不必硬造一个空类。

七之一、命名速查与反模式

标准事件模式的命名有一套口诀,记牢能少出错:

元素命名例子
事件字段名词 + 过去式 / 进行时PriceChangedLoading
事件参数类事件名 + EventArgsPriceChangedEventArgs
触发方法On + 事件名OnPriceChanged
处理方法任意,常带 sender, eProduct_PriceChanged
Tip

反模式提醒:别把事件命名成动词命令式(如 ChangePrice),那是方法;事件表达的是「已经发生 / 正在发生」的事。也别在事件参数里放可变集合让外界乱改——只读快照最稳。

八、动手练一练

  1. 定义一个 FileDeletedEventArgs : EventArgs,带只读属性 FileName;写一个 FileWatcher 类,用 event EventHandler<FileDeletedEventArgs>? FileDeleted 广播删除事件,并用 protected virtual OnFileDeleted 触发。
  2. 订阅两个处理方法:一个打印「已删除:文件名」,一个把文件名追加进一个 List<string> 日志。触发一次确认两者都执行。
  3. 尝试在 EventArgs 里把 FileName 设成只写或可变,体会为什么标准做法要求只读;再把属性改回只读,观察订阅方只能读不能改。

九、小结

标准事件模式 = 只读的 XxxEventArgs + 泛型 EventHandler<T> + protected virtual OnXxx 触发方法。它让事件签名全球统一,数据通过 EventArgs 安全传递,派生类还能扩展触发逻辑。适用「事件要带数据」或「写给别人用的类」;纯信号就用 EventHandler + EventArgs.Empty。记住:sender 说来源,e 装数据,触发前先判空,值没变就别触发。下一章离开事件,进入更轻量的 Lambda 表达式。