中EN
概念页

组件模式

Component Pattern

更新于 2026-10-12 约 13 分钟设计模式组件模式实体解耦ECSGPP

组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。

意图

让单个实体跨越多个领域,而不让这些领域互相耦合。(Game Programming Patterns — component)

机制

单个实体横跨多个领域。为了让各领域彼此隔离,每个领域的代码放在自己的组件类里,实体被简化为一个组件容器。(Game Programming Patterns — component)书中的例子是平台游戏主角 Bjørn:原本一个巨大的 update() 里混着输入、物理和图形代码,拆成 InputComponent、PhysicsComponent、GraphicsComponent 之后,GameObject 只负责持有它们并依次调用。(Game Programming Patterns — component)

书中强调的几个要点(Game Programming Patterns — component):

  • 问题不只是类太大,更是耦合:像 if (collidingWithFloor() && getRenderState() != INVISIBLE) playSound(HIT_FLOOR); 这样的代码,改它的人要同时懂物理、图形和声音。在多核上按领域拆线程(AI 一个核、声音一个核、渲染一个核)时,这种耦合还会招来死锁等并发 bug。
  • 继承无法干净地复用:装饰物(可见不可碰)、道具(可见可碰)、区域(不可见可碰)。若 Prop 继承 Zone 复用碰撞,就无法再继承 Decoration 复用渲染,否则陷入多重继承的“致命菱形 (Deadly Diamond)”;把一切塞进 GameObject 又浪费内存。用组件后,三种东西只是同一个 GameObject 配上不同组件:类从四个变成三个,没有重复代码,也没有多重继承。作者的比喻是:单体类像只能点套餐,组件像单点菜单。
  • 把组件藏到接口后面:Bjorn 持有 InputComponent* 而不是具体类后,传入 DemoInputComponent(由 AI 控制)就能实现主机常要求的“演示模式”,物理和图形完全不知道区别;代价是 update() 变成虚调用。再把物理、图形也抽象成接口,Bjorn 就变成通用的 GameObject,用 createBjorn() 这样的工厂函数组装出各种对象。
  • 为什么位置和速度留在容器里:它们是几乎所有组件都要用的“跨领域”状态,不清楚该归哪个组件;而且它们给了组件一种不互相耦合的通信渠道。

下图表示拆分后的结构,以及三种组件通信方式(下一节详述)。

flowchart TB
  GO["GameObject:位置、速度(共享状态)"] --> IC[InputComponent]
  GO --> PC[PhysicsComponent]
  GO --> GC[GraphicsComponent]
  IC -. "1 改写容器状态:设置速度" .-> GO
  GC -. "2 直接引用:查询是否在地面" .-> PC
  PC -. "3 经容器广播消息:发生碰撞" .-> GO

图中三条虚线代表三种可以并存的通信方式:经容器共享状态、组件之间直接引用、经容器转发消息。

何时使用

满足任一条件时:一个类触及多个你希望彼此解耦的领域;一个类变得庞大难用;你想定义许多共享不同能力组合的对象,但继承无法精确地挑出想复用的部分。(Game Programming Patterns — component)

取舍与坑(Game Programming Patterns — component)

  • 复杂度上升:每个概念上的“对象”变成一组必须实例化、初始化并正确连接的对象,组件间通信更难,内存占用也更难控制。在大型代码库中,解耦和复用可能值得;但要先确认没有在为不存在的问题做过度设计。
  • 多一层间接:先拿到组件,再做事情。在性能关键的内层循环里,这种指针跟随可能导致性能变差。反过来,组件也常常能改善性能和缓存一致性,因为它让 数据局部性 更容易按 CPU 需要的顺序组织数据。

设计决策(Game Programming Patterns — component)

对象如何获得组件

方式优点缺点
对象自己创建组件保证总是有需要的组件难以重新配置,浪费了“重新组合组件得到新对象”的能力
外部代码提供组件对象更灵活,最终可以成为通用容器;对象只认识组件接口需要外部正确装配

组件之间如何通信(不互斥,实际常常混用)

方式优点缺点适合
修改容器对象的状态组件彼此完全不知道对方存在共享信息都得上提到容器,污染容器并浪费内存;通信是隐式的,依赖组件处理顺序(先更新图形就会画出上一帧的位置)位置、尺寸这类每个对象都有的基础数据
直接互相引用简单、快两个组件紧耦合关系紧密的一对:动画与渲染、输入与 AI、物理与碰撞
发送消息(经容器广播,容器即 GoF 的中介者 (Mediator);消息也可以排队,见 事件队列)兄弟组件解耦,唯一的耦合是消息值本身;容器保持简单,只是盲目转发最复杂;发送者自己也会收到,要小心消息回环不太重要、“发出去就不管”的通信,如碰撞时播放音效

Nystrom 建议先从简单方式开始,需要时再增加通信路径。(Game Programming Patterns — component)

