首页 / C# 入门教程 / 事件与订阅模型

C# 入门教程

事件与订阅模型

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

C#C# 入门教程编程语言事件event发布订阅

56. 事件与订阅模型

本节目标:学完你能用 event 定义事件,用 += 订阅、用 -= 取消,并理解发布订阅模型。

你点按钮,程序弹出对话框——这种「某件事发生,通知关心它的人」的机制,就是事件(event)。它底层就是上一章的委托(delegate),但加了一层保护:外部只能订阅或取消,不能乱调用、也不能整体替换。

事件要解决的真实问题是「一对多通知」。比如温度计读数一变,显示器、报警器、日志记录器都要做出反应。让温度计认识每一个对象并挨个调用,耦合太重;事件让它们各自来「订阅」,温度变了统一广播,谁订阅谁收,互不认识也互不影响。把「通知」和「被通知的对象」解耦,正是事件的核心价值。

一、用 event 定义事件

事件基于一个委托类型。最常见用内置 ActionEventHandler

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 背后其实是一对访问器 addremove,和你写属性(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 里窗口关闭时常做这件事)。

六、事件适合在什么场景

不是所有「调用方法」都该用事件。适合用事件的典型场景:

  1. 一对多通知:一个变化要同时告知多个互不相干的对象(温度变→显示器 + 报警器 + 日志)。
  2. 发布者不该依赖订阅者:比如框架提供的按钮、定时器,它不可能预先知道你的业务方法。
  3. 运行时才决定谁关心:订阅关系常在执行中动态增删,事件天然支持。

反过来,不适合用事件的场景:

  • 调用方明确要拿到返回值、要同步得到结果——那用普通方法或 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 { /* 可在此加日志 */ }
}

九、动手练一练

  1. 写一个 Thermometer 类,用 event Action<decimal>? TemperatureChanged 表示温度变化;提供一个 SetTemperature(decimal) 方法,在新旧值不同时触发事件。
  2. 用两个局部函数订阅它(比如「显示器」和「报警器」),触发一次看是否都收到;再取消其中一个,确认只剩另一个收得到。
  3. 故意不判空就直接 TemperatureChanged(value) 触发,观察没有订阅者时会发生什么,再改成 ?.Invoke 修复。

十、小结

event = 受保护的委托:类内随意,类外只能 += / -=。它实现了发布者与订阅者的解耦,支持多订阅者,是按钮点击、消息通知等机制的基石。适用一对多、动态、松耦合的通知;拿到返回值或逻辑固定时改用方法或委托。记住三大坑:触发前判空(并发下先存本地副本)、Lambda 订阅难取消(用具名方法)、用完记得退订防泄漏。下章讲标准事件模式,让事件还能带上数据。