观察者模式 (Observer)
定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
— GoF 定义
动机
你随便往电脑上扔块石头,都能砸中一个用 MVC 架构构建的应用,而 MVC 的底层就是观察者模式。
译注:MVC(Model-View-Controller)是 70 年代由 Smalltalk 程序员发明的。Lisp 程序员大概会说他们在 60 年代就想出来了,只是懒得写下来而已。
观察者模式无处不在——Java 把它放进了核心库(java.util.Observer),C# 直接把它做进了语言(event 关键字)。它是 GoF 所有模式中知名度最高、使用最广的模式之一。但游戏开发圈有时候会奇怪地闭门造车,所以这对你来说可能全是新鲜事。需要我重新说明一下。
成就解锁
假设我们要给游戏加一个成就系统,包含几十种不同的徽章,玩家在完成特定里程碑时可以获得——比如说 “击杀 100 只猴妖”、“从桥上摔下去”、“只用一条死黄鼠狼通关一整关”。
译注:下图为 “黄鼠狼挥舞者” 徽章的示意图。作者声明:画这张图的时候绝对没有双关的意思。

![]:(images/observer-weasel-wielder.png)
这实现起来挺棘手的,因为我们的成就种类太多,触发方式五花八门。如果不够小心,成就系统的藤蔓就会缠绕到代码库的每一个黑暗角落。没错,“从桥上摔下去“确实跟物理引擎有关——但我们真的想在碰撞检测算法的线性代数中间看到一行 unlockFallOffBridge() 吗?
译注:这是个反问句。任何有自尊的物理程序员都不会让我们用“游戏玩法“这种俗物去玷污他们优美的数学。
我们想要的和往常一样:把跟游戏某个方面相关的所有代码放在同一个地方。挑战在于——成就被各种不同的游戏行为触发。怎么才能让成就代码跟所有这些行为通信,又不跟它们耦合在一起?
这就是观察者模式要解决的问题。它允许一段代码宣布“有趣的事情发生了“,而不用关心谁收到了通知。
举个例子,我们有一段物理代码负责处理重力、跟踪哪些物体安稳地躺在平地上、哪些正在坠入注定的深渊。要实现“从桥上摔下“的徽章,我们可以直接把成就代码塞进物理代码里,但那会很乱。相反,我们只需要这样做:
void Physics::updateEntity(Entity& entity) {
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if (wasOnSurface && !entity.isOnSurface()) {
notify(entity, EVENT_START_FALL); // "嘿,我不知道有没有人关心,但这家伙刚才掉下去了。你们自己看着办吧。"
}
}
成就系统把自己注册进来,这样每当物理代码发送通知时,成就系统就会收到。然后它可以检查坠落的物体是不是我们那位不太优雅的英雄,以及他坠落前站的地方是不是一座桥。如果是,就解锁对应的成就——全程物理代码毫不知情。
译注:物理引擎确实需要决定发送哪些通知,所以并不是完全解耦。但在架构设计中,我们的目标通常是让系统更好,而不是完美。
事实上,我们可以修改成就列表,甚至把整个成就系统拆掉,都不用碰物理引擎一行代码。它依然会发送通知,浑然不知已经没有人在听了。
译注:当然,如果我们永久移除成就系统,而且没有其他东西在听物理引擎的通知了,那通知代码也应该删掉。但在游戏的演进过程中,有这种灵活性总是好的。
工作原理
如果你还不知道怎么实现,从前面的描述大概也能猜出来了。不过为了让你轻松点,我快速走一遍。
观察者
我们先从那个爱管闲事的类开始——它想知道另一个对象什么时候做了什么有趣的事。这些八卦的对象由这个接口定义:
class Observer {
public:
virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) = 0;
};
任何实现了这个接口的具体类都变成了一个观察者。在我们的例子中,这就是成就系统:
class Achievements : public Observer {
public:
virtual void onNotify(const Entity& entity, Event event) {
switch (event) {
case EVENT_ENTITY_FELL:
if (entity.isHero() && heroIsOnBridge_) {
unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
}
break;
// 处理其他事件,更新 heroIsOnBridge_……
}
}
private:
void unlock(Achievement achievement) {
// 如果还没解锁就解锁……
}
bool heroIsOnBridge_;
};
译注:
onNotify()的参数完全由你决定。这就是为什么它叫观察者模式而不是“你可以直接贴进游戏的现成代码“。典型参数是发送通知的对象,以及一个通用的“数据“参数用来塞其他细节。如果你用的语言支持泛型或模板,你可能会用上,但根据具体用途定制也完全没问题。
被观察者
通知方法是被观察的对象调用的。GoF 管这个对象叫“被观察者“(Subject)。它有两个职责。
第一,它持有一个观察者列表——这些观察者正耐心地等待着它的消息:
class Subject {
private:
Observer* observers_[MAX_OBSERVERS];
int numObservers_;
};
译注:真实代码中你会用动态大小的容器而不是一个死板的数组。这里我用最基础的写法,是为了照顾那些从其他语言来、不熟悉 C++ 标准库的读者。
关键点在于,被观察者暴露了一个公开的 API 来修改这个列表:
class Subject {
public:
void addObserver(Observer* observer) {
// 加到数组里……
}
void removeObserver(Observer* observer) {
// 从数组中移除……
}
// 其他东西……
};
这让外部代码可以控制谁收到通知。被观察者与观察者通信,但并不耦合于它们。在我们的例子中,物理代码里不会出现一行提到成就的代码。然而它依然能跟成就系统对话。这就是这个模式的巧妙之处。
同样重要的是,被观察者持有的是观察者列表而不是单个观察者。这保证了观察者之间不会隐式地互相耦合。举个例子,假设音频引擎也要监听坠落事件来播放合适的音效。如果被观察者只支持一个观察者,那当音频引擎注册时,就会顶掉成就系统。这意味着两个系统会互相干扰——而且方式特别恶劣。支持列表就确保了每个观察者之间各自独立。
译注:注意,这段代码假设观察者不会在它们的
onNotify()方法内部修改列表。更健壮的实现会预防或优雅地处理这种并发修改。
被观察者的另一个职责是发送通知:
class Subject {
protected:
void notify(const Entity& entity, Event event) {
for (int i = 0; i < numObservers_; i++) {
observers_[i]->onNotify(entity, event);
}
}
// 其他东西……
};
译注:在实际代码中,我会避免使用继承。我会让
Physics拥有一个Subject实例。被观察的将不是物理引擎本身,而是一个独立的“坠落事件“对象。观察者可以用类似physics.entityFell().addObserver(this)的方式注册自己。
可被观察的物理引擎
现在,我们只需要把这一切接入物理引擎,让它能发送通知,而成就系统能把自己接进接收端。我们沿用原书《设计模式》的做法——继承 Subject:
class Physics : public Subject {
public:
void updateEntity(Entity& entity);
};
这样,我们把 Subject 中的 notify() 设为 protected,派生的物理引擎类可以调用它来发送通知,但外部代码不能。而 addObserver() 和 removeObserver() 是 public 的,所以任何能拿到物理系统引用的东西都可以观察它。
现在,当物理引擎做了值得关注的事情时,它调用 notify()——就像前面动机例子那样。然后遍历观察者列表,挨个通知。

