4. Observer
4. Observer:让主体同步通知订阅者,同时显式管理顺序、重入和生命周期,通过可复位因果实验和反例证据验收。
4. Observer
学习目标
- 能画出主题-观察者通知流
- 能解释重入与生命周期的坑
- 能选择链表/对象池实现观察者列表
为什么"4. Observer"从问题证据开始
你的游戏里有个成就系统:玩家击杀第一个敌人时弹出"初出茅庐"成就。最直觉的做法是在击杀代码里直接调用 achievements.unlock("first-blood")。这段代码今天能跑,但明天你遇到三个需求就卡住了——新系统要监听同一事件(除了成就,音效、UI、统计都要知道"击杀"发生)、被通知方要随时增减(版本更新加新监听器)、事件源不想知道谁在听(击杀逻辑不该关心成就、音效、UI 的存在)。
本页把"无模式基线"定义为"事件源直接调用通知方"的代码,然后引入观察者模式作为候选机制,验证它是否真的让"新增监听方、移除监听方、事件源无感"变成可实现。通过条件是事件源代码在监听者增减时完全不变——而不是通知逻辑散落各处。
来源、版本与独立重写边界
本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。
本章机制与术语
理解 Observer 需要四个核心概念:
-
↡:观察者注册到主题。
-
↡:观察者离开主题。
-
↡:notify 中修改列表的坑。
-
↡:事件的来源,维护观察者列表,事件发生时调用每个观察者的 notify。
-
↡:对事件感兴趣的接收方,注册到主题后被动接收通知。
-
订阅/退订(attach/detach):观察者加入或离开主题列表的操作——这是"增减监听方不影响事件源"的关键。
-
↡:主题遍历观察者列表逐个通知的过程——顺序、重入与生命周期问题都发生在这里。
四个概念共同约束"让主体同步通知订阅者,同时显式管理顺序、重入和生命周期":任何结论都必须回到"事件源是否无感、通知顺序是否可控、重入是否安全"三个可观察量。
第 1 / 5 步 · ① Subject 主题:维护观察者列表
主题变化自动通知所有观察者:UI、成就、分析、音频各自响应,互不干扰。
💡 对着动画看:动画中 Subject 在顶部,四个观察者(UI/成就/分析/音频)在底部逐个订阅,最后 notify() 广播箭头同时点亮。注意观察者是被动的——它们不主动轮询,而是等主题推消息。这就是"事件源不知道谁在听"的直观呈现。读"观察者与主题"两节时回到这张图,体会订阅/通知的方向。
官方结构逐项深读
4. Observer
Observer 的主张是:让事件源与事件接收方通过"订阅"解耦。主题(subject)不知道具体有哪些观察者,只维护一个接口列表;观察者通过 attach 注册、detach 退订;事件发生时主题遍历列表逐个通知。这样新增监听方 = 新增一个 observer 并 attach,事件源代码一行不改——这是"开闭原则"在事件驱动场景的直接体现。
Achievement Unlocked
开篇的成就案例:击杀事件需要同时通知成就、音效、UI、统计四个系统。直接调用方案把四个调用硬编码进击杀逻辑,每加一个监听系统就要改击杀代码。Observer 的方案是:击杀逻辑只维护"谁订阅了击杀事件"的列表,事件发生时广播——四个系统各自订阅,击杀代码永远不认识它们。新增系统只需"订阅",与击杀逻辑完全隔离。
How it Works
工作流程三件套:attach(注册)、notify(广播)、detach(退订)。观察者创建时 attach 到感兴趣的主题;主题状态变化时 notify 遍历列表逐个通知;观察者销毁前必须 detach,否则主题会持有悬空引用。顺序问题随之而来:列表遍历时如果观察者自己 detach 或被销毁,遍历就会踩空——这就是后文"剩余问题"里要处理的坑。
The observer
观察者的形态:一个接口(或回调),暴露 onNotify(event) 方法。游戏里常用事件枚举区分通知种类——onNotify(ENEMY_KILLED, actor),观察者内部 switch 决定处理哪个事件、忽略哪个。关键设计决策:观察者不该知道主题的内部实现,只消费主题广播的"发生了什么";想知道"为什么发生"就过头了,那会让观察者与主题耦死。
The subject
主题的形态:一个列表 + 一个广播方法。attach(observer) 把观察者加进列表,notify(event) 遍历列表逐个调 onNotify。主题不关心观察者如何处理事件,只负责"事件发生了,逐个告诉大家"。设计要点:主题列表的遍历顺序就是通知顺序,如果某个观察者的处理会修改列表(attach/detach 别的观察者),遍历就会出问题——重入是观察者模式最经典的坑。
Observable physics
物理引擎是观察者模式的大户:物理步进每帧产生大量事件(碰撞、进入区域、力变化),而关心这些事件的系统各不相同——音效要"碰撞声"、玩法要"得分"、UI 要"抖动"。让物理引擎认识所有关心者是不现实的,观察者让它只维护订阅列表,碰撞发生时广播。物理引擎从此不认识音效、UI、玩法——这正是解耦的价值。
It's Too Slow
观察者的第一个现实问题是性能:每次事件都要遍历整个列表逐个通知,如果通知链很长(观察者又触发新事件),开销可观。作者的反驳是:先测量。多数游戏事件频率不高(击杀、升级、掉落),遍历几十个观察者的开销远小于一次物理碰撞计算。只有在高频事件(每帧每对象的碰撞通知)上才需要优化——这引出了后面的链表、对象池和事件队列方案。
It's too fast?
反过来,通知可能"太快"——重入问题:主题在 notify 遍历中,某个观察者又触发了一个新事件(或修改了列表),导致遍历行为不确定。经典案例:成就观察者收到"击杀"通知后要解锁成就,解锁又触发"成就达成"事件,主题正在遍历的列表被修改——轻则漏通知,重则崩溃。解决方案:延迟通知(先收集再派发)或用可重入的遍历方式。
It Does Too Much Dynamic Allocation
如果观察者列表用链表且每次 attach 都分配节点,高频订阅/退订会产生大量堆分配——在游戏这种对 GC 敏感的环境里是灾难。作者给的解法是对象池 + 内嵌节点:观察者对象自带链表节点,attach 时把节点链进主题列表,节点从池里复用,消灭动态分配。这是观察者模式与 Object Pool 模式的经典搭配。
Linked observers
用链表替代数组实现观察者列表,收益是 O(1) 的插入/删除:观察者持有自己的节点,attach 把节点链进链表,detach 直接摘下节点——不需要数组的搬移或空闲槽查找。代价是列表不再是连续内存,遍历的缓存友好性略差。在"订阅/退订频繁、列表短"的场景,链表是正确选择。
A pool of list nodes
对象池 + 内嵌节点的组合拳:观察者对象里内嵌一个链表节点(而不是持指针),attach 时把节点地址链进主题链表。节点生命周期与观察者绑定,观察者销毁即节点回收——不需要额外的节点分配与释放。这让"订阅"变成纯指针操作,动态分配彻底消失,在 GC 敏感的游戏环境中意义重大。
Remaining Problems
即使有了链表和对象池,两个问题仍在:一是销毁顺序——观察者销毁时如果没 detach,主题链表里残留悬空指针,下次 notify 就崩溃;二是通知顺序的耦合——观察者隐式依赖 notify 的顺序(A 在 B 之前被通知),顺序一变行为就变。这些都是"用模式要付的学费",必须显式管理。
Destroying subjects and observers
生命周期管理是观察者模式最现实的坑:谁先销毁?如果观察者先销毁,必须在析构里 detach 自己(否则主题悬空);如果主题先销毁,观察者持有主题引用也要失效。作者给的规则是:销毁方负责解绑——观察者析构时主动 detach。无 GC 语言(C++)必须手工遵守,有 GC 语言(Java/C#)要小心观察者被 GC 回收后列表里残留的引用。
Don't worry, I've got a GC
有垃圾回收的语言看似省心——观察者被回收时列表引用还在,但 GC 不会通知主题"这个观察者死了"。所以要么观察者显式 detach,要么接受"主题短暂持有死观察者"并在 notify 时检查。GC 不解决悬空引用,只改变它的形态——从"野指针崩溃"变成"空引用异常",还是要程序员管。
What's going on?
一个经典陷阱:观察者用回调闭包捕获了过期的状态。比如 UI 观察者捕获了某个按钮引用,按钮销毁后通知到达,回调访问已销毁对象。作者的提醒:观察者捕获的外部状态必须与被观察主题的生命周期一致,否则就是定时炸弹。这也是为什么"观察者应只消费事件数据、不捕获可变外部状态"是安全写法。
Observers Today
现代游戏引擎中观察者无处不在:Unity 的 C# 事件/委托、Unreal 的委托绑定、Godot 的 signal——都是观察者模式的语言化变体。它们统一解决了"事件源与接收方解耦",但顺序、重入、生命周期这三个坑依然由开发者负责。看懂本章的原始模式,就能理解这些引擎特性的设计动机与使用边界。
Observers Tomorrow
观察者模式的演化方向:事件队列(解耦"产生"与"处理"的时间——事件先入队再集中派发)、反应式编程(数据流自动传播,观察者不再手动 attach)、ECS 消息(组件间通过共享事件总线通信)。但万变不离其宗:解耦事件源与接收方,同时显式管理顺序、重入与生命周期——这三个问题在任何演化形态里都还在。
可迁移实现或计算骨架
initial state -> 观察者 attach -> 主题状态变化 -> notify 遍历 -> 各观察者 onNotify
fault injection -> 观察者销毁后未 detach,或 notify 中修改列表
pass condition -> 事件源代码在监听者增减时完全不变,通知顺序与重入受控
reset -> initial state对应实现要点:主题维护观察者接口列表;attach/detach 走链表节点(可配合对象池消灭分配);notify 遍历时对"列表可能被修改"做防御(延迟派发或快照);观察者析构强制 detach。该骨架只保存实验合同;真实项目还要固定事件种类、订阅频率与生命周期规则,并保留基线实现以便回退。
本章练习与节点验证矩阵
练习
问题 1:解耦验证。
操作:在动画中新增第 5 个观察者(如"录像系统"),观察 Subject 代码是否要改
问题 2:顺序实验。
操作:让观察者 A 在 notify 中 attach 观察者 C,观察通知顺序
问题 3:悬空引用注入。
操作:模拟观察者销毁但未 detach,然后触发 notify
问题 4:生命周期对比。
操作:对比"析构自动 detach"与"忘记 detach"两种实现
术语复核
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 主题
- 观察者
- 通知
- 订阅
- 退订
- 重入
练习答案参考
练习判据即答案:每个练习的"验证判据"列给出了通过标准——先自己动手,再对照判据核验。
术语复核与本章回顾
掌握"4. Observer"意味着能从"事件源直接调用通知方让新系统加入困难"出发,解释主题、观察者、订阅/退订与通知四者的关系,再用"事件源是否无感、通知顺序是否可控、重入是否安全"三个可观察量推翻或保留实现。若三个判据不能同时满足,本章仍未通过。
一句话回顾:事件源只喊一嗓子"有情况!",谁感兴趣谁自己报名——主题管列表,观察者管处理,两者靠订阅接口隔开,从此新增系统不动事件源一行代码。