中EN
概念页

观察者模式

Observer Pattern

更新于 2026-10-12 约 10 分钟设计模式观察者模式事件委托解耦GPP

观察者模式 (Observer Pattern) 让一段代码宣布“发生了某件事”,而不必关心谁会收到通知:主体 (Subject) 维护一个观察者列表,在事件发生时逐个同步调用它们。它适合连接彼此几乎无关的子系统(例如物理与成就),但要注意同步阻塞、观察者的注销,以及运行时才能确定的通信关系难以推理。

意图

以成就系统为例:“从桥上掉下去”这个成就和物理引擎有关,但我们不希望在碰撞求解的线性代数中间看到 unlockFallOffBridge()。观察者模式让物理代码只发一句“这个物体掉下去了,谁关心谁处理”,成就系统自己注册来接收。这样换掉甚至删掉整个成就系统,都不用改物理引擎的一行代码。(Game Programming Patterns — observer)

机制

  • 观察者:实现 onNotify(entity, event) 接口的类,例如成就系统。
  • 主体:持有观察者列表,并公开 addObserver / removeObserver,由外部代码决定谁接收通知;它与观察者通信,却不与之耦合。必须是列表而不是单个指针,否则音频系统注册时会把成就系统挤掉,观察者之间就隐式耦合了。(Game Programming Patterns — observer)
  • 发送通知:notify() 遍历列表并逐个调用虚函数。按 GoF 的做法,可以让物理引擎继承 Subject,把 notify() 设为 protected,只有它自己能发通知。(Game Programming Patterns — observer)
  • “观察者”系统与“事件”系统的区别:作者在旁注里说,真实代码中他不会继承 Subject,而是让 Physics 持有一个 Subject,例如一个“掉落事件”对象,观察者用 physics.entityFell().addObserver(this) 注册。前者观察的是“做了有趣事情的那个东西”,后者观察的是“代表那件有趣事情本身的对象”。(Game Programming Patterns — observer)
  • 示例代码的简化:书中示例假定观察者不会在 onNotify() 里修改列表,更健壮的实现要禁止或妥善处理这种并发修改;示例用定长数组只是为了照顾不熟悉 C++ 标准库的读者,真实代码会用动态集合。(Game Programming Patterns — observer)

下图表示一次通知:物理系统作为主体,同步地依次调用每个已注册的观察者。

sequenceDiagram
  participant Phys as 物理系统(Subject)
  participant Ach as 成就系统(Observer)
  participant Aud as 音频系统(Observer)
  Phys->>Ach: onNotify(实体, 掉落事件)
  Ach-->>Phys: 返回
  Phys->>Aud: onNotify(实体, 掉落事件)
  Aud-->>Phys: 返回
  Note over Phys: 所有观察者返回后才继续自己的工作

图中的关键点是同步调用:通知就是一串普通的虚函数调用,一个慢的观察者会拖住主体。

何时使用

  • 两块基本无关、往往由不同人负责的代码需要最少量的通信,例如物理与成就。
  • 一块代码内部、为同一功能服务的代码之间不适合用它。Nystrom 的准则是:如果理解某部分程序时经常要同时想着通信的两端,就不要用观察者,改用更显式的调用。(Game Programming Patterns — observer)

取舍与坑

  • “太慢”多半是误解:发送通知只是遍历列表做虚函数调用,不排队,也不为消息分配内存。它比静态调用略慢,但除了最热的代码路径外可以忽略,而且这个模式本来就更适合非热点代码。(Game Programming Patterns — observer)
  • “太快”才是坑:调用是同步的,主体要等所有观察者返回。慢的工作应推到其他线程或工作队列;观察者去拿主体持有的锁则可能死锁。高度多线程的引擎里,用异步的 事件队列 可能更合适。(Game Programming Patterns — observer)
  • 动态分配:作者指出,很多游戏开发者更担心的其实是内存碎片:主机认证要求游戏连续运行多天不崩溃,碎片化的堆可能让游戏无法发售,详见 对象池。观察者列表只有注册和注销时才分配。若仍不能接受,可以让观察者自身成为链表节点(侵入式链表 (intrusive linked list),数据对象自身包含 next_ 节点指针,Linux 内核也常用这种做法):零分配,但一个观察者同时只能观察一个主体;头插法会让通知顺序与注册顺序相反,而良好的观察者纪律本来就要求观察者之间没有顺序依赖。另一种做法是用 对象池 预分配链表节点,这样一个观察者可以出现在多个主体的列表里。(Game Programming Patterns — observer)
  • 销毁问题:删除观察者而不注销,主体就持有悬空指针。最简单的约定是观察者在析构时自己注销;主体被销毁前可以发一条“临终”通知;更稳妥的做法是在观察者基类里自动注销,代价是双向指针。(Game Programming Patterns — observer)
  • 失效监听者问题 (lapsed listener problem):有 GC 也逃不掉。UI 界面关闭后若没有注销,主体的列表仍引用它,它就不会被回收,还会继续接收通知、更新看不见的 UI,甚至播放声音。教训是严格注销。(Game Programming Patterns — observer)
  • 现代写法:在有一等函数或闭包的语言里,观察者通常只是一个函数引用,例如 C# 的 event 与 delegate。Nystrom 说今天他会做成基于函数而不是基于类的系统,C++ 里也倾向于注册成员函数指针。类式观察者的僵硬之处在于:一个类只有一个 onNotify(),无法为不同主体使用不同的通知方法,所以主体通常把自己作为参数传过去,让观察者分辨是谁在通知。(Game Programming Patterns — observer)
  • 展望:数据绑定 (data binding):大量观察者代码都是“收到状态变化 → 命令式地改 UI”。数据绑定把这类杂活自动化;作者认为它对游戏引擎核心来说可能太慢、太复杂,但预计会先进入 UI 这类不那么关键的领域。(Game Programming Patterns — observer)

