1. 介绍
典型新手可能会问:「事件在实际中什么时候用?难道类与类之间的交互都应该用事件,而不是直接调用方法吗?」答案很简单:事件和委托不是万能钥匙,但没有它们,现代应用很快会变成「强耦合」(tightly coupled),一个组件的改动会牵连很多其他东西。用事件和委托可以让系统更灵活、可扩展、易维护。
- UI 编程(WinForms, WPF, Xamarin, MAUI):处理点击、悬停、文本输入和其他用户动作。
- 异步操作:文件下载完成、网络数据到达、定时器触发等。
- 插件和可扩展系统架构:允许在不改动主代码的情况下接入新模块。
- 信号/广播系统:让多个关心的组件收到发生的事件通知。
- 状态变更的观察(“观察者”):对新消息、数据变化、UI 刷新等作出反应。
那我们开始吧!
2. 架构骨架
假设你要做一个小型控制台应用 —— 公司内部小聊聊(或者练手用)。我们有实体:User、Chat,可能还有 Bot。当用户发消息时,聊天室要通知所有在线的用户和机器人,让他们在屏幕上显示这条消息,或者机器人生成自动回复。这是经典场景:发布者触发事件(event),处理器订阅 EventHandler/EventHandler<TEventArgs>,处理方法像 OnMessageReceived 会响应该通知。
// 用户类(订阅者)
public class User
{
public string Name { get; }
public User(string name)
{
Name = name;
}
// 将作为事件处理器的方法
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
Console.WriteLine($"[{Name}] 看到新消息: {e.MessageText}");
}
}
// 事件参数
public class MessageEventArgs : EventArgs
{
public string MessageText { get; }
public MessageEventArgs(string text) => MessageText = text;
}
// 聊天室类(事件发布者)
public class ChatRoom
{
public event EventHandler<MessageEventArgs>? MessageReceived;
public void SendMessage(string text)
{
// 触发事件(通知所有订阅者)
MessageReceived?.Invoke(this, new MessageEventArgs(text));
}
}
使用示例:
var chat = new ChatRoom();
var user1 = new User("安东");
var user2 = new User("玛丽亚");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("大家好! 😊");
控制台会看到两条消息 —— 两个用户都收到了新消息通知。
3. 动态订阅和退订
现实中用户可能会退出聊天室,不想再收到消息。我们给 User 加个正确的退订方法(用 -= 操作符):
// 在 User 类中可以添加方法 "退出聊天室"
public void Unsubscribe(ChatRoom chat)
{
chat.MessageReceived -= OnMessageReceived;
}
扩展示例:
var chat = new ChatRoom();
var user1 = new User("安东");
var user2 = new User("玛丽亚");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("第一条消息");
user2.Unsubscribe(chat); // 玛丽亚退出聊天室
chat.SendMessage("玛丽亚将看不到这条消息");
动态订阅/退订很常见:窗口关闭、选项卡关闭、临时服务从全局事件源退订。别忘了 —— 没退订的订阅者会变成僵尸,应用就会内存泄漏!
4. 消息处理与生成回复
现在加个机器人,机器人对每条消息都做出反应。如果消息里带有关键词 "机器人",机器人就说 "你好!"。同时演示多重订阅和多播委托。
public class Bot
{
public string Name { get; }
public Bot(string name) => Name = name;
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// 机器人根据关键字做出反应
if (e.MessageText.Contains("机器人", StringComparison.OrdinalIgnoreCase))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[机器人 {Name}]: 你好!我能帮啥?");
// 机器人发送回复
chatRoom.SendMessage($"机器人 {Name} 准备帮助你。");
}
}
}
}
使用:
var chat = new ChatRoom();
var user = new User("叶甫根尼");
var bot = new Bot("助手");
chat.MessageReceived += user.OnMessageReceived;
chat.MessageReceived += bot.OnMessageReceived;
// 叶甫根尼发了一条会触发机器人的消息
chat.SendMessage("你好,机器人,最近怎么样?");
控制台可能会输出(注意:调用顺序不保证):
[叶甫根尼] 看到新消息: 你好,机器人,最近怎么样?
[机器人 助手]: 你好!我能帮啥?
[叶甫根尼] 看到新消息: 机器人 助手 准备帮助你。
[机器人 助手]: 你好!我能帮啥?
[叶甫根尼] 看到新消息: 机器人 助手 准备帮助你。
[机器人 助手]: 你好!我能帮啥?
...
谁注意到潜在的 bug 了?没错 —— 无限循环:机器人对自己的消息也会产生反应(因为里面有关键词 "机器人")。其中一种避免办法是在处理器里加个简单检查:
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// 机器人不对自己的消息作反应
if (e.MessageText.Contains("机器人", StringComparison.OrdinalIgnoreCase) &&
!e.MessageText.Contains(Name))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[机器人 {Name}]: 你好!我能帮啥?");
chatRoom.SendMessage($"机器人 {Name} 准备帮助你。");
}
}
}
实际中遇到这种情况时,值得考虑循环事件的处理、用 lambda 表达式做过滤,或者实现取消后续处理器的机制(参见后面的章节)。
5. 异步操作和回调
事件经常用来通知异步操作完成。比如从网络加载数据或长时间计算:通过 Completed 事件通知结束,而 RunLongOperationAsync 执行耗时操作。
public class LongRunner
{
// 完成事件
public event EventHandler<EventArgs>? Completed;
public async Task RunLongOperationAsync()
{
Console.WriteLine("长操作开始...");
await Task.Delay(2000); // 模拟耗时工作
Console.WriteLine("操作完成,通知订阅者。");
Completed?.Invoke(this, EventArgs.Empty);
}
}
客户端代码:
var runner = new LongRunner();
// 订阅完成事件
runner.Completed += (sender, e) =>
{
Console.WriteLine("收到通知:操作完成!");
};
await runner.RunLongOperationAsync();
这就是异步交互的基础 —— UI 框架里经常用事件来表示加载结束、动画结束、用户点击等。
6. 信号系统
再看一个常见任务:通知系统(比如电商里商品打折)。所有关心的人会通过 SaleOccurred 事件收到通知:
public class SaleNotifier
{
public event EventHandler<SaleEventArgs>? SaleOccurred;
public void AnnounceSale(string product, decimal newPrice)
{
SaleOccurred?.Invoke(this, new SaleEventArgs(product, newPrice));
}
}
public class SaleEventArgs : EventArgs
{
public string Product { get; }
public decimal NewPrice { get; }
public SaleEventArgs(string product, decimal price)
{
Product = product; NewPrice = price;
}
}
public class Customer
{
public string Name { get; }
public Customer(string name) => Name = name;
public void OnSale(object? sender, SaleEventArgs e)
{
Console.WriteLine($"[{Name}] 收到通知:{e.Product} 现在价格是 {e.NewPrice.ToString(\"F2\")} 单位!");
}
}
使用:
var notifier = new SaleNotifier();
var c1 = new Customer("安德烈");
var c2 = new Customer("奥尔加");
notifier.SaleOccurred += c1.OnSale;
notifier.SaleOccurred += c2.OnSale;
notifier.AnnounceSale("水壶", 999.99m);
如果某个客户不再感兴趣,就退订:
notifier.SaleOccurred -= c2.OnSale;
notifier.AnnounceSale("搅拌机", 1999.99m);
这个模式用于通知广播(notification, pub/sub),在现代架构中非常重要。
7. 实现取消事件链的机制
有时候某个处理器需要「阻止」后续通知。通常用继承自 EventArgs 的取消标志来实现。下面示例手动遍历 GetInvocationList(),当 Cancel = true 时停止执行。
public class CancelEventArgs : EventArgs
{
public bool Cancel { get; set; }
}
public class EventSource
{
public event EventHandler<CancelEventArgs>? SomethingHappened;
public void DoSomething()
{
var args = new CancelEventArgs();
// 经典的遍历订阅者循环(当你需要控制调用顺序时)
var handlers = SomethingHappened?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
((EventHandler<CancelEventArgs>)handler)(this, args);
if (args.Cancel)
{
Console.WriteLine("消息链已中断。");
break;
}
}
}
}
}
使用:
var source = new EventSource();
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("第一个处理器");
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("第二个处理器取消事件。");
e.Cancel = true;
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("这个处理器不会被调用。");
};
source.DoSomething();
结果:
第一个处理器
第二个处理器取消事件。
消息链已中断。
这种机制常用于校验、在窗口关闭前的处理、权限检查等需要能够说「停!别继续处理」的场景。
8. 线程安全和订阅者管理
在多线程应用中,订阅者可能在事件触发时被添加或移除,这时需要线程安全的模式(参见 官方文档)。可以手动实现事件的 add/remove 访问器,并用 lock 来保护访问:
public class CustomEvent
{
private EventHandler? _handlers;
public event EventHandler SomethingHappened
{
add
{
lock (this) // 线程安全
{
_handlers += value;
}
}
remove
{
lock (this)
{
_handlers -= value;
}
}
}
protected void RaiseEvent()
{
// 使用副本
EventHandler? handler;
lock (this)
{
handler = _handlers;
}
handler?.Invoke(this, EventArgs.Empty);
}
}
这个方式不常用(通常内置机制已经够用),但在高并发系统里有时需要手动控制。
9. UI 中的事件 — WinForms/WPF/MAUI
WinForms:
private void button1_Click(object sender, EventArgs e)
{
MessageBox.Show("按钮被按下!");
}
背后是这样的订阅:
button1.Click += button1_Click;
WPF/MAUI: 常见的是 PropertyChanged 这类事件(模型属性变更通知):
public class MyViewModel : INotifyPropertyChanged
{
private string _value;
public string Value
{
get => _value;
set
{
if (_value != value)
{
_value = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value));
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
框架大量使用事件来构建响应式界面(MVVM 风格)。
10. 常见错误和踩坑
错误 #1:造成内存泄漏。
这是最常见的问题。如果短生命周期的对象(比如窗口或临时服务)订阅了长生命周期对象(比如全局缓存或静态类)的事件,且在销毁时没有退订,它会一直被引用,永远不会被回收。务必在 Dispose 或合适的生命周期点退订。
错误 #2:造成无限事件循环。
订阅者在处理事件时可能触发同样的事件,导致无限递归并最终抛出 StackOverflowException。总要检查条件,避免处理器对自己或自己触发的事件作出反应。
错误 #3:依赖订阅者调用顺序。
不要指望事件处理器会按照订阅顺序被调用。C# 规范和 CLR 实现并不保证这一点。如果顺序重要,就手动通过 GetInvocationList() 以你需要的顺序调用订阅者。
错误 #4:在多线程环境下对事件的不安全操作。
如果在一个线程中添加或移除订阅者,而在另一个线程中触发事件,可能会出现竞态条件。经典做法是在调用前把委托复制到局部变量:
var handler = MyEvent;
handler?.Invoke(this, EventArgs.Empty);
或者在手动实现 add/remove 时使用 lock 来保护。
GO TO FULL VERSION