1. 介绍
在 C# 中使用委托和事件很舒服、很方便——语言替你做了很多事。但在这层便利的背后有不少坑:看不见的内存泄漏、重复订阅导致的奇怪 bug、处理器不同步甚至在广播时突发异常。 委托和事件很强大,但需要注意对象生命周期、线程和对调用与取消订阅机制的理解。 如果你的事件“多数时候”正常,但偶尔不触发或抛奇怪错误——你并不孤单!我们来看看经验丰富的程序员们最常踩的坑,以及怎么避免它们。
主要错误表
| 错误 | 后果 | 如何避免 |
|---|---|---|
| 重复订阅 | 处理器被多次调用 | 注意订阅位置,添加前先移除 |
| 未取消订阅(内存泄漏) | 订阅者被留在内存中,变成“僵尸” | 总是取消订阅,使用 IDisposable |
| 处理器中抛出异常 | 剩下的处理器不会被调用 | 在处理器里用 try/catch 或手动逐个调用 |
| 在事件广播期间修改订阅 | 可能丢失或重复调用 | 遍历处理器的副本 (GetInvocationList()) |
| 调用事件时 MyEvent == null | NullReferenceException | 检查 null,使用 ?.Invoke |
| 处理器签名不匹配 | 编译错误 | 检查签名 |
| 在外部调用事件 | 编译错误 | 只能通过 OnEventName 调用 |
| 不当使用静态事件 | 订阅混在一起 | 非必要别用 static |
| lambda 的 closure 问题 | 意外的值 | 复制变量到局部 |
2. 重复订阅和多重调用
错误要点
如果你多次把同一个处理器订阅到同一事件,每一次 += 都会把方法加入委托队列。结果是处理器会被调用和添加次数相同的次数。
怎么表现?
想象你有一个按钮和点击处理器:
Button btn = new Button();
btn.Click += OnButtonClick; // 订阅一次
btn.Click += OnButtonClick; // 又一次订阅!
现在每次点击按钮时 OnButtonClick 会被调用两次。如果处理器里更新计数器或写日志,会看到重复的结果。
如何修复?
重复订阅通常是因为代码结构不当——比如把 += 放到会被多次调用的方法(不同生命周期阶段)。
- 注意订阅发生的位置。
- 不要把 += 放到可能多次运行的生命周期阶段。
- 有时可以用“唯一订阅”技巧——在添加前先移除:
myEvent -= MyHandler; // 先移除以防已存在
myEvent += MyHandler; // 再添加
这是安全的:如果之前没有该处理器,-= 不会有副作用。
3. 僵尸订阅者
错误要点
如果订阅者订阅了一个长生命周期的发布者但没有取消订阅,GC 不能回收它:发布者仍然持有对处理器委托的引用,也就间接持有订阅者对象。结果是内存泄漏。
典型示例
public class TemporaryPopup : IDisposable
{
private Window _hostWindow;
public TemporaryPopup(Window window)
{
_hostWindow = window;
_hostWindow.Closed += OnHostClosed;
}
private void OnHostClosed(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_hostWindow.Closed -= OnHostClosed; // 别忘了取消订阅!
}
}
如果忘了调用 Dispose(),即便你移除了对 TemporaryPopup 的其他引用,对象也不会被回收——窗口还持有它的处理器引用。
如何避免?
- 如果订阅者的生命周期比发布者短,给订阅者实现 IDisposable。
- 使用 using 模式或显式调用 Dispose():
using (var popup = new TemporaryPopup(mainWindow))
{
// ...
} // 这里会自动调用 Dispose
在 GUI 程序里——在窗口/表单关闭时取消订阅(比如在关闭处理器或表单的 Dispose() 中)。
4. 处理器内的异常
错误要点
当事件调用很多处理器时,如果其中一个抛异常,剩下的处理器就不会被调用。
演示
public event EventHandler MyEvent;
public void Raise()
{
MyEvent?.Invoke(this, EventArgs.Empty);
}
如果订阅在 MyEvent 的某个方法里抛出异常,后续的处理器就不会执行——调用链会被中断。
怎么处理?
- 在事件处理器内部要么本地捕获异常(try/catch),要么有意识地让异常向上抛。
- 复杂场景下可以手动遍历订阅列表,为每个处理器隔离错误:
var handlers = MyEvent?.GetInvocationList();
foreach (var handler in handlers)
{
try
{
handler.DynamicInvoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
// 记录日志、处理恢复
}
}
5. 在广播期间修改订阅列表
错误要点
如果某个处理器在事件处理中取消订阅自己或其他处理器,可能会改变调用顺序:有的处理器会被跳过或被重复调用。
如何避免?
- 不要在处理器内部修改订阅集合。
- 如果必须修改,先遍历委托的副本:
var handlers = MyEvent?.GetInvocationList();
foreach (EventHandler handler in handlers)
{
handler(this, EventArgs.Empty);
}
6. 委托为 null(无人订阅)的问题
错误要点
如果没有任何订阅者,事件的委托等于 null。不做检查就调用会抛出 NullReferenceException。
坏示例
public event EventHandler MyEvent;
public void Raise()
{
MyEvent(this, EventArgs.Empty); // 如果没人订阅——异常!
}
怎么做才对?
- 使用安全调用:MyEvent?.Invoke(this, EventArgs.Empty)。
- 或者用经典的线程安全写法:把委托复制到局部变量再调用它。
7. 不同签名的委托/事件混用
错误要点
委托是强类型的。处理器方法的签名和事件的签名不匹配会导致编译错误。
示例
public event EventHandler<string> TextChanged;
void WrongHandler(object sender, int number) { /* ... */ }
TextChanged += WrongHandler; // 编译错误!
尽量使用标准委托 EventHandler 和 EventHandler<T>,并确保签名完全匹配。
8. 在发布者类外部尝试调用事件
错误要点
event 是封装的委托:外部代码只能添加/移除处理器,不能直接调用它。
示例
public class MyPublisher
{
public event EventHandler SomethingHappened;
}
var publisher = new MyPublisher();
publisher.SomethingHappened?.Invoke(publisher, EventArgs.Empty); // 错误!
正确做法
发布者应该通过受保护或公有的方法来触发事件——通常叫 OnEventName。外部代码只能用 +=/-=。
9. 自定义 add/remove 访问器 的错误
错误要点
自定义事件访问器可以控制订阅过程,但很容易破坏调用顺序或线程安全性。
示例
public event EventHandler MyEvent
{
add { /* ... */ }
remove { /* ... */ }
}
如果不确定,优先使用默认事件实现。手动实现时参考文档并考虑同步问题。
10. 静态和实例上下文访问事件的问题
错误要点
很容易不小心把事件声明为 static,但它本该是实例级别的。这样所有实例会共享同一订阅队列。
示例
public static event EventHandler GlobalEvent; // 糟糕!
// 实例的个性化订阅被合并到一起
如何避免?
只有在确实需要全局级别(比如全局日志)时才用静态事件。通常用实例级事件来保持封装。
11. lambda 捕获变量的问题
错误要点
lambda 捕获的是变量的引用。在循环里这常会导致“最终值”问题。
示例
for (int i = 0; i < 5; i++)
{
button.Click += (s, e) => Console.WriteLine(i);
}
// 所有处理器都会打印 "5"
正确写法
for (int i = 0; i < 5; i++)
{
int copy = i; // 局部拷贝
button.Click += (s, e) => Console.WriteLine(copy);
}
12. 弱引用与强引用混用:高级“弱事件”
在大型应用(如 WPF)中会用到“弱事件”机制,发布者对订阅者只保持弱引用,避免阻止 GC 回收。弱事件能防止泄漏,但订阅者可能被回收而收不到事件。
详情参考: Weak Event Patterns (MSDN)
13. 缺乏命名和签名标准
错误要点
遵循命名和签名规范:事件用过去式命名(Changed, Closed, Completed),事件参数继承自 EventArgs。
“不对”的示例:
public delegate void SomethingHappens(int what);
// ...
public event SomethingHappens Something;
“正确”的示例:
public event EventHandler<EventArgs> SomethingHappened;
大部分自定义事件几乎都应该用 EventHandler 或 EventHandler<T>。你的同事(以及将来的你)会感谢这种统一。
GO TO FULL VERSION