事件与订阅模型
本教程共 100 篇 · 第 56 篇 · 更新于 2026-07-31 · 约 8 分钟阅读
56. 事件与订阅模型
本节目标:学完你能用 event 定义事件,用 += 订阅、用 -= 取消,并理解发布订阅模型。
你点按钮,程序弹出对话框——这种「某件事发生,通知关心它的人」的机制,就是事件(event)。它底层就是上一章的委托(delegate),但加了一层保护:外部只能订阅或取消,不能乱调用、也不能整体替换。
事件要解决的真实问题是「一对多通知」。比如温度计读数一变,显示器、报警器、日志记录器都要做出反应。让温度计认识每一个对象并挨个调用,耦合太重;事件让它们各自来「订阅」,温度变了统一广播,谁订阅谁收,互不认识也互不影响。把「通知」和「被通知的对象」解耦,正是事件的核心价值。
一、用 event 定义事件
事件基于一个委托类型。最常见用内置 Action 或 EventHandler:
namespace CSharpDemo;
class Notifier
{
public event Action<string>? Alert;
public void Trigger(string msg)
{
Alert?.Invoke(msg);
}
}
void OnAlert(string m) => Console.WriteLine($"收到提醒:{m}");
var notifier = new Notifier();
notifier.Alert += OnAlert;
notifier.Trigger("下雨了"); // 收到提醒:下雨了
notifier.Alert -= OnAlert;
notifier.Trigger("又下雨了"); // 无人订阅,无输出
Alert 是事件,Trigger 是「触发事件」也就是对外广播的方法。注意触发前用 ?. 判断有没有人订阅,没人就跳过。这里 OnAlert 写成顶层的局部函数,订阅和触发都更直观,不必再套一个 Program 类。
Note
event关键字不改变委托本身,只是给委托加了访问限制。类内部能完整操作(可=、可+=、可-=、可直接Invoke),类外部只能用+=和-=。
event 背后其实是一对访问器 add 和 remove,和你写属性(property)时的 get / set 一个道理。编译器默认帮你生成了标准实现,所以平时看不见。想自定义(比如加日志、做线程同步)也能手写,但入门阶段用默认实现就够:
public event Action<string>? Alert
{
add { /* 有人订阅时 */ }
remove { /* 有人取消时 */ }
}
默认实现里,这两个访问器用线程安全的方式维护订阅列表,这也是为什么我们不必自己加锁去保护订阅关系。
二、订阅与取消
别的代码用 += 表示「我要听这件事」,-= 表示「我不要听了」。这就是发布订阅(publish-subscribe) 模型:发布者 Notifier 不认识订阅者,订阅者也不依赖发布者内部。两者只通过事件松耦合。
把「为什么需要事件」再讲透一点。如果 Alert 只是个 public Action<string>? 普通委托字段,外面任何代码都能做三件危险的事:
- 写
notifier.Alert = null把别人的订阅全清空; - 直接
notifier.Alert("假的")伪造一次触发,绕过了你精心设计的Trigger; - 用
=整体替换,把之前所有订阅者一刀切掉。
event 把「赋值」和「直接调用」这两件事禁掉,只留下 += / -=。订阅者之间彼此看不见,谁也动不了别人的订阅。这正是事件存在的核心理由:把「通知权」和「控制权」分开,发布者掌控何时广播,订阅者只管进和退。
Tip一句话记:委托字段是「谁都能改的喇叭」,事件是「只能报名听、不能抢话筒的广播」。公开委托字段几乎总是设计失误,需要通知就用
event。
三、用按钮点击的思路理解
GUI 里按钮点击就是事件:你写 button.Click += 处理方法。本章不写界面,但模型一模一样——按钮是发布者,「点击」是事件,你的处理方法是订阅者。把按钮换成上面的 Notifier,原理完全相同。
换个生活类比更好记:事件像订阅公众号。你(订阅者)关注(+=)某个公众号(发布者),它每次发文(触发事件)你就收到推送(处理方法被调用);你取关(-=)就不再收到。公众号不需要知道你是谁、有多少人关注,它只管发。这种「发布者不知订阅者、订阅者互不知晓」的松散关系,正是事件最舒服的地方。
四、多个订阅者一起收
事件天生支持多播(multicast),多个方法都订阅,触发时全部执行,顺序就是订阅的顺序:
namespace CSharpDemo;
class Notifier
{
public event Action<string>? Alert;
public void Trigger(string msg) => Alert?.Invoke(msg);
}
void A(string m) => Console.WriteLine($"A 收到:{m}");
void B(string m) => Console.WriteLine($"B 收到:{m}");
var n = new Notifier();
n.Alert += A;
n.Alert += B;
n.Trigger("开会了");
// A 收到:开会了
// B 收到:开会了
一次触发,A 和 B 都收到。触发时订阅列表是按「先来后到」顺序调用的,所以先 += 的 A 先执行。如果某个订阅者的处理方法抛了未捕获异常,默认会中断后续订阅者——这也是为什么处理方法里尽量别抛异常,或者在订阅者内部自己兜住。
五、新手踩坑提醒
第一个坑:触发前没判空。如果没有任何订阅者,直接 Alert(msg) 会抛 NullReferenceException。一定用 Alert?.Invoke(msg)。更严谨的写法先把引用存到本地变量,避免多线程下刚好在判空之后、调用之前被别人取消:
namespace CSharpDemo;
class Notifier
{
public event Action<string>? Alert;
public void Trigger(string msg)
{
var handler = Alert; // 先存本地副本
handler?.Invoke(msg); // 再对副本判空调用
}
}
Warning在可能多线程触发的场景,先
var handler = Alert;再handler?.Invoke(msg)是推荐写法。直接Alert?.Invoke(msg)在单线程够用,但在并发下存在「判空时还有人,调用前被取消」的极小窗口,本地副本能关掉这个窗口。
第二个坑:用 Lambda 订阅后无法取消。下面这种 -= 删不掉:
n.Alert += m => Console.WriteLine(m); // 临时 Lambda
// n.Alert -= m => ... // 删不掉,这是另一个对象
要能取消,必须把处理方法存成具名方法或局部变量,再拿同一个引用去 -=。
Note官方文档特别强调:取消订阅时要保证「加减的是同一个引用」。用具名方法最省心,Lambda 想取消就得先把 Lambda 存进变量。
第三个坑:忘了取消订阅导致内存泄漏。事件会让发布者「抱着」订阅者的方法引用。如果订阅者是长生命周期对象、而事件源一直活着,订阅者可能一直不被回收。不再关心事件时,记得 -= 退订(GUI 里窗口关闭时常做这件事)。
六、事件适合在什么场景
不是所有「调用方法」都该用事件。适合用事件的典型场景:
- 一对多通知:一个变化要同时告知多个互不相干的对象(温度变→显示器 + 报警器 + 日志)。
- 发布者不该依赖订阅者:比如框架提供的按钮、定时器,它不可能预先知道你的业务方法。
- 运行时才决定谁关心:订阅关系常在执行中动态增删,事件天然支持。
反过来,不适合用事件的场景:
- 调用方明确要拿到返回值、要同步得到结果——那用普通方法或
Func委托更直接。 - 只有唯一固定的处理逻辑、且编译期就确定——直接调方法最清楚,没必要绕一圈事件。
- 需要严格控制「必须恰好一个」处理器——事件允许多播,这种语义不匹配。
Tip拿不准时问自己:是不是「我发生了,但不知道也不关心谁来处理」?是,就用事件;不是,大概率直接调方法更好。
七、事件、委托、接口怎么选
三者都能「把一个动作交给别人做」,区别在耦合和方向:
| 方式 | 调用方向 | 耦合 | 适合 |
|---|---|---|---|
| 普通方法 / 接口 | 调用方主动找实现 | 编译期绑定,较强 | 逻辑固定、需要返回值或强契约 |
| 委托(delegate / Func / Action) | 调用方持有可调用对象 | 中等,运行期可换 | 传一小段逻辑、回调 |
| 事件(event) | 被通知方反向订阅 | 最松,双向都不认识 | 广播通知、UI 交互 |
简单说:要「我调用你」用方法 / 接口;要「我传一段逻辑给你用」用委托;要「我广播、你愿意就来听」用事件。
八、C# 14 的小变化
从 C# 14 起,事件也能声明为 partial,把定义和实现拆成两部分(和 partial 方法类似)。普通入门用不到,但知道事件在持续演进就好。示意如下:
// 定义声明(无访问器)
partial event EventHandler? Something;
// 实现声明(提供 add / remove)
partial event EventHandler? Something
{
add { /* 可在此加日志 */ }
remove { /* 可在此加日志 */ }
}
九、动手练一练
- 写一个
Thermometer类,用event Action<decimal>? TemperatureChanged表示温度变化;提供一个SetTemperature(decimal)方法,在新旧值不同时触发事件。 - 用两个局部函数订阅它(比如「显示器」和「报警器」),触发一次看是否都收到;再取消其中一个,确认只剩另一个收得到。
- 故意不判空就直接
TemperatureChanged(value)触发,观察没有订阅者时会发生什么,再改成?.Invoke修复。
十、小结
event = 受保护的委托:类内随意,类外只能 += / -=。它实现了发布者与订阅者的解耦,支持多订阅者,是按钮点击、消息通知等机制的基石。适用一对多、动态、松耦合的通知;拿到返回值或逻辑固定时改用方法或委托。记住三大坑:触发前判空(并发下先存本地副本)、Lambda 订阅难取消(用具名方法)、用完记得退订防泄漏。下章讲标准事件模式,让事件还能带上数据。