订阅、通知与中转:谁通知谁,谁又持有谁
2026-10-13 · 概念 + 语法。今天两块:① 讲两种「通知」型设计模式——观察者与中介者;② 从另一条线里提炼的概念与语法:判断依据别被自己擦掉、强引用 / 弱引用、订阅者到底是「对象」还是「方法」。
全文脱敏:真实类名 / 方法名 / 产品名 / 厂商名一律泛化;内部流程的步骤名写成泛化词;不含具体耗时 / 次数 / 比例。
引子
程序里天天在干一件事:这边变了,通知那边。今天从一个"按一下按键就要触发某动作"的例子出发,把它一路拆开——它串起了两种「通知」的姿势,也带出一批天天在用的语法。
先把两个词立住(后面反复用):
- 发布者:有事发生时负责"喊一声"的那个对象。
- 订阅者:想被通知、于是提前"挂个耳朵"上去的那个对象。
一、观察者模式(Observer):订阅者,直接挂在发布者身上
概念:一对多的「订阅—通知」。发布者状态一变,就挨个通知所有挂上来的订阅者。
发布者 / | \ 通知 / | \ 通知 / | \ 订阅者A 订阅者B 订阅者C关键在方向不对称:
- 订阅者认识发布者——是它主动去挂的:
发布者.某事件 += 我的处理函数 - 发布者不认识订阅者——它只管"喊一声",不知道、也不在乎此刻谁在听
类比:订报纸。你(订阅者)知道报馆在哪、去订了(相当于+=);报馆(发布者)根本不需要认识你——它每天照印照送,谁订谁收,没订的一个字收不到。
语法:C# 里,观察者模式的"语言级实现"就是事件event,底层是委托delegate:
// 发布者这侧:声明一个"事件"——一颗别人可以"挂上去"的钩子eventAction<数据,宽,高,帧号>收到一帧;// ↑关键字 ↑ Action<...>:内置委托类型,表示"一段没有返回值、收这几个参数的函数"// 订阅者这侧:把"我的一个方法"挂上去发布者.收到一帧+=我的处理方法;// ↑ "+=" = 订阅;对应的 "-=" = 退订判据:看到+=挂在一个具体对象上(某个设备.某事件 += …),就是观察者模式。
二、中介者模式(Mediator):大家都挂到"一个中枢"
概念:一群对象如果彼此都要互相通知,逐个+=会织成一张"你扯我、我扯你"的网。中介者模式换个思路——谁都不认识谁,全都只认识"中枢":有事发给中枢,由中枢转发。
对象A ──┐ 对象B ──┼──► 中 枢 ──► 转发给所有"关心这类事"的对象 对象C ──┘ (对象之间,一条线都没有)类比:机场塔台。每架飞机只跟塔台通话,不直接和别的飞机通话;谁让谁、谁等谁,塔台统一调度。要是几十架飞机两两直接喊话,就吵成一锅粥了。
语法:中介者最漂亮的一点——用「消息的类型」当路由键。发的时候不用告诉中枢"发给谁",中枢按消息是哪一个类去派发:
// 收:注册时写清楚"我只关心『删除消息』这一类事"中枢.注册<删除消息>(this,收到删除时就干这个);// 发:丢一个消息进去,类型本身就是路由键中枢.发送(new删除消息());// ↑ 谁会收到?——所有注册过「删除消息」的人;发的人根本不知道都有谁"消息"往往是个空类——它不装逻辑,只是一个信封,全部意义就是"这是哪一类事":
class删除消息{}// 空空如也;它的"类型"才是关键类比:群公告。你在群里发一句"今天放假",不用 @ 每个人;关心的人自己会看。你发出去的是"内容 + 它属于哪一类通知",群(中枢)负责让对的人看到。
判据:看到+=/注册挂在一个中枢对象上(而不是具体设备),就是中介者。
三、两者对照:差在「谁持有谁」
| 观察者 Observer | 中介者 Mediator | |
|---|---|---|
| 订阅挂在 | 具体对象上 | 中枢上 |
| 订阅者与发布者 | 订阅者认识发布者 | 双方互不认识,只认识中枢 |
| 引用的松紧 | +=是强引用订阅者 | 中枢对订阅者持弱引用 |
| 适合场景 | 一对一 / 一对多,关系清楚 | 多对多,容易乱成一团时 |
一张纸的判据:看+=(或"注册")挂在谁身上——挂着的是具体对象→ 观察者;挂着的是中枢→ 中介者。
四、概念澄清三连(今天被反复追问出来的点)
这三条是今天真正卡住的地方,单独拎出来:
弱引用,弱的是"接收方",不是"消息"。
订阅时被"轻轻拉住"的是接收方那个对象;而消息是一次性的——发完就丢,没人留着它。
类比:你订阅的是"报纸这个长期服务"(接收方是长期的);而每一期报纸本身是消耗品,看完就扔(消息是一次性的)。"订阅者"是对象,不是方法、也不是类。
发布者.某事件 += 处理方法其实等价于+= this.处理方法——真正订阅的是"拥有这个方法的那个对象"。同一个类 new 出两个对象,各挂各的,会各自收到通知。
类比:同一款手机(类)中,是你这一台(实例)下载了某个 App、订阅了推送——不是"这款手机"订阅的,也不是"下载这个动作"订阅的。强引用 vs 弱引用,正是 Observer 与 Mediator 的分水岭。
- 事件
+=:强引用——只要你挂在事件上,订阅者就不会被回收;挂了不退订,就是内存泄漏(对象本该没人用了,却因为还挂在事件上一直活着)。 - 中枢注册:弱引用——订阅者若没别处引用,会被回收;中枢自动清掉那条订阅,不会留下"指向死对象的野回调"。
- 事件
一句提醒:弱引用是兜底,不是许可。它保证"对象没了、订阅自清",但不保证"订阅时机对"。若某个对象被复用(一直有强引用),那它这条订阅就始终有效——此时"只注册、不退订"反而是刻意的选择:退了订,复用后再进来就没人接了。
五、通用教训:别让"达成目标"的动作,擦掉"判断目标是否达成"的依据
概念:你想判断"某件事到底成没成",总要有个依据(一个标志、一条记录)。坑在于——达成这件事的那个动作,往往顺手把这个依据清掉了。于是你的判断永远说不成(或永远说成),依据形同虚设。
实例:判断"设备是否已进入工作态",原先靠一条记账(上次设的参数 + 一个版本号)。可"让设备进入工作态"这个动作,自己就会作废这条记账(清空它、版本号 +1)。等下次来查,记录早被擦掉 → 判定永远为假→ "别重复劳动"的那道优化从未生效。
记账 := 上次设的值 + 版本号 # 想拿它判断"是否已就绪" ... 进入工作态() # ◄ 这个动作会顺手 作废 上面的记账 ... 判断是否就绪() ──► 永远读不到记账 ──► 永远判"没就绪" ──► 每次都白干一遍正解:判断"就绪没就绪",别问记账,直接问事实来源本身——回读设备当前的真实状态:
状态 := 回读设备() # 直接问设备:你现在是什么态? 就绪 := (状态 == 工作态)类比:你贴了张便利贴"已开过会",用来判断"这还会用不用再开"。可"开会"这个动作,散会时顺手把便利贴撕了。下次一看——没便利贴 → 以为没开过 → 又开一次,无限循环。要判断"开没开过",别信便利贴,去查会议记录(事实来源)。
判据:写任何"要不要补做某事"的判断前,先问一句——这个判断依赖的状态,由谁清除、什么时候清?如果清除它的正是"你想避免重复做的那件事"本身,那这个判据一定废。
附:几个实用语法点(详讲)
1. 委托与事件:delegate/event/Action<...>/EventHandler<T>/+=
- 委托
delegate= “一种函数的类型”。把一个方法当成值来传、来存,靠的就是它。 Action<...>/Func<...>= 框架内置的两个委托,省得你每次自己定义。Action收参数、无返回值;Func收参数、有返回值。Action<int>a;// 收一个 int,返回 voidFunc<int,string>f;// 收一个 int,返回一个 stringevent= 在委托外面加个"安全壳":外面的人只能+=/-=(订/退),不能直接触发或覆盖它。只有类自己内部能触发。// 类内部触发(喊一声)if(收到一帧!=null)收到一帧(数据,宽,高,帧号);// 现代写法:null 条件调用,一句顶上面两行收到一帧?.Invoke(数据,宽,高,帧号);- 常见坑:① 触发前要防"没人订阅(它是 null)",用上面两种写法;②
+=是强引用,只订阅不退订会泄漏(见 §四)。
2. 弱引用:WeakReference
普通引用是强的——“我拿着你,你就不能被回收”。弱引用正好相反:我指着你,但拦不住你被回收;你被回收后,我这个弱引用就变成"空"。
var强=某个对象;// 强引用:对象活着var弱=newWeakReference(某个对象);// 弱引用:不阻止它被回收...if(弱.IsAlive){var真=弱.Target;}// 回收了 → IsAlive 变 false为什么要它:让"长命的注册表 / 缓存 / 中枢"能间接指向"短命的对象",又不把短命对象拖成活不了也死不掉的僵尸。常见坑:别把它当"自动清理"用——你该退订还是要退订,弱引用只是最后一道兜底。
3. 泛型方法,把"类型"当键:注册<T>(...)
void注册<T>(接受者 r,处理函数 h);// T 是"占位类型",调用时才定调用注册<删除消息>(…)时,T就被定成删除消息;中枢内部拿typeof(T)当Dictionary 的键,把订阅者按"消息类型"分桶存。这就是"消息类型当路由键"的实现:发new 删除消息()→ 按删除消息这个键取桶 → 派给桶里的人。好处:编译期就检查类型,比"用字符串当键"安全得多。
4. 异步:async/await/Task/Task<T>/CancellationToken
Task/Task<T>= “一个将来会完成的操作”,T是它完成后的结果类型。async/await= 把异步写得像同步:await一个Task,就"在这里等它完成,但不占用线程"。CancellationToken= 一个"取消信号",一路传给底层操作;长任务在该停时检查它、主动退出。asyncTask<bool>查状态Async(int设定,CancellationToken 取消=default){var状态=await读设备Async(取消);// 可被取消return状态.值==设定;}- 常见坑:① 一个
async Task方法里一个await都没有——合法,但多半是疏忽(要么忘了await,要么这方法根本不该是异步的,干脆去掉async);②catch (Exception)会吞掉取消异常,想区分"真出错"和"被取消",要先单独 catch 取消异常。
5. 表达式体成员:=>
方法/属性只看一眼"算出什么"的写法,把{ return …; }压成一行:
bool就绪=>状态==工作态;// 只读属性,一行int加一(intx)=>x+1;// 方法,一行常见坑:=>是"映射到",别和赋值=、相等==看混——一个=是赋值,两个==才是比较,=>是把左边的名字"定义成"右边的表达式。
6. 空值处理:?./??/ 可空?
收到一帧?.Invoke(...);// 左边是 null → 整个表达式短路成 null,不报错var名=输入的名字??"匿名";// 左边 null → 用右边的默认值int?可能没有=null;// 值类型也能"可空"常见坑:?.只保护一步;a?.B.C里若B也可能为 null,链子会断在.C。多级就得多写?.。
7. 模式匹配:is/is not+ 变量绑定
if(数据来源isnot某类型 vm)return;// "不是某类型" → 直接返回(守卫)if(数据来源is某类型 vm){vm.干点什么();}// 是 → 顺手把 vm 绑好好处:判断 + 取用一步到位,省掉先as再判 null。常见坑:绑定出来的vm只在if的为真分支里有效,别拿到外面用。
8. 特性与源生成:[Attribute]与"帮你写代码"
特性[Attribute]= 贴在类/方法上的标记,给工具/框架看:
[某标记]privatevoid做点事(){...}// 框架看到这个标记,就替它生成配套代码有些标记(源生成器)会在编译期根据你写的方法"自动补出一段代码"——比如把一个普通方法,自动包成一个符合某接口的对象(还是接口的方法)。好处:少写样板。代价:那段代码是生成的,你在源码里搜不到——调试时容易"明明调了它,怎么找不到定义"。遇到这种’凭空冒出来’的成员,先想到’是不是被什么标记生成出来的’。
小结(今天的账)
- 观察者:订阅者直接挂在发布者上;订阅者认识发布者,发布者不认识订阅者;
+=挂在具体对象上。 - 中介者:大家都挂到中枢上,互不认识;用消息类型当路由键;
+=/注册挂在中枢上。 - 分水岭 = 引用松紧:事件
+=是强引用(不退订=泄漏);中枢注册是弱引用(自清)。 - 三连澄清:弱引用弱的是接收方不是消息;订阅者是对象不是方法/类;弱引用是兜底不是许可。
- 一条通用教训:判断"某状态成没成",别依赖会被’达成它的动作’自己擦掉的依据——去问事实来源本身。
- 语法:委托/事件(
Action/Func/?.Invoke)、WeakReference、泛型当键、async/await/CancellationToken、=>、?./??、is/is not、特性与源生成。