EventHandler<T> 标准事件模式
本教程共 100 篇 · 第 57 篇 · 更新于 2026-07-31 · 约 8 分钟阅读
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金额用
decimal加m后缀(9.99m),避免浮点误差。这是金额场景的硬规矩。即便事件里只是传个值,类型也要选对。
四、OnXxx 虚方法的作用
注意 OnPriceChanged 是 protected virtual。它的好处有两点:一是把「触发事件」收拢到一处,逻辑清晰,将来要加日志、加条件都很方便;二是派生类可以重写它,在事件发出前后插入自己的逻辑,记得调用 base.OnPriceChanged(e) 把链条续上。
class DiscountProduct : Product
{
protected override void OnPriceChanged(PriceChangedEventArgs e)
{
Console.WriteLine("(子类)价格变动,准备促销提醒");
base.OnPriceChanged(e); // 不漏掉基类的广播
}
}
Note标准模式里,事件字段叫
XxxChanged,对应的触发方法叫OnXxxChanged。这套命名几乎所有.NET库都在用,跟着用最省心,也方便别人读你的代码。
为什么 sender 是 object
sender 用 object 是为了适配「事件可能被不同来源触发」的情况。处理方法只关心数据 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 即可,不必硬造一个空类。
七之一、命名速查与反模式
标准事件模式的命名有一套口诀,记牢能少出错:
| 元素 | 命名 | 例子 |
|---|---|---|
| 事件字段 | 名词 + 过去式 / 进行时 | PriceChanged、Loading |
| 事件参数类 | 事件名 + EventArgs | PriceChangedEventArgs |
| 触发方法 | On + 事件名 | OnPriceChanged |
| 处理方法 | 任意,常带 sender, e | Product_PriceChanged |
Tip反模式提醒:别把事件命名成动词命令式(如
ChangePrice),那是方法;事件表达的是「已经发生 / 正在发生」的事。也别在事件参数里放可变集合让外界乱改——只读快照最稳。
八、动手练一练
- 定义一个
FileDeletedEventArgs : EventArgs,带只读属性FileName;写一个FileWatcher类,用event EventHandler<FileDeletedEventArgs>? FileDeleted广播删除事件,并用protected virtual OnFileDeleted触发。 - 订阅两个处理方法:一个打印「已删除:文件名」,一个把文件名追加进一个
List<string>日志。触发一次确认两者都执行。 - 尝试在
EventArgs里把FileName设成只写或可变,体会为什么标准做法要求只读;再把属性改回只读,观察订阅方只能读不能改。
九、小结
标准事件模式 = 只读的 XxxEventArgs + 泛型 EventHandler<T> + protected virtual OnXxx 触发方法。它让事件签名全球统一,数据通过 EventArgs 安全传递,派生类还能扩展触发逻辑。适用「事件要带数据」或「写给别人用的类」;纯信号就用 EventHandler + EventArgs.Empty。记住:sender 说来源,e 装数据,触发前先判空,值没变就别触发。下一章离开事件,进入更轻量的 Lambda 表达式。