与其他模式的关系

  • 组件和 游戏循环与更新方法 中的游戏循环、更新方法一起,构成很多引擎核心的“三件套”。(Game Programming Patterns — update-method)
  • 它和策略 (Strategy) 模式相似:都把部分行为委托给从属对象。区别是策略对象通常无状态(定义“如何做”),组件则常持有描述对象身份的状态;无状态的组件可以在多个容器之间共享,这时就更像策略了。(Game Programming Patterns — component)
  • 子类沙盒的基类变成“大杂烩”时,可以把部分提供的操作拆成组件(见 子类沙盒);现代引擎用组件和类型对象来表达实体种类,而不是为每种怪物写一个类(见 原型模式)。

代码草图(C++)

按书中示例整理:最终的通用 GameObject、可替换的输入组件、工厂函数,以及消息广播方式的容器。

class InputComponent {
public:
  virtual ~InputComponent() {}
  virtual void update(GameObject& obj) = 0;   // 书中此时参数仍写作 Bjorn&
};

class PlayerInputComponent : public InputComponent {
public:
  virtual void update(GameObject& obj) {
    switch (Controller::getJoystickDirection()) {
      case DIR_LEFT:  obj.velocity -= WALK_ACCELERATION; break;
      case DIR_RIGHT: obj.velocity += WALK_ACCELERATION; break;
      case DIR_NONE:  break;
    }
  }
private:
  static const int WALK_ACCELERATION = 1;
};

class DemoInputComponent : public InputComponent {
public:
  virtual void update(GameObject& obj) { /* AI 自动控制 */ }
};

class GameObject {
public:
  int velocity;
  int x, y;                                  // 跨领域的共享状态

  GameObject(InputComponent* input, PhysicsComponent* physics, GraphicsComponent* graphics)
  : input_(input), physics_(physics), graphics_(graphics) {}

  void update(World& world, Graphics& graphics) {
    input_->update(*this);                   // 顺序很重要:输入 → 物理 → 图形
    physics_->update(*this, world);
    graphics_->update(*this, graphics);
  }

private:
  InputComponent*    input_;
  PhysicsComponent*  physics_;
  GraphicsComponent* graphics_;
};

GameObject* createBjorn() {                  // 工厂方法:组装出 Bjørn
  return new GameObject(new PlayerInputComponent(),
                        new BjornPhysicsComponent(),
                        new BjornGraphicsComponent());
}

// 消息方式:容器把消息广播给所有组件(包括发送者自己)
class Component {
public:
  virtual ~Component() {}
  virtual void receive(int message) = 0;
};

class ContainerObject {
public:
  void send(int message) {
    for (int i = 0; i < MAX_COMPONENTS; i++)
      if (components_[i] != NULL) components_[i]->receive(message);
  }
private:
  static const int MAX_COMPONENTS = 10;
  Component* components_[MAX_COMPONENTS];
};

UE 中的对应

  • Actor 与 Actor 组件 → Gameplay 框架:Actor 是“组件的容器”,Actor 组件控制移动、渲染、位置、视角等(场景组件、静态网格组件、相机组件、弹簧臂组件……);Character 就是一个自带角色移动组件、骨骼网格组件和胶囊组件的 Pawn。这是组件模式在 UE 里最直接的体现。
  • 外部代码提供组件 → Game Features 与模块化玩法:Game Feature 插件最常用的 Action 是 Add Components,把功能插件中的组件注入到现有 Actor 上。这对应上表中“外部代码提供组件”的选项,而且官方给出的好处正是避免无关功能之间意外的交互或依赖。
  • 实体只是一个 ID → MassEntity 数据导向框架:在 MassEntity 中,实体 (Entity) 只是一个 ID 加一组片段 (Fragment),无状态的处理器批量处理片段。本章旁注提到,有些组件系统更进一步:实体只是一个数字 ID,各类组件放在各自的集合里,并记录自己属于哪个实体,即实体组件系统 (Entity Component System),详见数据局部性一章。MassEntity 正是这种做法,组件模式在这里与缓存友好的内存布局结合在了一起。(Game Programming Patterns — component)

GEA 的视角:从单体层次到组件(卷 II 17.2.1)

