☰
订阅、通知与中转:谁通知谁,谁又持有谁
2026/10/14 2:48:42 网站建设 项目流程

订阅、通知与中转:谁通知谁,谁又持有谁

2026-10-13 · 概念 + 语法。今天两块:① 讲两种「通知」型设计模式——观察者与中介者;② 从另一条线里提炼的概念与语法:判断依据别被自己擦掉、强引用 / 弱引用、订阅者到底是「对象」还是「方法」。
全文脱敏:真实类名 / 方法名 / 产品名 / 厂商名一律泛化;内部流程的步骤名写成泛化词;不含具体耗时 / 次数 / 比例。


引子

程序里天天在干一件事:这边变了,通知那边。今天从一个"按一下按键就要触发某动作"的例子出发,把它一路拆开——它串起了两种「通知」的姿势,也带出一批天天在用的语法。

先把两个词立住(后面反复用):

  • 发布者:有事发生时负责"喊一声"的那个对象。
  • 订阅者:想被通知、于是提前"挂个耳朵"上去的那个对象。

一、观察者模式(Observer):订阅者,直接挂在发布者身上

概念:一对多的「订阅—通知」。发布者状态一变,就挨个通知所有挂上来的订阅者。

发布者 / | \ 通知 / | \ 通知 / | \ 订阅者A 订阅者B 订阅者C

关键在方向不对称:

  • 订阅者认识发布者——是它主动去挂的:发布者.某事件 += 我的处理函数
  • 发布者不认识订阅者——它只管"喊一声",不知道、也不在乎此刻谁在听

类比:订报纸。你(订阅者)知道报馆在哪、去订了(相当于+=);报馆(发布者)根本不需要认识你——它每天照印照送,谁订谁收,没订的一个字收不到。

语法:C# 里,观察者模式的"语言级实现"就是事件event,底层是委托delegate:

// 发布者这侧:声明一个"事件"——一颗别人可以"挂上去"的钩子eventAction<数据,宽,高,帧号>收到一帧;// ↑关键字 ↑ Action<...>:内置委托类型,表示"一段没有返回值、收这几个参数的函数"
// 订阅者这侧:把"我的一个方法"挂上去发布者.收到一帧+=我的处理方法;// ↑ "+=" = 订阅;对应的 "-=" = 退订

判据:看到+=挂在一个具体对象上(某个设备.某事件 += …),就是观察者模式。


二、中介者模式(Mediator):大家都挂到"一个中枢"

概念:一群对象如果彼此都要互相通知,逐个+=会织成一张"你扯我、我扯你"的网。中介者模式换个思路——谁都不认识谁,全都只认识"中枢":有事发给中枢,由中枢转发。

对象A ──┐ 对象B ──┼──► 中 枢 ──► 转发给所有"关心这类事"的对象 对象C ──┘ (对象之间,一条线都没有)

类比:机场塔台。每架飞机只跟塔台通话,不直接和别的飞机通话;谁让谁、谁等谁,塔台统一调度。要是几十架飞机两两直接喊话,就吵成一锅粥了。

语法:中介者最漂亮的一点——用「消息的类型」当路由键。发的时候不用告诉中枢"发给谁",中枢按消息是哪一个类去派发:

// 收:注册时写清楚"我只关心『删除消息』这一类事"中枢.注册<删除消息>(this,收到删除时就干这个);// 发:丢一个消息进去,类型本身就是路由键中枢.发送(new删除消息());// ↑ 谁会收到?——所有注册过「删除消息」的人;发的人根本不知道都有谁

"消息"往往是个空类——它不装逻辑,只是一个信封,全部意义就是"这是哪一类事":

class删除消息{}// 空空如也;它的"类型"才是关键

类比:群公告。你在群里发一句"今天放假",不用 @ 每个人;关心的人自己会看。你发出去的是"内容 + 它属于哪一类通知",群(中枢)负责让对的人看到。

判据:看到+=/注册挂在一个中枢对象上(而不是具体设备),就是中介者。


三、两者对照:差在「谁持有谁」

观察者 Observer中介者 Mediator
订阅挂在具体对象上中枢上
订阅者与发布者订阅者认识发布者双方互不认识,只认识中枢
引用的松紧+=是强引用订阅者中枢对订阅者持弱引用
适合场景一对一 / 一对多,关系清楚多对多,容易乱成一团时