代码草图(C++)

按书中示例整理:先是物理引擎发通知和成就系统接收,再是零分配的侵入式链表版 Subject。

void Physics::updateEntity(Entity& entity) {
  bool wasOnSurface = entity.isOnSurface();
  entity.accelerate(GRAVITY);
  entity.update();
  if (wasOnSurface && !entity.isOnSurface())
    notify(entity, EVENT_START_FALL);     // 物理代码不知道谁在听
}

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_;
};

// 侵入式链表:观察者自身就是链表节点
class Observer {
  friend class Subject;
public:
  Observer() : next_(NULL) {}
  virtual void onNotify(const Entity& entity, Event event) {}
private:
  Observer* next_;
};

void Subject::addObserver(Observer* observer) {
  observer->next_ = head_;                // 头插:后注册的先收到通知
  head_ = observer;
}

void Subject::notify(const Entity& entity, Event event) {
  Observer* observer = head_;
  while (observer != NULL) {
    observer->onNotify(entity, event);
    observer = observer->next_;
  }
}

UE 中的对应

  • 多播委托就是函数式的观察者 → 委托与 Tick 机制:多播委托 (Multicast Delegate) 可绑定多个函数,用 Broadcast() 调用全部;没有绑定时调用也安全,并会跳过已失效的对象。该页还强调调用顺序未定义,不一定按添加顺序,这正好对应“观察者之间不应有顺序依赖”。RemoveAll(对象) 用于注销,对应“严格注销”。需要蓝图绑定的事件(事件分发器)用动态多播。
  • 该页也给出 UE 官方的取舍:能用定时器或委托就不用 Tick,即“事件驱动”优先于“轮询驱动”。

GEA 的视角:兴趣注册、回调阶段与职责链(卷 II 17.6.3.1、17.8.7–17.8.8)

GEA 第 17 章中有三处与观察者模式直接相关:

  • 注册对事件的兴趣:多数对象只关心少数事件,多播或广播时调用所有对象的处理器很浪费。可以为每种事件维护感兴趣对象的链表,或让每个对象维护一个位数组;更进一步,在最初的对象查询里就只包含感兴趣的对象(例如爆炸时只查询半径内能响应 Explosion 事件的对象)。是否划算取决于查询实现以及"查询中过滤"与"遍历中过滤"的相对成本。详见 事件与消息传递。
  • 更新阶段的回调注册:GEA 认为与其让游戏循环遍历所有对象调用 PreAnimUpdate() 等钩子,不如让动画系统允许任何客户(对象或其他引擎系统)为各更新阶段注册回调,只有真正需要的客户才被调用,也消除了底层系统对游戏对象的依赖。见 游戏对象的实时更新。
  • 职责链:事件可以沿对象关系图(附着层次、组件、队伍)转发,处理器返回是否"消费"了事件以决定是否继续转发。这与观察者的"一对多通知"互补:观察者由主体广播给所有注册者,职责链按预定顺序逐个传递直到被处理。
  1. Game Programming Patterns — observer
  2. Game Programming Patterns — observer(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) observer 章,gameprogrammingpatterns.com/observer.html
  4. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  5. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.6.3.1 节
  6. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.8.7–17.8.8 节

相关页面

游戏开发事件队列Event Queue事件队列 (Event Queue) 用一个先进先出队列,把“发送消息或事件”和“处理它”在时间上解耦:发送方入队后立即返回,接收方在自己方便的时候再取出处理。它比观察者和命令模式多做的一件事是时间上的解耦,适合“推”与“拉”两端节奏不同的场合(例如音频请求),代价是全局可见、世界状态可能已经改变,以及可能出现隐蔽的反馈回环。游戏开发对象池Object Pool对象池 (Object Pool) 预先一次性分配一组可复用对象,需要时从池里取一个未使用的对象并初始化,用完标记为未使用,而不是逐个 new/delete。它能避免频繁分配的开销和堆碎片,常用于粒子、子弹、正在播放的声音等。代价是池大小要调校、同时活跃的数量有上限、复用的对象不会被自动清零,在有 GC 的环境里还可能让本该回收的对象一直活着。游戏开发 · UE5委托与 Tick 机制Delegates & TickUE 有两种驱动游戏逻辑的方式:事件驱动——委托 (Delegate) 在某事发生时回调绑定的函数;轮询驱动——Tick 每帧(或按最小间隔)调用 Actor/组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。官方建议能用定时器、委托就不用 Tick,必须 Tick 时用 Tick 组和 Tick 依赖保证顺序,避免“差一帧”问题。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原