GEA 从引擎架构的角度给出了与本页相同的结论,并补充了几点细节:

  • 问题来源:单体类层次难以理解和修改、无法表达多维分类(两栖车放不进"陆地/水上"的载具树)、多重继承带来致命菱形;不许多重继承时功能会冒泡到根类。GEA 以 Unreal 的 Actor 为冒泡效应的典型例子,并列记录在 运行时对象模型架构 中,与 UE5 文档"Actor 是组件的容器"的说法对照。
  • 混入类:有的团队允许"只从主层次继承一个父类 + 任意多个无基类的混入类"(如 MHealth、MCarryable),但 GEA 认为通常组合或聚合这些类比继承更好。
  • 组件的所有权:两种做法:根类为每种可能的组件各持一个指针、由派生类构造函数创建所需组件、基类析构统一删除;或根类持有通用组件链表,根类不必知道有哪些组件类型,同类组件也可以有多个。GEA 认为两者没有明显优劣。这与本页"设计决策"中"对象如何获得组件"(对象自己创建 vs 外部代码提供)的讨论对应。
  • 纯组件模型:去掉 GameObject,每个组件存对象 id、按 id 分组。难点是创建正确的组件组合(工厂或数据驱动)、组件间通信(例如把同一对象的组件连成环形链表)和对象间消息(只能事先知道发给哪个组件或多播给全部组件)。GEA 的建议是:纯组件模型可行但不是明显更好,选团队最熟悉、最适合当前游戏的架构。
  • 组件与属性:GEA 指出以对象为中心的组件往往对应某个底层子系统的连接(渲染、动画、物理),以属性为中心的属性对应对象的某种特性(生命、外观、背包),但这个区别细微到多数时候无关紧要。
  1. Game Programming Patterns — component
  2. Game Programming Patterns — component(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) component 章,gameprogrammingpatterns.com/component.html
  4. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  5. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.2.1 节

相关页面

游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发原型模式Prototype Pattern原型模式 (Prototype Pattern) 的核心思想是“一个对象可以生成与自己相似的对象”:生成器持有一个原型实例,通过 clone() 复制出类和状态都相同的新对象。Nystrom 认为这个设计模式本身在现代游戏中很少是最佳答案,更有价值的是“原型委托 (delegation)”用于数据建模,让数据条目继承另一条目的属性,减少重复。游戏开发子类沙盒Subclass Sandbox子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。游戏开发 · UE5Gameplay 框架Gameplay FrameworkGameplay 框架 (Gameplay Framework) 是 UE 提供的一组相互配合的类,用来搭建游戏的基本结构:Actor 与组件 (Component) 构成场景中的一切;GameMode 在服务器上定规则并生成玩家;玩家由“控制器 (Controller) + 棋子 (Pawn)”组成;GameState 与 PlayerState 负责把全局与每个玩家的状态复制给所有客户端;GameInstance 跨关卡存活。游戏开发 · UE5MassEntity 数据导向框架MassEntity FrameworkMassEntity 是 UE5 面向玩法的数据导向 (Data-Oriented) 计算框架,属于实体-组件-系统 (Entity Component System, ECS) 一类设计:实体 (Entity) 只是一个 ID 加上一组片段 (Fragment) 数据,相同组成的实体归入同一原型 (Archetype) 并按内存块 (Chunk) 连续存放,无状态的处理器 (Processor) 通过实体查询 (EntityQuery) 批量处理片段。它适合成千上万个同质对象(人群、交通),MassGameplay 插件在其上提供表现、生成、LOD、复制、StateTree 等功能。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发运行时引擎分层架构Runtime Engine ArchitectureGregory 把游戏视为“软实时、交互式、基于代理的计算机模拟”,把游戏引擎定义为数据驱动、可复用来做多款游戏的软件。运行时引擎按层组织:从目标硬件、驱动、操作系统、第三方 SDK、平台无关层、核心系统、资源管理器,到渲染、物理、动画、音频、网络等子系统,再到游戏性基础层和游戏专属子系统;上层依赖下层,避免循环依赖。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。游戏开发游戏脚本系统Game Scripting脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.游戏开发单例与服务定位器Singleton & Service Locator单例 (Singleton) 同时承诺两件事:一个类只有一个实例,并且提供全局访问点。Nystrom 认为它在游戏里弊大于利,因为单例本质上就是全局变量。服务定位器 (Service Locator) 是更灵活的“近亲”:它同样给出全局访问点,但调用者只依赖抽象服务接口,具体实现由定位器在运行时或编译期决定,还可以退化为空服务。两者都应当慎用,首选仍是直接把对象传进去。游戏开发 · UE5Game Features 与模块化玩法Game Features & Modular GameplayGame Features 与 Modular Gameplay 两个插件让开发者把一个玩法功能做成独立的插件,可以单独启用或停用。功能本身由一个 GameFeatureData 资产里的 Action 列表描述,最常用的是 Add Components:把插件里的组件按需挂到已有的 Actor 类上。这样主工程的代码保持干净,无关功能之间不会意外产生依赖,对需要长期增减功能的线上产品尤其重要。游戏开发事件队列Event Queue事件队列 (Event Queue) 用一个先进先出队列,把“发送消息或事件”和“处理它”在时间上解耦:发送方入队后立即返回,接收方在自己方便的时候再取出处理。它比观察者和命令模式多做的一件事是时间上的解耦,适合“推”与“拉”两端节奏不同的场合(例如音频请求),代价是全局可见、世界状态可能已经改变,以及可能出现隐蔽的反馈回环。

被这些页面引用3 篇

输入关键词开始搜索

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