一张纸的判据:看+=(或"注册")挂在谁身上——挂着的是具体对象→ 观察者;挂着的是中枢→ 中介者。


四、概念澄清三连(今天被反复追问出来的点)

这三条是今天真正卡住的地方,单独拎出来:

  1. 弱引用,弱的是"接收方",不是"消息"。
    订阅时被"轻轻拉住"的是接收方那个对象;而消息是一次性的——发完就丢,没人留着它。
    类比:你订阅的是"报纸这个长期服务"(接收方是长期的);而每一期报纸本身是消耗品,看完就扔(消息是一次性的)。

  2. "订阅者"是对象,不是方法、也不是类。
    发布者.某事件 += 处理方法其实等价于+= this.处理方法——真正订阅的是"拥有这个方法的那个对象"。同一个类 new 出两个对象,各挂各的,会各自收到通知。
    类比:同一款手机(类)中,是你这一台(实例)下载了某个 App、订阅了推送——不是"这款手机"订阅的,也不是"下载这个动作"订阅的。

  3. 强引用 vs 弱引用,正是 Observer 与 Mediator 的分水岭。

    • 事件+=:强引用——只要你挂在事件上,订阅者就不会被回收;挂了不退订,就是内存泄漏(对象本该没人用了,却因为还挂在事件上一直活着)。
    • 中枢注册:弱引用——订阅者若没别处引用,会被回收;中枢自动清掉那条订阅,不会留下"指向死对象的野回调"。

一句提醒:弱引用是兜底,不是许可。它保证"对象没了、订阅自清",但不保证"订阅时机对"。若某个对象被复用(一直有强引用),那它这条订阅就始终有效——此时"只注册、不退订"反而是刻意的选择:退了订,复用后再进来就没人接了。


五、通用教训:别让"达成目标"的动作,擦掉"判断目标是否达成"的依据

概念:你想判断"某件事到底成没成",总要有个依据(一个标志、一条记录)。坑在于——达成这件事的那个动作,往往顺手把这个依据清掉了。于是你的判断永远说不成(或永远说成),依据形同虚设。

实例:判断"设备是否已进入工作态",原先靠一条记账(上次设的参数 + 一个版本号)。可"让设备进入工作态"这个动作,自己就会作废这条记账(清空它、版本号 +1)。等下次来查,记录早被擦掉 → 判定永远为假→ "别重复劳动"的那道优化从未生效。

记账 := 上次设的值 + 版本号 # 想拿它判断"是否已就绪" ... 进入工作态() # ◄ 这个动作会顺手 作废 上面的记账 ... 判断是否就绪() ──► 永远读不到记账 ──► 永远判"没就绪" ──► 每次都白干一遍

正解:判断"就绪没就绪",别问记账,直接问事实来源本身——回读设备当前的真实状态:

状态 := 回读设备() # 直接问设备:你现在是什么态? 就绪 := (状态 == 工作态)

类比:你贴了张便利贴"已开过会",用来判断"这还会用不用再开"。可"开会"这个动作,散会时顺手把便利贴撕了。下次一看——没便利贴 → 以为没开过 → 又开一次,无限循环。要判断"开没开过",别信便利贴,去查会议记录(事实来源)。

判据:写任何"要不要补做某事"的判断前,先问一句——这个判断依赖的状态,由谁清除、什么时候清?如果清除它的正是"你想避免重复做的那件事"本身,那这个判据一定废。


附:几个实用语法点(详讲)

1. 委托与事件:delegate/event/Action<...>/EventHandler<T>/+=

  • 委托delegate= “一种函数的类型”。把一个方法当成值来传、来存,靠的就是它。
  • Action<...>/Func<...>= 框架内置的两个委托,省得你每次自己定义。Action收参数、无返回值;Func收参数、有返回值。
    Action<int>a;// 收一个 int,返回 voidFunc<int,string>f;// 收一个 int,返回一个 string
  • event= 在委托外面加个"安全壳":外面的人只能+=/-=(订/退),不能直接触发或覆盖它。只有类自己内部能触发。
    // 类内部触发(喊一声)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、特性与源生成。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询