挺简单的,对吧?就一个类,维护一个指向某个接口实例的指针列表。很难相信如此直白的东西竟然是无数程序和应用程序框架的通信骨架。
但观察者模式并非没有批评者。当我问其他游戏程序员对这个模式怎么看时,他们提出了一些抱怨。让我们看看能做些什么来应对——如果有什么可做的话。
“它太慢了”
我经常听到这个说法,而且通常来自那些其实不了解这个模式细节的程序员。他们有一个默认假设:任何闻起来像“设计模式“的东西一定包含成堆的类、层层间接引用,以及各种创造性浪费 CPU 周期的方式。
观察者模式尤其背了坏名声,因为它经常跟一些不光彩的角色混在一起——叫“事件“、“消息”、甚至“数据绑定“。其中有些系统确实可以很慢(而且往往是故意为之,有充分理由的)。它们涉及为每次通知做队列或动态分配之类的操作。
但现在你已经看过这个模式的实际实现了,你知道事实并非如此。发送通知就是遍历一个列表、调用几个虚方法。当然,它略微慢于静态分派调用,但在除了最性能关键的代码之外的任何地方,这个代价都可以忽略不计。
而且我发现这个模式最适合用在热路径之外的代码中,所以动态分派的成本通常可以承受。除此之外,几乎没有额外开销。我们不为消息分配对象,没有队列,就是同步方法调用上的一层间接引用。
它太快了?
事实上,你得小心一点——因为这个模式是同步的。被观察者直接调用观察者,意味着在所有观察者的通知方法返回之前,被观察者不会恢复自己的工作。一个慢的观察者会阻塞被观察者。
这听起来很吓人,但实际上并不是世界末日。只是你需要知道这一点。UI 程序员——他们做这种基于事件的编程已经很多年了——对这事有一条老生常谈的座右铭:“别卡 UI 线程”。如果你响应事件时要同步处理,就尽快完成并交还控制权,以免 UI 锁死。如果你有慢活要干,就推到另一个线程或工作队列里。
不过,把观察者跟多线程和显式锁混在一起确实要小心。如果一个观察者试图获取被观察者持有的锁,你可能会死锁游戏。在高度线程化的引擎中,也许用事件队列做异步通信更合适。
“它做了太多动态分配”
程序员部落中有大群人——包括许多游戏开发者——已经迁移到带垃圾回收的语言上了,动态分配不再像过去那么恐怖。但对于像游戏这样性能关键的软件,内存分配仍然重要——即使在托管语言中。动态分配需要时间,回收内存也需要时间——即使它是自动的。
在前面那个例子代码中,我用了一个固定数组,因为我尽量保持简单。在实际实现中,观察者列表几乎总是一个动态分配的集合,随着观察者的增删而扩展和收缩。这种内存活动吓到了一些人。
当然,首先要注意的是,它只在注册观察者时才分配内存。发送通知完全不涉及内存分配——就是一个方法调用。如果你在游戏开始时注册观察者,然后就不怎么动它们,分配量是极小的。
但如果这仍然是个问题呢?我来讲一种实现增删观察者而完全没有动态分配的方法。
链表式观察者
在我们目前看到的代码中,Subject 拥有一个指向每个观察者的指针列表。Observer 类本身没有指向这个列表的引用,它只是一个纯虚接口。接口优于具体、有状态的类,所以这通常是件好事。
但如果我们愿意在 Observer 里放一点状态,就可以通过把被观察者的列表穿过观察者自己来解决分配问题。不把独立的指针集合放在被观察者里,而是让观察者对象成为链表中的节点:

