从一次改造说起:为什么回调会要了老命
很多人在初学C#时,对委托、event、Action 这几个概念总是处于“看着认识,一用就混”的状态。我最早也是。当时团队里有一个老项目,订单状态一旦变化,需要同时通知短信服务、操作日志模块、数据统计模块,甚至还要刷新一个后台管理界面。原始代码于是变成了这样:在一个方法里依次调用 SmsService.Send()、Logger.Write()、StatService.Add()。
第一版还好,只通知三处。后面需求越来越多,每加一个通知对象,就要改一次核心业务方法,而且这些服务类都是直接new出来的,耦合度直线上升。最离谱的一次,短信通道超时,整个订单流程被拖慢了2秒。直到我带着团队重构,把这块改成“事件驱动”,才算彻底解脱。
这篇文章不是教科书式的概念罗列,而是围绕标题里的委托 event Action / event Action 做一次实战归纳。适合几类人看:刚开始学C#、被委托事件绕晕的人;写过一点代码但不敢在项目里用event的人;准备面试、需要把“什么是委托和事件”讲清楚的人。看完全文,你应该能直接写出一个带有 event Action 事件的发布器,也能避开我当年踩过的坑。
场景还原:一处通知牵动多个模块
先还原一下那个订单场景的代码结构:
public class OrderService { public void CompleteOrder(Order order) { // 核心业务:改状态、落库 order.Status = OrderStatus.Completed; _db.Save(order); // 通知各种模块 SmsService.Send(order.UserPhone, "您的订单已完成"); Logger.Write($"订单 {order.Id} 完成"); StatService.AddCompleted(order.CreatedAt); } }每次有新模块要关心订单完成,就得改 CompleteOrder 方法,加一行调用。如果有一天短信服务挂了,因为 Send() 是同步调用,还会阻塞主流程。这就是高耦合带来的问题。
最土的办法是定义一个接口,把所有关心订单状态的观察者收集到一个列表里,轮询调用。这能用,但很重。其实C#早就给了更轻量的解决方案:委托。所谓委托,就是“把一个方法本身当作变量传来传去”。你不必让所有观察者实现同一个接口,只需要把各自的方法塞给调用方,时机到了再逐个触发。
委托的本质:方法也能当对象
理解委托,先要扭转一个思维定式:方法不只是写在类里的一段代码,它也可以是一个对象。
就像你给外卖小哥留了一个电话号码,小哥不会把电话本身带走,只会把这个号码记下来。到了饭点,他按号码打过去。委托就是这个“号码”,它记录了“哪个对象上的哪个方法”,别人需要调你的方法时,不需要知道你姓甚名谁,只需要持有一个委托实例。
在C#里定义一个委托,本质是定义一种“方法签名规范”:
public delegate void NotifyHandler(string message);这句声明代表:所有符合“有一个string参数、无返回值”的方法,都可以被赋值给 NotifyHandler 类型的变量。于是可以这样用:
NotifyHandler handler; handler = Console.WriteLine; handler("订单已完成");Console.WriteLine 是一个接收 string 参数、无返回值的方法,它和 NotifyHandler 的签名完全匹配。这种直接拿方法名赋值给委托类型的写法,叫“方法组转换”,从 C# 1.0 一直保留到今天。
委托类型和实例的关系,和类与对象非常像。委托类型好比一份岗位JD,定义了这个职位需要哪些技能;委托实例则是实际签下的员工,背后绑定了具体的人和方法。
别再造轮子了:Action 与 Action 的日常用法
C# 1.0 时代,用委托就得写一大堆 delegate 声明。比如上面那个 NotifyHandler,即使全项目里只有一处用,也要单独声明一次。写多了很繁琐。
后来.NET框架内置了一批通用委托类型,其中最常用的就是 System.Action:
| 委托类型 | 参数 | 返回值 |
|---|---|---|
| Action | 无参数 | void |
| Action | 1个参数 | void |
| Action<T1, T2> | 2个参数 | void |
| Action<T1, T2, T3> | 3个参数 | void |
| ... | ... | void |
从声明到调用,Action 是这样用的:
Action<string> log; log = msg => Console.WriteLine($"[LOG] {msg}"); log("hello world");lambda 表达式(=> 这种写法)在这里就是创建一个没有名字的委托实例,相当于“顺手注册了一段匿名逻辑”。你会发现,带委托的代码几乎都被 lambda 占满了,这正是 Action 配合现代C#写法的自然状态。
Action 与 Action 如何选
简单判别方法:事件或回调本身是否需要传递一条消息。
- 只需要告诉外界“事情发生了”,用 event Action。比如商品上下架、用户登录状态切换。
- 需要把具体内容跟着事件一起发出去,用 event Action 。比如日志消息、异常信息、某段文本通知。
不要动不动就定义自己的 delegate 类型,除非这个委托类型要在多处复用、且有明确的业务语义。否则直接用内置的 Action/Func,代码读起来干净很多。
顺带看懂 Func
和 Action 肩并肩的还有 Func,它是有返回值的委托。Func 代表“无参数、返回 string 的方法”,Func<string, int> 代表“接收 string、返回 int 的方法”。通常用于需要拿到执行结果的地方,比如查询数据、计算处理。因为本文集中在 void 回调场景,主要都是 Action 的活儿。
事件对 Action 泛型的应用很常见,尤其是写网络通讯程序时,收到消息、解析消息、上报状态,用 event Action 一条链下来非常顺。这也是上位机、服务端开发里普遍存在的写法。
event:一种被加了“只读权限”的委托
很多人面试时会说“事件就是委托的一种”。这句话没错,但不严谨。更准确的说法是:事件是一种基于委托的“受限引用”。它对外只暴露了订阅(+=)和退订(-=)两个入口,把替换、调用、清零的权利全部收归类自己。
没加 event 的 public 委托字段有多危险
先看反面教材:
public class Publisher { public Action<string> Notify; }这个字段是 public 的,外部随便玩:
var p = new Publisher(); p.Notify = Console.WriteLine; // 合法:整体替换 p.Notify += msg => Console.WriteLine(msg); // 合法:追加 p.Notify("直接触发"); // 合法:任何人都能主动点火 p.Notify = null; // 合法:直接把监听列表清空看出问题了吗?事件发布者原本应该掌握“何时触发”的决定权,结果外界可以把整个通知管线复位,甚至绕过内部逻辑直接触发。这在多模块协作项目里等于把钥匙发给了所有人。
加了 event 之后编译器干了什么
同样的字段,加一个 event 试试:
public class Publisher { public event Action<string> Notify; }此时外部仍然可以 += 和 -=,但尝试触发或者赋值,编译直接报错:
var p = new Publisher(); p.Notify += Console.WriteLine; // OK p.Notify("直接触发"); // 编译错误:事件只能出现在 += 或 -= 左边 p.Notify = Console.WriteLine; // 编译错误:不能对事件直接赋值 p.Notify = null; // 编译错误编译器为“event 字段”做的事,等价于这样的代码:
private Action<string> _notify; public event Action<string> Notify { add { _notify += value; } remove { _notify -= value; } }也就是说,event 的本质是:一个私有委托字段 + 一对 add/remove 访问器。类外面的人只能通过 add/remove 调整监听列表,根本碰不到那个真正的委托实例。这也是为什么我们常说“事件是安全的委托”。
触发权在类内部,订阅权在外部
事件配合方法使用时,正确的姿势是:
public class Publisher { public event Action<string> Notify; public void Run() { // 业务逻辑...... Notify?.Invoke("任务完成"); } }只有在类内部才可以调用 Notify(),因为只有自己知道业务到什么节点了、该不该通知。外面的人负责订阅,类负责触发。各自守好自己的边界。
这里有个容易忽略的细节:调用事件前为什么要判空或使用 ?.Invoke?因为事件可能一个订阅者都没有,此时 Delegate 是 null,直接 Invoke 会抛 NullReferenceException。Notify?.Invoke("...")的意思是“如果有订阅者才触发”,是防御空引用的标准做法。
自定义 add/remove 的进阶用法
默认的 event 字段用法已经能满足日常需要。但有时你需要自己做点手脚,比如加日志:
private Action<string> _notify; public event Action<string> Notify { add { Console.WriteLine("添加订阅者"); _notify += value; } remove { Console.WriteLine("移除订阅者"); _notify -= value; } }这个技巧在调试事件泄漏问题时特别好用,能从 add/remove 的日查看谁订阅了、谁忘记退订了。不过注意:一旦自定义 add/remove,就是自己接管了委托字段的管理,线程安全问题也变成了你的责任,因此不确定没必要别乱加。
实战:用 event Action 搭一个多订阅者通知器
把前面讲的东西拼起来,看一个完整案例。需求很简单:有一个消息中心,任何模块都可以向它投稿,所有订阅者都能收到同一条消息。
最小实现:发布器与订阅者
public class MessageCenter { public event Action<string> MessagePublished; public void Publish(string message) { MessagePublished?.Invoke(message); } }订阅端可以同时挂好几个订阅者:
var center = new MessageCenter(); center.MessagePublished += msg => Console.WriteLine($"[控制台] {msg}"); center.MessagePublished += msg => File.AppendAllText("app.log", msg + Environment.NewLine); center.MessagePublished += msg => Debug.WriteLine($"[调试] {msg}"); center.Publish("系统启动成功");运行后三个订阅者都会收到“系统启动成功”,这就实现了观察者模式。发布者不需要知道订阅者具体是谁,哪怕是后来加的第100个订阅者,也不用改发布者代码。这正是当初那个订单项目重构后最想达到的效果。
event Action 与 event Action 的分工
如果用一个不带参数的 event Action,消息中心只能告诉外部“有消息发布了”,至于消息内容是什么,还得去别的地方取。带 string 参数的版本,则把内容本身打包进了事件参数里。
平时写业务事件,建议优先按“参数即承载物”来设计:
- event Action:像门铃响,只关注“响了”这一事实。
- event Action :像来了一封短信,不光知道“来了”,还知道写了什么。
如果以后要承载更复杂的结构体,就把 string 换成对应的数据类,比如 event Action 。原理完全一致,只是参数类型不同。
多播委托异常会中断链条
这里有个绝大多数新手不知道的坑。事件背后是委托的多播调用列表,默认是“一个接一个执行”。如果某个订阅者的处理方法抛了异常,后面的订阅者全都不会再执行:
center.MessagePublished += msg => throw new Exception("第一条就炸了"); center.MessagePublished += msg => Console.WriteLine("这条永不执行");运行后只看到异常,第二条订阅者根本没机会执行。因为多播委托的 Invoke 是按列表顺序循环调用的,任何一个节点抛异常,整个调用就中断了。所以发布者不能用天然的多播委托一穿到底,至少要评估订阅者的健壮性。
用 GetInvocationList 逐个安全触发
解决办法也很简单:取委托的调用列表,逐个遍历,自己包 try-catch。
public void Publish(string message) { if (MessagePublished == null) { return; } foreach (Action<string> handler in MessagePublished.GetInvocationList()) { try { handler(message); } catch (Exception ex) { Console.WriteLine($"订阅者执行失败:{ex.Message}"); } } }GetInvocationList() 返回的是一个 Delegate 数组,每个元素就是一个订阅者方法。手动逐个调用,并把每段逻辑隔离在 try-catch 里,这样即使某一个订阅者炸了,也只是记录一条错误,不会影响其他订阅者。
这个设计在写插件、脚本、消息总线时几乎是必选项。稳健的发布器不应该为一个订阅者的崩溃断电。
重复订阅这个隐蔽问题
还有一个很隐蔽的问题:+= 同一个方法多次,同一个订阅者会被执行多次:
center.MessagePublished += OnMessage; center.MessagePublished += OnMessage; // 发布时 OnMessage 会跑两次解决办法是订阅前先减去一次:
center.MessagePublished -= OnMessage; center.MessagePublished += OnMessage;这个方法很实用,尤其是界面刷新、定时器重挂事件时,能避免事件积少成多,造成性能下降甚至逻辑错乱。如果实在担心忘记退订,可以封装一个 Subscribe 方法,内部自动执行“先减后加”。
跨线程回调:在 UI 程序里用 Action 的正确姿势
委托和事件在后台线程配合 UI 的场景是最容易被绊倒的。比如一个串口程序,后台线程不断收到数据,希望通过 event Action 把数据交给界面刷新。
为什么后台线程更新控件会抛异常
WinForms 或 WPF 里,UI 控件只能在 UI 线程上操作。后台线程尝试改控件的 Text 属性,会抛 InvalidOperationException:跨线程操作无效,从不是创建控件的线程访问它。
事件本身不会帮你切换线程,订阅者在哪里执行,取决于发布器是在哪个线程调用的 Invoke。发布器在后台线程,所有订阅者就在后台线程执行。所以要么发布器负责调度线程,要么订阅者自己切线程,不能指望事件框架帮忙。
用 SynchronizationContext 回到 UI 线程
比较通用的做法是,UI 侧在构造时捕获当前的同步上下文,然后把回调塞回去:
public partial class MainForm : Form { private readonly SynchronizationContext _context; private readonly MessageCenter _center; public MainForm() { InitializeComponent(); _context = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); _center = new MessageCenter(); _center.MessagePublished += msg => { _context.Post(_ => UpdateLabel(msg), null); }; } private void UpdateLabel(string msg) { lblMsg.Text = msg; } }SynchronizationContext.Current 在 UI 线程获取到的就是“能把代码送回 UI 线程”的上下文。Post 表示异步投递,不会阻塞发布器所在的线程。这样即使发布器在串口后台线程里调用事件,订阅者最终也能安稳地回到 UI 线程执行。
两套对应写法:WinForms 与 WPF
如果不想依赖 SynchronizationContext,也可以直接用控件自己的调度器。
WinForms:
_center.MessagePublished += msg => { lblMsg.BeginInvoke(new Action(() => lblMsg.Text = msg)); };WPF:
_center.MessagePublished += msg => { Dispatcher.BeginInvoke(() => lblMsg.Text = msg); };两个思路本质相同:把更新动作转移给控件归属的线程执行。区别只在于调度API的名字。能理解这一点,在哪个框架里都不慌。
实际项目中,我会更推荐 SynchronizationContext 的做法。因为界面层不关心具体是哪个控件,只需要知道“我要回UI线程”,上下文抽象了一层,在单元测试里也很容易替换。
面试脱口而出版:委托与事件的区别怎么答
“C#里什么是委托?”“委托和事件什么关系?”——这两道题面试出现频率极高。每次听到有人当场背诵定义,我都能感觉到面试官不太买账。背定义不如讲结构。
30秒版本
委托是一种类型,用来描述方法的签名;委托的实例可以保存一个或多个方法,然后在需要的时候调用。事件是建立在委托基础上、带有发布/订阅语义的受限委托:外部只能通过 += 和 -= 来订阅或退订,不能直接触发,也不能整体替换。
3分钟版本:设计意图加代码佐证
先把一个最简单的发布器写在纸上:
public class Button { public event Action Click; public void OnClick() { Click?.Invoke(); } }然后解释:Click 是一个事件,外部说“我要订阅”就是用+= 你的方法;事件被触发时,也就是按钮内部调用 Invoke 时,订阅的方法才会执行。这背后的关键是触发权的归属:只有 Button 自己能决定什么时候触发 Click,外部无法强行触发,也无法把 Click 整个重置为别的方法。
再补一个对比点:去掉 event,只写public Action Click;,外部就能button.Click = null;甚至button.Click();主动触发,设计就崩了。所以事件不是语法噱头,而是一种访问边界控制。
别和前端“事件委托”混淆
有一个概念坑是跨语言的。前端JavaScript里有个“事件委托”,指的是利用DOM事件冒泡,把子元素的事件处理交给父元素统一处理。这是完全不同的东西。
C#的委托与事件是两个层面的概念:
- 委托:一种方法类型/方法引用的容器。
- 事件:一种基于委托的发布/订阅机制。
- 前端的“事件委托”:一种利用事件冒泡的页面优化技巧。
面试时如果被问“事件委托”,先确认对方问的是C#里的 delegate/event,还是DOM里的事件代理。把两者混在一起讲,容易翻车。
常见追问
面试官经常追着问场景题,比如“你要设计一个日志中心,多个模块要订阅日志,用事件还是回调?”答案明显是事件。因为日志中心是中心化的发布方,多个模块独立订阅,而且不希望外部随便调用日志中心内部逻辑。这时public event Action<string> LogReceived;是最自然的模型。
还有一个高频追问是“多播委托如何取返回值”。委托支持多个返回值,但普通 Invoke 只返回最后一个方法的结果。完整做法是拿到 GetInvocationList() 逐个调用,分别拿返回值:
Func<int>[] handlers = ...; // 类似思路 foreach (var handler in someMulticast.GetInvocationList()) { var result = ((Func<int>)handler)(); Console.WriteLine(result); }这条我已经在一线项目中见过太多次了,回答出来很加分。
这条路上我踩过的坑
最后集中说几个长期实践中容易踩的坑,每一个我都在真实项目里付出过代价。
事件订阅导致的内存泄漏
这是最阴的一个坑。事件从发布器引用着订阅者:发布器是长期存在的单例或全局对象,订阅者是临时创建的窗体,连续打开关闭几十次,旧窗体一直被事件源引用,永远无法被垃圾回收。表现是内存只升不降。
预防思路很直白:
- 临时订阅者在销毁时记得
-=退订。 - 订阅方法里不持有大对象、长生命周期对象。
- 实在不好管理的,使用弱事件模式(WeakEvent)。
如果用的是我们前面自定义的 add/remove,还能顺手在 remove 里打一条日志,排查泄漏时能直接定位。
匿名 lambda 回调的移除问题
很多新手写出来这样的代码:
center.MessagePublished += msg => Console.WriteLine(msg);后来想退订,却找不到方法名。匿名 lambda 没有名字,你不可能在另一个地方再把同一个匿名方法退订。正确做法是把回调方法单独声明成普通方法,或者先用变量保存委托:
Action<string> handler = msg => Console.WriteLine(msg); center.MessagePublished += handler; center.MessagePublished -= handler;不是不能用 lambda,而是用之前要想清楚“以后还需不需要退订”。需要退订的场景,尽量不写匿名方法。
async void 事件处理器的吞异常特性
把事件处理器直接写成 async void 是C#里相当容易犯的错:
center.MessagePublished += async msg => { await Task.Delay(100); throw new Exception("炸了"); };async void 方法的异常不会沿调用栈传回发布器,而是直接进入线程池级的未观察异常处理,相当于被悄悄吞掉了。如果你用 GetInvocationList 逐个 try-catch 也拦不住它,因为异常出现在 await 后的异步上下文中。
如果必须用异步处理器,建议不要在事件处理器里直接抛异常,显式捕获并记录日志才是稳妥方案。
我个人现在的习惯是:凡是设计对外发布的类,一律用 event 而不是公开委托字段;凡是发布器要支持大量订阅者,一定用 GetInvocationList 做隔离;凡是事件回调涉及UI更新,第一时间考虑 SynchronizationContext,而不是等运行时报了跨线程错误才去补救。这些习惯看起来不起眼,真正把它们贯穿到每个项目里,能帮你躲开一多半线上事故。