C#里的事件机制是构建松耦合程序的重要工具。无论是WinForms里的按钮点击,还是WPF中的数据变更通知,背后都离不开事件。这篇文章会从委托讲起,一步步带你搞清楚事件的定义、订阅、触发,以及在实际项目中应该遵循的标准写法。

事件的底层原理:为什么事件离不开委托
要理解事件,必须先理解委托。委托本质上是一种类型安全的函数指针,它可以把方法当作参数传递,也可以把多个方法存到一个调用列表里。当你调用一个委托变量时,它会依次执行列表中的所有方法。
而事件,本质上就是对委托变量的一层封装。用event关键字修饰一个委托类型的字段后,这个字段在外部类中就只能出现在+=和-=的左侧,无法被外部直接调用或赋值。这个限制就是事件机制的核心价值:发布者保留唯一的触发权,订阅者只能决定自己要不要参与。
如果没有这层封装,直接暴露一个公开的委托字段会带来两个严重问题。第一,外部代码可以把委托变量赋值为null,把其他订阅者全部清掉;第二,外部代码可以直接调用这个委托,冒充发布者触发事件。事件关键字正是为了堵住这两个漏洞而存在的。
定义和订阅事件:从最简单的例子开始
下面用一个经典的“煮咖啡通知”例子演示完整流程。先定义一个委托类型,再在发布者类中声明事件,最后在订阅方注册处理方法。
using System;
namespace EventDemo
{
// 第一步:定义委托类型,约定事件处理方法的签名
public delegate void BoiledEventHandler(object sender, EventArgs e);
// 发布者:热水器
public class Heater
{
private int temperature;
// 第二步:声明事件
public event BoiledEventHandler Boiled;
// 第三步:提供触发事件的方法,通常声明为 protected virtual 以便子类重写
protected virtual void OnBoiled(EventArgs e)
{
// 判空后再触发,防止没有订阅者时抛出空引用异常
Boiled?.Invoke(this, e);
}
public void BoilWater()
{
for (int i = 0; i <= 100; i += 20)
{
temperature = i;
if (temperature >= 80)
{
// 达到条件,触发事件
OnBoiled(EventArgs.Empty);
}
}
}
}
// 订阅者:警报器
public class Alarm
{
public void MakeAlert(object sender, EventArgs e)
{
Console.WriteLine("警报:水快烧开了!");
}
}
class Program
{
static void Main(string[] args)
{
Heater heater = new Heater();
Alarm alarm = new Alarm();
// 第四步:订阅事件,把处理方法挂到事件上
heater.Boiled += alarm.MakeAlert;
heater.Boiled += (s, e) => Console.WriteLine("匿名方法也可以订阅");
heater.BoilWater();
// 取消订阅,防止对象生命周期结束后仍被引用
heater.Boiled -= alarm.MakeAlert;
Console.ReadKey();
}
}
}
这段代码里有几个细节值得注意。触发事件时使用了?.Invoke()的写法,这是C# 6之后推荐的方式,比先判断null再调用更简洁且线程安全。OnBoiled方法被声明为protected virtual,这是.NET的设计惯例,目的是让继承的子类也能重写事件的触发逻辑。
订阅时用+=,取消订阅用-=。如果同一个方法被订阅了两次,触发时会被执行两次,取消一次订阅只移除一个引用,这些行为要记清楚,不然排查重复执行问题时会很头疼。
EventHandler标准模式与自定义EventArgs
实际项目中并不需要每次都自定义委托类型。.NET框架提供了泛型委托EventHandler<TEventArgs>,它的签名是void Handler(object sender, TEventArgs e),已经成为事件处理的事实标准。如果需要给订阅者传递数据,就继承EventArgs写一个自定义参数类。
using System;
// 自定义事件参数,携带温度数据
public class TemperatureEventArgs : EventArgs
{
public int Temperature { get; }
public TemperatureEventArgs(int temperature)
{
Temperature = temperature;
}
}
public class Heater
{
// 使用框架自带的泛型委托,无需自己声明委托类型
public event EventHandler<TemperatureEventArgs> Boiled;
protected virtual void OnBoiled(int temperature)
{
Boiled?.Invoke(this, new TemperatureEventArgs(temperature));
}
public void BoilWater()
{
OnBoiled(98);
}
}
class Program
{
static void Main()
{
var heater = new Heater();
// 使用Lambda表达式订阅
heater.Boiled += (sender, e) =>
{
Console.WriteLine($"当前温度:{e.Temperature} 度");
};
heater.BoilWater();
}
}
这种写法的好处很明显:sender参数让订阅者知道事件来自哪个对象,继承EventArgs的参数类可以携带任意业务数据,而且整个签名符合.NET生态的统一约定,别的开发者一看就懂。如果你的类库会被外部使用,强烈建议遵循这套模式,而不是随意定义签名。
另外提醒一点,从C# 9开始EventArgs的构造不再强制要求继承,任何类型都可以直接作为泛型参数使用,但为了保持代码的一致性和兼容性,老项目里继续继承EventArgs依然是更稳妥的选择。
事件使用中的常见坑
第一个坑是内存泄漏。事件发布者会持有订阅者的强引用,只要发布者还活着,订阅者就无法被垃圾回收。这在WinForms开发中尤其常见:一个长生命周期的全局对象订阅了某个短生命周期的窗体事件,窗体关闭后内存迟迟不释放。解决办法是在窗体关闭时显式执行-=取消订阅。
第二个坑是事件处理方法的异常传播。一个事件有多个订阅者时,如果前面的订阅者抛出异常且未捕获,后面的订阅者将不会收到通知。Invoke是同步顺序调用的,这一点和多播委托的行为一致。如果需要保证每个订阅者都被执行,就要手动遍历委托的调用列表,逐个用try-catch包裹。
第三个坑涉及多线程。在触发事件前判断了null,真正调用前另一个线程恰好取消了订阅,这时仍可能抛出空引用异常。稳妥的做法是把事件变量先复制到局部变量再判断调用,形如var handler = Boiled; handler?.Invoke(this, e);,而Boiled?.Invoke()这种写法在编译后实际上就是这样的逻辑,所以直接用条件调用运算符即可。
掌握这些细节后,事件就不再神秘。它不过是封装过的多播委托,配合合理的命名规范和生命周期管理,能帮你写出结构清晰、模块之间互不依赖具体实现的代码,这也是观察者模式在C#中最直接的落地方式。