为了实现这一点,我们首先删掉 Subject 里的数组,换成一个指向链表头的指针:
class Subject {
Subject() : head_(NULL) {}
// 方法……
private:
Observer* head_;
};
然后给 Observer 加上一个指向链表中下一个节点的指针:
class Observer {
friend class Subject;
public:
Observer() : next_(NULL) {}
// 其他东西……
private:
Observer* next_;
};
我们这里还把 Subject 声明为友元类。被观察者拥有增删观察者的 API,但它要管理的链表现在在 Observer 类自身内部。最简单的办法就是让它成为友元来操作这个链表。
注册新观察者就是把它接入链表。我们选简单的做法——插入到头部:
void Subject::addObserver(Observer* observer) {
observer->next_ = head_;
head_ = observer;
}
另一个选择是插到链表尾部。那样会增加一点复杂度——Subject 要么遍历整个链表找尾部,要么维护一个始终指向最后节点的 tail_ 指针。
插到头部更简单,但有一个副作用。当遍历链表向每个观察者发送通知时,最近注册的观察者会最先收到通知。所以如果你按 A、B、C 的顺序注册,它们会按 C、B、A 的顺序收到通知。
理论上,这都不重要。良好观察者纪律的一条原则是:同一个被观察者的两个观察者之间不应该有顺序依赖。如果顺序确实重要,说明这两个观察者之间有某种隐式耦合,早晚会咬你。
最终只剩一件事——发送通知。就是遍历链表:
void Subject::notify(const Entity& entity, Event event) {
Observer* observer = head_;
while (observer != NULL) {
observer->onNotify(entity, event);
observer = observer->next_;
}
}
不算太差,对吧?被观察者可以有任意多的观察者,完全没有动态内存。注册和注销跟简单数组一样快。不过我们牺牲了一个小功能。
因为我们在用观察者对象本身作为链表节点,这意味着它只能属于一个被观察者的观察者列表。换句话说,一个观察者一次只能观察一个被观察者。而在更传统的实现中——每个被观察者有自己独立的列表——一个观察者可以同时出现在多个列表中。
也许你能接受这个限制。我发现一个被观察者对应多个观察者的情况比反过来更常见。如果这对你确实是个问题,还有一个更复杂但不需动态分配的方案。太长塞不进这一章,但我勾勒一下骨架,细节你来填……
节点对象池
跟前面一样,每个被观察者都有一个链表。但链表节点不再是观察者对象本身。而是单独的小“链表节点“对象——包含指向观察者的指针,以及指向链表中下一个节点的指针。

