Flecs 观察者与响应式系统设计:把「变化」变成一等公民 Observer & Reactive System Design
Observer 是 Flecs 里最上瘾也最容易翻车的特性——用对了是手感,用错了是把 ECS 写回 OOP
为什么「变化通知」是响应式的第一块基石
先说个我自己的翻车现场。
几年前做一个俯视角战斗原型,我给生命值挂了一个「值变了就通知我」的回调:飘伤害数字、更新血条、播音效、镜头抖一下,顺手把仇恨表也刷了。代码短到什么程度?以前写一个系统要拆三个文件,那会儿一个回调全办完。我当时还挺得意,跟主程说这架构真香。
三个月后,一波精英怪的自爆炸进人堆。同屏两百多个单位,每只身上挂三四层持续伤害,一帧之内血量被写了三千多次,帧时间从 4 毫秒窜到 22 毫秒。翻性能面板,最贵的那个函数叫「刷新 UI 文本」——被叫了三千次,而屏幕上只有一块血条。
这就是响应式在 ECS 里的双面性。它把「变化」变成一等公民,你不用再问自己「我该在哪个系统里检查这个值变没变」,事件自己会找上门。可一旦你让它接管主流程,那条精心设计的数据导向管线就会退化成一团互相调用的回调网——那玩意儿有个更熟悉的名字,叫面向对象。
所以这篇要聊的不是「Observer 怎么注册」,而是「什么时候该让它开口、什么时候该让它闭嘴」。具体会拆四层:Observer 的同步回调本质、Observer 与 Trigger 的语义分家、用脏标记把「推」降噪成「拉」、以及关系变化那条最容易失控的传播链路。看完你至少能回答一个问题:项目里那些噼里啪啦响的回调,有几个是真该响的。
第一层:Observer 是「此刻的回调」,不是「另一个系统」
先把机制说清楚,不然后面全是空谈。
Flecs 的世界默认是拉模型:你写系统,系统里带查询,每帧扫一遍匹配的实体,一次处理一批。这是数据导向的根,好处是内存连续、可批处理、可并行、顺序可控。Observer 是另一条路——推模型:当某个操作发生的瞬间,Flecs 立刻同步回调你注册的处理器。注意「同步」两个字,它不是排队等下一帧,是在世界正被改动的那一刻就插进去执行。
这个差别不是风格问题,是状态可见性的问题。系统跑的时候,世界是「上一个阶段全部结束」的稳定快照;Observer 跑的时候,世界是半成品——你要加的组件可能刚加了一半,你要删的实体还没真删干净,同批次的其他改动可能还没落地。
落到玩家能感知的层面,你就能明白为什么这个区分要命。拿一次普攻命中举例:
系统流:碰撞系统算出命中,写一个「命中记录」组件;伤害系统在下一个阶段统一结算,扣血、判定死亡、加死亡标记;死亡系统再统一处理掉落和经验。链条长,但每一步都可预测、可断点、可并行,出问题你能一眼定位到是哪个环节。
Observer 流:命中记录一写进去,回调立刻结算伤害;掉血触发了血量回调,血量回调里判定死亡加了死亡标记;死亡标记的回调又去发掉落、发经验、发成就。一秒钟之内四跳走完。你感觉爽,其实你已经把顺序控制权交出去了——这四个环节谁先谁后,取决于你注册的顺序和当时世界的状态。
那是不是就该全用系统?也不是。有些东西晚一帧就出戏:受击音效晚 16 毫秒,玩家立刻觉得「不跟手」;粒子要在命中点炸开;网络包要即时入队。这类反馈的共性是——它对玩家来说是手感,不是结算。
我常用的一句判断标准:玩家能「感觉到延迟」的,用 Observer;玩家只能「看到结果」的,用系统。震屏、拖尾、顿帧、数字跳出来,这是手感;掉多少血、死没死、掉什么装备、算不算成就,这是结算。手感可以稍微乱一点,结算必须一步不错。
顺便说一句横向对比,帮你把「推」和「拉」这两极立住。EnTT 的 observer 是个「变更收集器」:你把它接到注册表上,它把符合条件的加入和移除记在列表里,你在合适的时候自己去遍历这个列表。它不回调你,它记账。这是个很聪明的折中——延迟一帧,但顺序永远在你手里。而 Flecs 选了更激进的那条:立刻回调,性能更好、手感更紧,代价就是上面说的那些半成品状态。
| 方案 | 触发时机 | 成本模型 | 顺序可控性 | 适合的事件密度 | 典型翻车点 |
|---|---|---|---|---|---|
| 系统 + 查询 | 管线里的固定位置 | 全量扫描,成本随匹配实体数线性增长 | 完全可控 | 高频、大面积变化 | 扫了一堆没变的实体,白烧 CPU |
| Observer 直连 | 操作发生的那一瞬间 | 每次触发一次回调,成本随「变化次数」增长 | 基本不可控 | 低频、稀疏事件 | 事件风暴、递归触发、读到半更新状态 |
| Observer 打标记 + 系统批量处理 | 变化时盖章,帧末统一处理 | 盖章极便宜,处理成本可预算 | 完全可控 | 中高频、需要合并 | 忘记清标记,脏数据累积 |
第二层:Observer 与 Trigger 的语义分家——「状态」不是「事件」
如果你翻过几年前的 Flecs 教程,会看到两套写法:Trigger 和 Observer。别慌,你现在只需要记 Observer 一条路。历史上的分工是:Trigger 是底层的事件匹配器,负责「当这个事件配上这个组件时通知我」;Observer 是它的高阶封装,能一次监听多种事件、能拿到组件值、能配通配符。
从 3.x 开始官方把 Trigger 标记为弃用,4.x 直接把它收掉,能力合进 Observer。这个演进本身就是个信号:框架在帮你减少概念数量。我们这行有个迷信,觉得概念越多越专业,其实正相反——每多一个概念,团队里就多一批人会写错。
但 Trigger 留下的那点语义差异值得懂,因为它对应两种完全不同的脑内模型:
事件订阅模型(Trigger 的思路):我订一个事件,事件来了我处理,处理完世界继续跑。适合表达「回合结束」「关卡加载完成」「技能释放」「网络包收到」这种瞬时发生、过期不候的东西。
条件监控模型(更早期还有过 Monitor 那一层):当「满足某条件的实体超过 N 个」时通知我。适合表达「警戒值满了」「连杀提示」「资源见底」。这类需求本质上是「持续成立的状态」,不是「一瞬间的事件」。
Flecs 最后用 Observer 统一了这些,代价是条件判断要你自己在回调里写,好处是你脑子里只剩一个开关。
接下来是这一层真正的重点,也是我这几年见人踩得最多的坑——把状态当事件用。
看一个能救命的故事。我自己给 Boss 做了个阶段标记:进二阶段就加一个「狂暴」标签,标签的回调里播变身动画、换 BGM、拉一次全场冲击波。上线测试,Boss 被打回一阶段再进二阶段,第二次变身没播。为什么?因为标签的「加」是幂等的——同一个标签已经在身上,再加一次不产生结构变化,回调根本不响。
于是有人(我)开始自作聪明:进阶段前先 remove 再 add。这回响是响了,但两次操作会被合并进同一个批次,某些情况下你又看不到你想要的那一次。最后老老实实改成一个「阶段变更」组件,里面塞个序号,每次进阶段序号加一——这种「值更新」的语义天然每次都会响,问题解决。
把这件事抽象成一句话就是:标签是状态,不是事件。你把状态当事件用,迟早要还。
| 事件类型 | 语义 | 触发次数 | 正确的表达对象 | 典型误用 |
|---|---|---|---|---|
| OnAdd | 结构变化:这个东西被挂上来了 | 只响一次,重复挂不响 | 进入某个状态:中毒、眩晕、死亡标记 | 拿它当「每次都通知我」的事件 |
| OnSet | 值写入:这个数据被改了 | 每次写都响,值不变也响 | 属性变化、阶段切换、带序号的信号 | 每帧无脑写一遍,制造事件风暴 |
| OnRemove | 结构变化:这个东西被摘掉了 | 只响一次 | 状态退出:解控、buff 到期、清理引用 | 在回调里访问已被移走的目标 |
| 自定义事件 | 业务语义:这件事发生了 | 你发几次响几次 | 回合结束、伤害结算完毕、技能命中 | 什么都用自定义事件,绕过组件模型 |
| 删除 / 删除目标 | 实体或关系目标被销毁 | 一次 | 弱引用清理、UI 卡片销毁 | 在这做重活,拖慢销毁流程 |
这里插一个我很佩服的公开案例:《守望先锋》的自研 ECS。他们在 GDC 那场讲架构和网络同步的分享里,把组件明确分成两类——普通组件,和一种叫「状态(state)」的特殊组件。状态是什么?就是「变了之后外面必须知道」的那些数据,因为网络同步要靠它做差异比对。而普通组件改了就改了,没人需要被通知。
这个分类的狠的地方在于:状态是被严格限制、且基本不允许随意增删的(要保证网络层能预测内存布局)。说白了,他们在设计层面上做了一次「事件预算」——全游戏只有很小一部分数据有资格触发通知,剩下的全是哑巴数据。你想想,如果他们把每个组件都做成会喊话的状态,那套 60Hz 的回滚同步早就崩了,带宽和确定性两项都撑不住。
玩家那边感受到的是什么?是老生常谈的「跟手」。但「跟手」不是玄学,它是分层的产物:必须立刻反馈的(命中顿帧、技能前摇、准星反馈)走高频通道,可以晚一点结算的(伤害数字、击杀播报、成就)走合并通道。这个分层你完全可以照抄进 Flecs——先问自己「这个变化,外部真的有人关心吗」,再决定它配不配拥有一个 Observer。
第三层:脏标记——把「推」降噪成「拉」的重构示范
这一层讲实操。场景选最常见也最容易翻车的:UI 面板。
角色身上 20 个属性,一个 AOE 让 200 个怪掉血,每个怪的血量变化都直连 UI。理论上屏幕刷新率 60,一帧能画 60 次;实际上一帧能叫醒 UI 系统 4000 次,而屏幕上只有一块血条、一个数字。这就是我开头那个 22 毫秒的来源。
重构分三步,我个人管它叫「盖章—收网—擦桌子」:
盖章:Observer 里只干一件事——给需要刷新的东西打个脏标记。可以是一个空标签,也可以是个带版本号的组件(版本号的好处是能顺带解决「刷新过程中又变了」的问题)。回调里不要碰 UI、不要碰文本、不要做任何有成本的事。
收网:一个专门的刷新系统放在管线末尾,扫一遍带脏标记的实体,去重、合并,一次性把结果推给 UI。
擦桌子:刷完清标记,否则下一帧你还在刷上一帧的东西。
这三步换来的不只是性能。更值钱的是顺序可控——所有 UI 更新发生在同一时刻,玩家永远不会看到「血条已经变了但数字还没变」的中间态。做过血条动画的人都懂,那种中间态比卡顿更让人出戏。
那到底哪些东西该立刻响、哪些该合并、哪些可以拖?我给团队定的三层分级,用了三年还在用:
| 级别 | 典型对象 | 延迟容忍 | 实现方式 | 为什么 |
|---|---|---|---|---|
| L0 立即 | 音效、粒子、震屏、网络包入队、顿帧 | 一帧都不能等 | Observer 里直接干,但只干这一件事 | 人眼对人耳对动作的延迟感知在十几毫秒量级,晚一帧就「不跟手」 |
| L1 帧末合并 | 血条、数字、小地图图标、仇恨表、任务进度 | 100 毫秒以内都无感 | 打脏标记,帧末统一刷 | 合并后成本从 N 次降到 1 次,N 往往是几百上千 |
| L2 惰性 | 成就检查、自动存档、寻路重算、光照更新 | 秒级都行 | 进队列,按预算每帧做几个 | 这些活单次成本高,集中做会撞帧,摊开做才平滑 |
再说一个反方向的案例,帮你把这套思路钉死:《城市:天际线 2》。这款游戏跑在 Unity DOTS 上,几十万市民和车辆在 ECS 里跑模拟。DOTS 里压根没有 Flecs 这种回调式观察者,它的变更检测是「版本号 + 过滤器」——系统遍历时,只挑本帧变过的实体处理。
这就是纯粹的拉模型。好处太明显了:批量、可并行、没有回调栈、没有半更新状态;代价是延迟至少一帧,而且你必须在数据里留版本号这些「记账」字段。
为什么一个几十万实体规模的模拟必须这么干?你想想如果每个市民的心情变化都要通知 UI——几十万条通知涌向一个只显示当前选中市民的面板。荒谬吧?所以他们的做法天然就是「UI 想看的时候自己去取快照」,模拟层根本不知道 UI 的存在。
我对这件事的判断很明确:Flecs 给了你推的能力,但你的默认选项应该是拉。推是给那些「晚一帧就出戏」的东西准备的,是稀缺资源,不是基础设施。如果你的项目里 Observer 的触发量已经超过了系统处理量的一成,说句不好听的,你已经在写事件驱动的 OOP 了,只是恰好用了 ECS 的容器。
第四层:关系变化的传播链路——ChildOf 与 IsA 的级联地狱
关系是 Flecs 最有辨识度的东西(这块 [08] 篇拆过底层),而它跟观察者一碰,就是本篇最容易失控的地方。
先看看玩家能感知到什么。角色骑马,马跑人跟着跑;换武器,武器出现在手上对应的挂点;给角色换个阵营,全城的守卫态度瞬间翻转;给 Boss 挂个「精英模板」,它所有属性按模板放大——最后这个是 IsA,模板继承。
这些体验在数据层是什么样?本质上都是「一条关系边被加上了或者被删掉了」。ChildOf 加一条边,意味着这个实体的世界坐标要跟着父节点重算,而且它的所有子孙都要重算。IsA 加一条边,意味着继承链上的属性要重新合并一遍。
问题就在这:这些操作天然会触发结构变化事件,而结构变化的传播是乘法的,不是加法。
两个具体的坑,我都亲身踩过:
坑一,通配关系的乘法效应。一个父节点下面挂 5000 个子实体,你用一个监听「任意父节点」的通配观察者去收挂载事件。你以为是「一次层级变化」,实际是 5000 次回调,每次都要算一遍匹配。批量创建 UI 列表的时候这个数还会更难看。
坑二,级联深度。你在回调里顺手递归更新子节点的变换,一条 20 层深的骨骼链就是 20 层回调嵌套。栈深还是小事,要命的是顺序取决于插入顺序——同一帧里你先挂了 A 后挂了 B,A 的子节点可能用着上一帧的父节点坐标。这种 bug 表现为「偶尔抖一下」,最难查。
正确姿势跟第三层一样,只是把对象从 UI 换成变换和属性:关系变化只盖章,传播交给固定顺序的系统。挂上父节点?打一个「层级脏」标记。摘掉父节点?打一个「层级脏」标记。然后一个变换系统在渲染之前,自顶向下跑一次,把整棵子树算完。IsA 同理:属性脏标记 + 一个属性重算系统。
我画给你看这条链路长什么样。玩家换上一把 +10 力量的剑:剑的模板关系被加上(第一跳,Observer)→ 角色属性打脏(第二跳,盖章)→ 属性系统在帧末重算一次并发表(第三跳,系统)→ 面板打脏(第四跳,盖章)→ UI 系统刷新(第五跳,系统)。五跳里只有第一跳是推,剩下四跳都是拉。
这个模式我叫它「短推长拉」:用一次推,把变化送进拉的世界,然后让拉的世界按自己的节奏跑完。反过来写成「一路推到底」,这条链路就会变成一张网——每个节点都认识别的节点,加一个新系统要改五个回调,三个月后没人敢动。
| 关系变化 | 需要传播什么 | 传播方式 | 建议上限 | 超限降级 |
|---|---|---|---|---|
| ChildOf 挂载 | 世界变换、渲染排序、可见性 | 打层级脏标记,帧末自顶向下重算 | 单帧重算子树上限几千节点量级 | 超出则拆帧,一帧算一层 |
| ChildOf 摘除 | 同上 + 引用清理 | 打标记,不要递归清理 | 同上 | 进延迟销毁队列 |
| IsA 模板切换 | 属性合并、技能表刷新 | 打属性脏标记,帧末重算一次 | 单帧重算实体数按预算排队 | 队列化,按重要性排序 |
| 阵营 / 好感等自定义关系 | AI 态度表、UI 提示 | 打标记;只重算「与目标有关」的子集 | 别全量重扫 | 分帧 + 空间分区 |
呼应一下渲染那篇([12])的结论:变换层级千万别用递归回调去推,那等于把 O(节点数) 的活干成了 O(节点数 × 深度),还顺带把顺序搞乱。这块我吃过亏,别学我。
参数化设计工具:几张能直接贴进设计文档的表
下面这些数字都是经验刻度,不是物理定律。你拿去当起点,然后自己测。我最怕看到有人把别人的经验值当圣旨——包括我的。
表 A:事件方式选型表(按事件密度)
这里的「事件密度」指「每帧发生变化的实体数 ÷ 该组件涉及的总实体数」。这个比值决定了你的成本曲线长什么样。
| 密度 | 典型场景 | 推荐方案 | 理由 |
|---|---|---|---|
| < 0.5% | 死亡、升级、成就解锁、关卡事件 | Observer 直连,随便用 | 稀疏,回调开销淹没在噪声里 |
| 0.5% ~ 5% | 受击、拾取、状态异常 | Observer 只打标记,帧末批量处理 | 回调不算贵,但处理逻辑贵,必须合并 |
| 5% ~ 30% | 每帧移动的单位、附近的敌人 | 别用 Observer,写系统扫 | 回调的固定开销开始反超批量遍历 |
| > 30% | 全体单位每帧的坐标、动画时间 | 系统 + 批量操作,禁止逐实体回调 | 你在做的事本质就是一遍全量扫描,别绕路 |
表 B:三层响应分级的参数建议
| 参数 | L0 立即 | L1 帧末合并 | L2 惰性队列 |
|---|---|---|---|
| 延迟容忍 | 0 帧 | 1 帧(约 16ms),最多 100ms 内可接受 | 几百毫秒到几秒 |
| 合并窗口 | 不合并 | 同帧内多次变化合并为一次 | 同队列内按优先级排序 |
| 每帧预算 | 单次回调建议控制在几微秒量级 | 整个刷新阶段建议不超过总帧预算的 10%~15% | 每帧固定做 N 个,N 可配 |
| 失败姿势 | 宁可少做一些,不可做重活 | 超预算则丢标记、下帧再刷 | 超预算则继续排队,但要报警 |
表 C:观察者上线前自查清单
| 问题 | 不合格的样子 | 改法 |
|---|---|---|
| 这个回调里有几个副作用? | 播动画 + 改数据 + 刷 UI + 发网络包 | 拆成多个观察者,每个只干一件事 |
| 它会不会触发别的观察者? | 链式触发,无终止条件 | 加深度计数器,开发期超过 4 层直接打日志报警 |
| 有没有用标签当事件? | 靠加同一个标签来反复触发 | 换成带序号的组件 + 值更新语义 |
| 会不会每帧无脑写值? | 每帧把血量写成它原本的值 | 写之前比一下,或者用版本号门控 |
| 有没有挂通配关系观察者? | 「任意父节点」全场合监听 | 限定到具体父实体,或者只监听关键方向 |
| 能不能被一键关掉? | 没开关,出问题只能改代码 | 给观察者加启停开关,线上可紧急关闭 |
| 每帧触发多少次? | 没人知道 | 回调里埋计数器,按来源统计 Top10 |
| 它在 defer 里吗? | 以为立刻响,其实延后了 | 搞清批量操作的边界,需要立刻响的别塞进批里 |
双路径:五条铁律,和五个「什么时候该反悔」
初级路径:先记住五条,够你撑过第一个项目
一,手感用推,结算用拉。玩家能感觉到延迟的走观察者,只有结果重要的走系统。拿不准就归到「结算」,因为结算写错了是 bug,反馈慢了只是不够爽。
二,一个观察者只干一件事。看到回调里有三个以上副作用,立刻拆。这条比任何性能优化都管用。
三,进入状态用 OnAdd,数值更新用 OnSet,退出状态用 OnRemove。这三句话能解决九成的「怎么选」式提问。
四,关系变化只盖章,绝不递归。挂父节点、换模板,都只打脏标记,让帧末的传播系统去干脏活。
五,上线前埋一个计数器。按事件来源统计每帧触发次数,跑一次压力场景看 Top10。你会惊讶于那个第一名是谁——我的记录保持者是「鼠标悬停高亮」,一帧能响两百多次。
进阶路径:五个「什么时候该反悔」
一,当你的观察者开始互相认识了。如果 A 观察者的回调里在等 B 的结果,说明这条链应该合成一个系统。系统天生有顺序,回调没有。
二,当你查一个 bug 要打八个断点。这是可读性崩溃的信号。ECS 有个很真实的批评是「代码跳来跳去」(这事儿 [25] 篇会聊),观察者就是那把最快的刀。
三,当 defer 边界开始咬人。批量操作为了性能会把改动攒起来,观察者的事件也一起攒着,等批量结束才一块儿放出来。这时候「我明明刚加了它怎么没反应」会成为团队日常。要么别在批里期待立即反馈,要么把这条链改成系统。
四,当多线程把顺序打乱。工作线程上发的事件在哪个线程回调、能不能碰主线程的东西,这些边界在 [10] 篇里讲透了,但结论要在这里重复一次:观察者 + 并行处理 = 你自己给自己造的竞态。
五,当你想用它做「事件总线」。这是我见过最普遍的死法。用 ECS 的观察者去搭一套全局消息系统,谁都能发谁都能收,三个月后没有任何人能说出「这一帧发生了什么」。说句实话,你要真需要总线,外面挂一个正经的消息队列,别寄生在 ECS 里。
争议与常见坑:那把最顺手的刀,最容易割到自己
「观察者让 ECS 变回 OOP」这个批评,一半是对的。它本身不是坏事——推模型解决的是拉模型解决不了的问题。坏事是拿它当主循环。给你一条量化红线:如果观察者的触发量超过系统处理量的 10%,你已经不在写数据导向的代码了,你只是恰好用了 ECS 的容器。
坑一:值没变也响。OnSet 的语义是「被写了」,不是「变了」。每帧无脑把当前血量写回去,等于每帧给自己发一次事件。改法很土但很有效:回调里比一下新值旧值,或者用一个版本号门控写入。
坑二:递归触发。A 的回调改 B,B 的回调改 A。Flecs 不会帮你检测环,它只会忠实地一直调下去,直到栈炸或者你发现帧率不对。开发期加个深度计数器,超过四层就报警——这个便宜到不行的保险,救过我两次。
坑三:defer 期间的时序幻觉。批量操作里,事件会被攒到批量结束才放出来。你以为「加完就响」,实际上是「这批全加完才响」。这不叫 bug,叫设计,但你的直觉会认为它是 bug。
坑四:通配关系观察者的乘法。批量为 5000 个子实体挂父节点,回调不是一次,是 5000 次。而且你通常是在加载关卡的时候干这事,正好撞上「加载慢」这个最容易被骂的点。
坑五:半更新状态下读数据。OnRemove 的回调里,实体还在,但别指望它还在查询结果里;OnAdd 的回调里,同批次要加的其他组件可能还没落地。这条我在项目里立了规矩:回调里只信你自己收到的那个东西,别东张西望。
坑六:销毁时机的直觉错误。你在回调里销毁一个实体,它的清理又触发了别的观察者,那个观察者又去访问已经处于销毁流程中的目标。这类问题往往表现为「偶发崩溃,无法复现」,能查到你怀疑人生。
最后说个反直觉的观察:越强大的 ECS 框架,越容易写出不像 ECS 的代码。观察者就是最典型的那把刀——它太好用了,好用到你会忘了自己为什么选 ECS。
FAQ:几个真问题
问:观察者能替代系统吗?我能不能整个游戏都用观察者写?
技术上能跑,工程上不建议。判断标准很朴素:这个逻辑是「对所有匹配实体做同一件事」,还是「某个特定变化发生后做一件事」?前者写系统,后者写观察者。全用观察者的项目,性能通常比全用系统的差,因为回调是逐实体的,批量遍历是连续内存一次扫完——这是 [06] 篇讲的架构决定的,不是风格偏好。
问:OnAdd 和 OnSet 到底怎么选?
一句话口诀:进入状态用 OnAdd,数值更新用 OnSet,退出用 OnRemove。再加一条判断:如果这个变化发生两次你需要被通知两次,那就不该用 OnAdd。因为结构变化是幂等的,第二次加同一个东西不产生变化,回调不响。这条规则救过无数「Boss 二阶段不播动画」的 bug。
问:我在观察者回调里加了组件,为什么下一个观察者没立刻触发?
八成是 defer 边界。批量操作(或者调度器内部的延迟模式)会把改动攒起来,事件也跟着攒,等批量结束才一起放。想验证很简单:把这段代码从批量操作里挪出来单独跑,如果立刻就响了,那就不是观察者的问题,是时序的问题。
问:我的观察者就是不触发,常见原因有哪些?
按出现频率排:一,事件类型选错了,用 OnAdd 去等值更新;二,你以为的「变化」其实是幂等操作,压根没产生结构变化;三,通配写法写窄了或写宽了,实际匹配不到你以为的那些实体;四,它被禁用了(这个最容易被忘,尤其你在做热重载或者调试开关的时候);五,发生在 defer 里,你测的时候还没到放出来的时机。排查顺序就按这个来,能省你半天。
问:多线程下观察者安全吗?
「安全」这个词在这儿太含糊。准确的说法是:你得清楚每个观察者会在哪个线程回调,以及回调里碰的数据归谁。工作线程上发出的变化,可能就在工作线程上回调你,你在里面去改主线程的 UI 状态,那就是自己给自己埋雷。稳妥的做法是把跨线程的观察者砍到只剩「收集数据、打标记」两件事,所有真正干活的处理统一回到主循环做。这块的边界 [10] 篇写得很细,建议对着看。
问:Observer 写多了以后,怎么定位是哪个在拖性能?
别上重型工具,先在每个回调里埋一个计数器,按「来源 + 事件类型」分类。跑一遍最重的战斗场景,把 Top10 打出来。九成情况下问题出在两三个高频回调上,而且通常都是「每帧无脑写值」和「UI 直连」这两种。修完再看下一轮,比一上来就上完整剖析链路快得多(工具链的事另开一篇讲)。
最后留个问题给你,不用现在回答:如果你把项目里所有观察者全部删掉、只留系统,游戏会坏几处?如果只坏三处,恭喜你,那三处就是真正需要「推」的地方,剩下的都是你在用事件给自己找安全感。如果坏了两百处——那你其实写的不是 ECS,是个穿着 ECS 外套的消息中间件,这也没问题,但别骗自己说这是数据导向。