AIGC标识 【游戏架构】【笔记】解耦型模式——《游戏编程模式》学习笔记004

该笔记中有AI辅助内容。

组件模式

“允许一个单一的实体跨越多个不同域而不会导致耦合。”

假如用过 Unity 或 UE,应该都非常熟悉这个模式啦。

简单来说:若单一实体需要横跨多个域。为了能够保持域之间相互隔离,我们可以将每个域的代码都独立地放在自己的组件类中,而实体本身则可以简化为这些组件的容器。


使用场景

  • 你有一个涉及多个域的类,但是你希望让这些域保持相互解耦。
  • 一个类越来越庞大,越来越难以开发。
  • 你希望定义许多共享不同能力的对象,但采用继承的办法却无法令你精确地重用代码。

注意事项

在一些性能要求较高的内部循环代码中,组件指针可能会导致低劣的性能。这实际上是经典 OOP 组件模式的局限。

维度 经典 OOP 组件模式 (OOP Components) 数据定向 ECS 架构 (Entity Component System)
实体 (Entity) 复杂的容器类(如 GameObject / AActor),内部存组件指针列表 仅仅是一个 32/64 位整数 ID(无任何数据与方法)
组件 (Component) 数据 + 逻辑(如 MonoBehaviour / UActorComponent) 纯数据结构 (POD Struct),绝不包含任何逻辑方法
逻辑 (System) 分散在各个组件的 Update() / Tick() 方法中 集中在独立的 System 里,通过 ID 批量迭代过滤数据
内存与 Cache 内存指针离散分布,CPU L1/L2 缓存命中率低 数据连续紧密排列在 Cache Line 中,向量化 (SIMD) 极快
引擎代表 Unity Classic (MonoBehaviour) / UE Classic (Actor-Component) Unity DOTS (Entities) / UE Mass Framework

但是组件结构使得在使用数据本地化模式时能够更容易按照CPU所需的顺序来组织数据。


设计决策

  1. 对象该如何获得组件

    • 类自己创建自己的组件
      • 优点:确保了这个类一定有自己所需要的组件
      • 缺点:导致重新配置这个类变得困难
    • 外部代码来提供组件
      • 对象变得灵活
      • 可以从具体的组件类型中解耦出来
  2. 组件之间如何传递信息

    • 通过修改容器对象的状态
      • 优点:这使得组件间保持解耦
      • 缺点:
        • 由容器对象来共享所有组件间需要共享的数据
        • 信息传递变得隐秘,同时对组件执行的顺序产生依赖
    • 组件间直接互相引用
      • 优点:简单且快捷
      • 缺点:组件之间紧密耦合
    • 通过传递信息的方式
      让需要传递信息的组件通过广播的方式去建立联系
      • 缺点:实现方式复杂
      • 优点:
        • 兄弟组件之间是解耦的
        • 容器对象十分简单,他只是信息的中介

          GoF 称之为中介模式:两个或两个以上的对象通过将信息传递到一个中介的方法来取得相互之间的联系。

    在事实上,我们可能这三个方式都会用上。状态共享对于每个对象都拥有的基本状态如位置和尺寸等非常管用;强关联的组件们,可以直接让他们产生联系;对不太重要的通信、可即发即弃的信息可以使用传递信息的方式。


引擎落地对标