因为多个节点可以指向同一个观察者,所以一个观察者可以同时出现在多个被观察者的列表中。我们又回到了可以同时观察多个被观察者的状态。
避免动态分配的方式很简单:因为这些节点大小和类型都一样,你可以预分配一个对象池。这样你就有一堆固定数量的链表节点可用,按需使用和回收,完全不需要碰真正的内存分配器。
剩余的问题
我觉得我们已经驱散了三个用来吓退人们使用这个模式的妖魔。我们看到它简单、快、而且可以对内存管理友好。但这是否意味着你应该一直用观察者模式?
这就不同了。和所有的设计模式一样,观察者模式不是万能药。即使实现得正确且高效,它也不一定是正确的解决方案。设计模式之所以名声不好,就是因为有人把好模式用在了错的问题上,结果越搞越糟。
还剩两个挑战:一个是技术层面的,另一个更像是可维护性层面的。我们先做技术的,因为那些总是最简单的。
销毁被观察者和观察者
我们走过的示例代码是可靠的,但它回避了一个重要问题:当你删除一个被观察者或观察者时会发生什么?如果你随意地对某个观察者调用 delete,被观察者可能还持有指向它的指针。现在就是一个指向已释放内存的悬空指针。当被观察者试图发送通知时——这么说吧,你不会有好果子吃。
销毁被观察者则更简单一些,因为在大多数实现中,观察者并没有指向被观察者的引用。但即便如此,把被观察者的比特位送进内存管理器的回收站,也可能出问题。那些观察者可能仍然期待将来会收到通知,它们不知道再也不会发生了。严格来说它们根本不是观察者了——它们只是自己以为自己还是。
有几种处理方式。最简单的就是像我这样——把问题甩给别人。观察者有责任在自己被删除时,从所有被观察者那里注销自己。通常情况下,观察者确实知道自己正在观察哪些被观察者,所以只需要在析构函数里加一个 removeObserver() 调用。
如果你不想在某个被观察者挂掉之后让观察者悬着,也很容易解决。让被观察者在销毁前发最后一个“临终“通知。这样,任何观察者都能收到,然后采取它认为合适的行动。
人——即便是我们这些在机器陪伴下待了足够长时间、沾染了些许精确本性的人——在可靠性这件事上是出了名的不靠谱。这就是为什么我们发明了计算机:它们不会犯我们经常犯的错误。
更安全的答案是让观察者在被销毁时自动从所有被观察者那里注销。如果你在基础的观察者类里一次性实现这个逻辑,使用它的人就不需要记得自己去做。但这确实增加了一些复杂度。这意味着每个观察者需要维护它正在观察的被观察者的列表。你最终会得到双向的指针。
别担心,我有 GC
你们这些用带垃圾回收的时髦语言的酷小孩,现在大概挺得意的,觉得自己根本不用操心这个,因为你从来不用显式地 delete 任何东西。再想想!
想象一下:你有个 UI 界面显示了一堆玩家角色的数据,比如血量之类的。玩家打开界面时,你实例化一个新对象。关掉时,你忘了这个对象,让 GC 清理它。
每次角色脸上挨一拳(或者其他地方,随你),它就发送一个通知。UI 界面观察到了,就更新那根小小的血量条。很好。现在——玩家关掉了界面,但你没有注销观察者,会发生什么?
UI 界面不再可见了,但它不会被 GC 回收——因为角色的观察者列表里仍然持有对它的引用。每次加载界面,都会把一个新的实例加到那个越来越长的列表里。
玩家玩游戏的全过程中——跑来跑去、打架——角色一直在发送通知,这些通知被所有那些界面接收。它们不在屏幕上,但它们收到通知,浪费 CPU 周期去更新不可见的 UI 元素。如果它们还做其他事——比如播放音效——你还会得到明显错误的行为。
这个问题在通知系统中太常见了,以至于有了一个名字:失效监听者问题(lapsed listener problem)。由于被观察者持有对监听者的引用,你最终会得到长久驻留在内存中的僵尸 UI 对象。教训是:在注销这件事上要自律。
到底发生了什么?
观察者模式的另一个更深层的问题,是它的目的本身的直接后果。我们用它是为了帮助解耦两段代码。它让被观察者间接地跟某个观察者通信,而不用静态绑定它。
这在你想推理被观察者的行为时是一个真正的胜利——任何旁听者都会是一个恼人的干扰。如果你正在捣鼓物理引擎,你真的不想让编辑器——或你的大脑——被一堆成就系统的东西塞满。
但从另一方面来说,如果你的程序不工作、bug 跨越了某个观察者链条,推理这个通信流就会困难得多。如果是显式耦合,只需要查一下被调用的方法就行。你的 IDE 能轻而易举做到,因为耦合是静态的。
但如果耦合通过观察者列表发生,想要知道谁会收到通知的唯一办法就是看运行时列表里恰好有哪些观察者。你没法静态推理程序的通信结构,只能推理它的命令式、动态行为。
我对如何处理这个问题有个简单的准则:如果你经常需要同时思考通信的两端来理解程序的某个部分,就不要用观察者模式来表达那层关联。选更显式的方案。
当你黑进某大型程序时,你往往有一块一块的东西是你集中工作的。我们有很多术语来描述这个——“关注点分离”、“内聚与耦合”、“模块化”——但本质就是:这些放一起,那些不放一起。
观察者模式是让这些基本不相关的块相互通话的好方法,同时又不会让它们融合成一大块。但在一整块专注于某个功能或方面的代码内部,它就没那么有用了。
这就是为什么它适合我们的例子:成就和物理几乎是毫不相关的领域,很可能由不同的人来实现。我们希望它们之间的通信尽可能少,这样开发其中任何一个都不需要了解太多另一个。
今天的观察者
《设计模式》出版于 1994 年。那时面向对象编程是热门范式。地球上的每个程序员都想 “30 天学会 OOP”,中层管理者按他们创建的类数量来发薪。工程师以继承层次的深度衡量自己的本事。
观察者模式在那股思潮中流行起来,所以它重类、重继承也是意料之中。但现在的主流程序员对函数式编程更适应了。为了收一条通知就要实现一整个接口,不符合今天的审美。
它感觉笨重又僵硬。它就是笨重又僵硬。比如说,你没法让一个类对不同被观察者使用不同的通知方法。
更现代的做法是让“观察者“只是一个方法或函数的引用。在有头等函数的语言中——尤其是有闭包的语言——这是更常见的观察者写法。
举个例子,C# 把 “事件“做进了语言里。你注册的观察者是一个 “委托”(delegate),即方法引用。JavaScript 的事件系统中,观察者可以是实现 EventListener 协议的对象,但也可以只是函数——人们几乎总是用后者。
如果我今天设计一个观察者系统,我会让它基于函数而不是基于类。即使在 C++ 中,我也会倾向于一个让你注册成员函数指针作为观察者的系统,而不是某个 Observer 接口的实例。
明天的观察者
事件系统和其他类似观察者的模式在今天已经极其普遍了。它们是一条被踩得很熟的路。但如果你拿它们写几个大型应用,你就会开始注意到一件事:你的观察者里的代码有很多看起来是一样的。通常是这样:
- 收到通知,得知某个状态变了。
- 命令式地修改某块 UI 来反映新状态。
全都是“哦,英雄血量现在是 7?让我把血量条的宽度设成 70 像素。“一段时间之后,这变得相当乏味。计算机科学家和软件工程师们一直在试图消除这种乏味——已经尝试了很久。他们的尝试有过各种不同的名字:“数据流编程”、“函数式响应式编程“等等。
虽然有一些成功案例——通常是在有限领域,如音频处理或芯片设计——但圣杯至今仍未找到。与此同时,一种不那么激进的做法开始流行。近年来许多应用框架使用了“数据绑定“。
跟更激进的模型不同,数据绑定并不试图完全消灭命令式代码,也不试图围绕一个巨大的声明式数据流图来架构你的整个应用。它做的只是自动化那些繁琐的工作:修改 UI 元素或计算属性来反映某个值的变化。
和其他声明式系统一样,数据绑定可能太慢、太复杂,不适合塞进游戏引擎的核心。但如果我没在 UI 等游戏不那么关键的区域看到它开始渗透,我会很惊讶。
与此同时,老好观察者模式依然会在那里等着我们。当然,它不如那些能同时把“函数式“和“响应式“塞进名字的热门技术那么酷,但它极其简单,而且切实有效。对我来说,这往往是一个解决方案最重要的两个标准。
译注:这也解释了为什么我认为把模式文档化是重要的。当术语开始模糊的时候,我们就失去了清晰和简洁交流的能力。你说“观察者“,有人听到的是“事件“或“消息“,因为要么没人费心把区别写下来,要么他们刚好没读到。这也是我写这本书的初衷——事件和消息我也有专门的章节:事件队列。
参见
- 本章讨论的“事件“和“消息“的更详细版本,参考事件队列一章。
实践 Demo
笔记与思考
- 模式骨架:三要素——Subject(维护
observers[]+notify()遍历调用)、Observer(实现onNotify(event)业务逻辑)、业务代码(在关键节点调用notify()) - 三步流程:注册(
addObserver)→ 通知(notify遍历列表)→ 注销(removeObserver)。通常注册在初始化时集中完成,运行时极少变动 - JS 版 vs C++ 版:JS 是鸭子类型——有
onNotify方法就能当观察者,不需要显式implements;C++/Java 需要显式继承Observer接口。函数式语言里甚至可以传一个函数当观察者 - Vue 开发者的视角:
reactive()+watch()底层就是观察者模式——reactive= Subject 的依赖收集,watch的回调 = Observer 的onNotify。日常用 Vue 开发时接触的“响应式“本质上就是这个模式 - 游戏中的典型场景:成就系统(物理引擎发事件→成就检查条件)、UI 更新(血量变化→血量条刷新)、音效(攻击事件→播放音频)、AI(环境事件→AI 决策),全部解耦
- 三个坑:① 观察者销毁前必须注销,否则 Subject 持有悬空指针(或 GC 语言里的失效监听者问题);②
notify()是同步的,慢观察者会阻塞被观察者;③ 调试困难——通信链是动态的,IDE 没法帮你跳转 - 使用准则:跨系统/跨模块通信用观察者(如物理→成就);同一模块内部紧密逻辑用显式调用(如
player.takeDamage()里直接调checkDeath()),别滥用