1. Unity (C#)

  • 容器与组件:GameObject 作为纯容器,挂载继承自 MonoBehaviour 的组件。
  • 通信三种方式在 Unity 中的最佳实践:
    • 直接引用:在 Inspector 中拖拽赋值或在 Awake() 中通过 GetComponent<T>() 缓存引用(注意:切勿在 Update 里频繁调用 GetComponent)。
    • 容器状态共享:直接读写 transform.position、gameObject.layer 等容器公共属性。
    • 传递信息/广播:
      • ❌ 避免使用 Unity 原生的 SendMessage() / BroadcastMessage()(通过反射查找方法,极其昂贵且无类型检查)。
      • ✅ 推荐使用 C# event / Action 委托,或接口通信(IEventSystemHandler)。

2. Unreal Engine (C++)

  • 容器与组件层级:UE 的 AActor 是容器,但 UE 将组件细分为了三个层级:

    1. UActorComponent:最基础的逻辑组件(无空间变换,无渲染,如 UHealthComponent)。
    2. USceneComponent:继承自 UActorComponent,带有 Transform(位置/旋转/缩放),可相互嵌套树状层级。
    3. UPrimitiveComponent:继承自 USceneComponent,带有渲染网格或物理碰撞(如 UStaticMeshComponent)。
  • 通信三种方式在 UE 中的最佳实践:

    • 直接引用:在 C++ 中通过 FindComponentByClass<T>() 或 GetComponentByClass() 获取并缓存。
    • 容器状态共享:读写 Actor 根节点的 Transform(GetActorLocation() / SetActorRelativeRotation())。
    • 传递信息/广播:使用 UE 的 动态多播代理(Dynamic Multicast Delegate)。可以在 C++ 中广播,蓝图中直接绑定事件监听。

事件队列

“对消息或事件的发送与受理进行时间上的解耦。”

事件队列是一个按照先进先出顺序存储一系列通知或请求的队列。
发出通知时系统会将该请求置入队列并随即返回,请求处理器随后从事件队列中获取并处理这些请求。
请求可由处理器直接处理或转交给对其感兴趣的模块。
这一模式对消息的发送者与受理者进行了解耦,使消息的处理变得动态且非实时。


使用场景

  1. 为了避免在硬件报告输入直至应用程序获取事件期间,被操作系统遗漏
  2. 为了任意系统都可以从队列中接受事件,而各系统之间不需要产生联系
  3. 为了使某些操作不阻塞主线程
  4. 建立缓冲,但是这样子不适应于需要实时反馈的情况

注意事项

  1. 中心事件队列是个全局变量
    当你有一些系统的任何部分都能访问的状态时,各种细小部分不知不觉中就产生了互相依赖
  2. 事件队列使得事件失去了即时性
    所以需要记录各种细节,避免事件失去了事实功能
  3. 有可能在反馈系统循环中绕圈子
  4. 当目标早已被销毁——使用悬挂指针
    • 隐患:玩家在第 1 帧对怪物 A 释放了伤害事件并推入队列,但第 2 帧怪物 A 已经被其他系统(如场景AOE)销毁了。当第 3 帧事件队列弹出事件并尝试对怪物 A 扣血时,会导致空指针崩溃或踩内存。
    • 解法:队列中的事件绝对不能直接持有一级裸指针(Raw Pointer),必须持有 弱引用(TWeakObjectPtr / WeakReference) 或 唯一实体 ID(Entity ID / Guid)。在出队处理时先校验有效性。

实现方式

  1. 使用循环队列

    • 列的head(队头)是请求被读取的地方。头部中存储的是最早的请求。
    • 列的tail(队尾)是另一端,是下一个入队请求写入的位置。
    • 保证队尾到达底部时绕回数组的开始
    • 队列不能溢出,所以需要最大长度
  2. 将请求汇总

    • 当遇到当前等待处理的请求相符的请求,将两个请求合并为一个单独的请求
    • 注意:合并是在请求“入列”时进行的操作,而不是“处理”时进行的操作。

      为什么要合并:

      • 音量叠加炸音:如果 10 个怪物在同一帧死亡,若没有事件队列,会瞬间调用 10 次 PlaySound(),导致声音叠加过载炸音(甚至硬件崩溃)。
      • 队列入队合并(Coalescing):事件队列在入队时检查“若队列里已有相同音效”,只需将音量微调或直接丢弃重复请求,最终只播放 1 次最响的死声。
  3. 跨越线程


设计时的决策

  1. 入队的是什么
    • 入队的是事件
      • 你可能会允许多个监听器
      • 可访问队列的域往往更广
    • 入队的是消息
      一个“消息”或“请求”描述一种“我们期望”发生在“将来”的行为,类似于“播放音乐”。
      • 你更可能只有单一的监听器
  2. 谁能读取队列
    • 单播队列
      当一个队列是一个类的API本身的一部分时,可以使用单播队列
      • 队列成为读取者的实现细节。所有发送者知道的只是它发送了一条消息
      • 队列被更多地封装
      • 不用担心多个监听器竞争的情况
    • 广播队列
      • 事件可以被删除。当没有监听器时,事件被废弃
      • 可能需要过滤事件。只接收自己需要的事件
    • 工作队列
      有多个监听器,但是队列中的每一项只会被投递到一个监视器中
  3. 谁可以写入队列
    • 一个写入者
      • 隐式地知道事件的来源
      • 通常允许多个读取者
    • 多个写入者
      • 需要小心不要触发反馈循环
      • 可能需要发送者在事件本身的引用
  4. 队列中对象的生命周期是什么
    如果你使用一个具有垃圾回收机制的语言,那么你不需要过多担心这个。填满队列中的消息,只要是必要的时候就会逗留在内存里。
    • 转移所有权 unique_ptr<T>
      这是手动管理内存时的一种传统方法。当一个消息排队时,队列声明它,发送者不再拥有它。当消息处理时,接收者取走所有权并负责释放它。
    • 共享所有权 shared_ptr<T>
      只要任何事情对它有一个引用,消息就依然存在。当被忘记时它就会自动释放。
    • 队列拥有它——队列是一个对象池
      队列返回一个已经存在于队列内存的消息引用,接着发送者会填充队列。消息处理时,接收者参考队列中相同消息的操作。

这份笔记梳理得非常到位!尤其是准确指出了事件队列的核心价值在于“时间上的解耦”,并将“入队合并(Coalescing)”和“单播/广播/工作队列”的区别归纳得极其清晰。

为了让你的游戏编程模式笔记库彻底无死角,为你补全终极认知区别、工业级踩坑点(野指针风险)以及双引擎落地对标。


观察者 vs. 事件队列

很多人容易混淆观察者模式与事件队列模式,两者的核心差异在于时间维度:

维度 观察者模式 (Observer / Event Bus) 事件队列模式 (Event Queue)
解耦维度 空间解耦(发送者不知道谁接收) 空间 + 时间双重解耦(发送者既不知道谁接收,也不管何时处理)
执行时机 同步(Synchronous):Notify 时直接调用观察者回调,阻塞当前帧 异步(Asynchronous):Notify 时仅入队,消费线程/后续帧统一 Tick 处理
典型代表 C# delegate / UE MulticastDelegate 音频播放队列、输入事件队列、网络数据包队列

引擎落地对标

1. Unity (C#)

  • 新输入系统 (New Input System):底层完全建立在事件队列上(Input Event Buffer),硬件中断产生 Event 放入 Native 队列,在每帧 InputSystem.Update() 时统一出队分发。
  • Job System / 多线程通信:使用 NativeQueue<T> 在主线程与 Work Thread 之间传递数据/任务事件。
  • 跨线程安全的标准队列:System.Collections.Concurrent.ConcurrentQueue<T>。

2. Unreal Engine (C++)

  • 无锁线程安全队列:UE 提供了专门针对多生产者-单消费者(MPSC)或单生产者-单消费者(SPSC)优化的模板队列 TQueue<T, EQueueMode::Mpsc>。
  • 音频线程 (Audio Thread):主线程通过 FAudioThread 的 Command Queue 向音频渲染线程异步投递音效命令,完全不会卡顿逻辑主线程。
  • TaskGraph 系统:FTaskGraphInterface 本质上是一个带依赖关系的事件/任务队列,用于将大量的 Async Task 调度到不同的 Worker Thread 处理。

服务定位器

“为某服务提供一个全局访问入口来避免使用者与该服务具体实现类之间产生耦合。”

服务定位器将一个服务的“是什么”(具体实现类型)和“在什么地方”(我们如何得到它的实例)与需要使用这个服务的代码解耦了。

  • 一个服务类为一系列操作定义了一个抽象的接口。
  • 一个具体的服务提供器实现这个接口。
  • 一个单独的服务定位器通过查找一个合适的提供器来提供这个服务的访问,它同时屏蔽了提供器的具体类型和定位这个服务的过程。

使用场景

!谨慎使用!

  • 当一个单例系统要层层传递,才能让底层的函数访问

注意事项

  1. 服务必须被定位
    使用这个模式时需要定位服务,所以有可能出现定位失败的情况,需要进行处理。
  2. 服务不知道被谁定位了
    意味着这个服务在任何情况下都必须能正确工作。

很多架构师反对过度使用“服务定位器”,是因为它存在一个隐蔽性缺陷:隐藏了类的真实依赖关系。

  • 服务定位器:类在内部“主动拉取”依赖。看类的构造函数不知道它依赖了啥,必须翻看函数体才能发现它调用了 ServiceLocator::Get<IAudioService>()。
  • 依赖注入(DI):类在构造时“被动接收”依赖。构造函数显式要求参数 MyClass(IAudioService audio),依赖关系一目了然,单元测试(Unit Test)时极易 Mock。

设计决策

  1. 服务是如何被定位的
    • 外部代码注册——最常见
      • 优点:
        • 简单快捷
        • 我们来控制服务提供器如何被构建
        • 可以在游戏运行的时候更换服务提供器——可用于测试
      • 缺点:定位器依赖外部代码
        访问服务的任何代码都假设其他代码已经注册过这个服务了。如果没有执行初始化,游戏要么崩溃,要么服务会神秘地无法工作。
    • 在编译时绑定
      • 优点:
        • 十分快速
        • 保证服务可用
      • 缺点:
        • 不能方便地更改服务提供器
          因为绑定发生在编译期,所以任何时候你想要变动服务,就必须重新编译并且重启游戏。
    • 在运行时配置
      通常来说,这表示加载一份配置文件来标示服务提供器,然后使用反射来在运行期实例化这个类。
      • 优点:
        • 不需重编译就能切换服务提供器
        • 非程序员能够更换服务提供器
        • 一份代码库能够同时支持多份配置
      • 缺点:
        • 但比较复杂和十分重量级
        • 定位服务需要时间
  2. 当服务不能被定位时发生了什么
    • 让使用者处理
      最简单的解决办法就是转移责任。如果定位器找不到服务,那它就返回NULL。
      • 它让使用者决定如何处理查找失败
      • 服务使用者必须处理查找失败
    • 终止游戏
      使用断言来声明定位器的可用性
      • 使用者不需要处理一个丢失的服务
      • 如果服务没有被找到,游戏将会中断
    • 返回一个空服务
      • 使用者不需要处理丢失的服务
      • 当服务不可用时,游戏还可以继续
  3. 服务的作用域多大
    • 全局访问
      • 它鼓励整个代码库使用同一个服务
      • 但是我们对何时何地使用服务完全失去了控制
    • 访问被限制到类中
      • 我们控制了耦合
      • 但可能导致重复的工作

引擎落地对标

1. Unity(C#)

自定义 ServiceLocator 或 VContainer/Zenject

  • 在 Unity 中,如果不引入第三方 DI 框架(如 VContainer),通常会使用一个泛型单例实现静态 ServiceLocator 字典:Dictionary<Type, object>。
  • 游戏启动时(如 [RuntimeInitializeOnLoadMethod] 或 BootScene 挂载点)进行服务的 Register 与 Unregister。

2. Unreal Engine(C++)

Subsystem(子系统框架)

  • UE4.22 引入的 Subsystem 本质上就是引擎层直接内置并托管生命周期的“服务定位器”!
  • 开发者只需继承 UEngineSubsystem、UGameInstanceSubsystem 或 UWorldSubsystem,引擎就会自动创建实例并注册到全局定位器中。
  • 调用时直接使用:GetGameInstance()->GetSubsystem<UMyAudioSubsystem>(),完美解决了“手动注册”和“生命周期管理”的痛点。

依赖注入
这个术语表示了一个基本的思想。
假设你有一个类,依赖另外一个类。在书中例子,我们的Locator类需要Audio服务的一个实例。
通常,这个定位器应该负责为自己构建这个实例。依赖注入却说外部代码应该负责为这个对象注入它所需要的这个依赖实例。

拓展:断言

断言(Assertion) 是程序员在代码中放置的一种“硬性假设判定”。

它的核心逻辑是:“我承诺此时此地的某个条件必然为真(True)。如果它为假,说明程序逻辑已经出了严重 Bug,请立即强行中断程序,并在报错弹窗中告诉我是哪一行代码出了问题!”

1. 代码展示(UE C++ vs Unity C#)

  • UE (C++) 中的断言宏:
// 假设在游戏逻辑中,AudioService 绝不可以为空
IAudioService* Audio = ServiceLocator::GetAudio();

// 触发断言:如果 Audio == nullptr,在 Debug/Development 模式下直接中断崩溃并弹出堆栈信息
check(Audio != nullptr);
checkf(Audio != nullptr, TEXT("AudioService 未注册!请检查初始化顺序。"));

  • Unity (C#) 中的断言:
AudioService audio = ServiceLocator.GetAudio();

// 判定条件必须为 true,否则在 Editor/Development Build 中抛出断言异常或暂停编辑器
System.Diagnostics.Debug.Assert(audio != null, "AudioService 尚未注册!");
UnityEngine.Assertions.Assert.IsNotNull(audio, "AudioService 绝对不能为空!");

2. 断言(Assert)与 普通条件分支(If-Else / Exception)的区别

维度 普通 If 条件判断 / 异常处理 断言 (Assertion)
针对的目标 预期内的运行时错误

(如:网络超时、玩家密码输错、文件没找到)
不可接受的程序 Bug / 逻辑漏洞

(如:指针空了、数组越界、配置未注入)
处理方式 尝试修复、重试、弹出提示框告知玩家 立即中断程序/崩溃,方便开发者直接挂起调试器
构建版本(Build) 发布版本(Shipping/Release)必须保留 通常只在 Debug / Development 版本生效,Release/Shipping 构建中会被自动擦除,零性能损耗

[1] Robert Nystrom. 游戏编程模式[M]. 微信读书版

posted @ 2026-09-23 23:53  SEHOD  阅读(12)  评论(0)    收藏  举报