Bevy 事件/状态 时序问题:官方路线图与社区方案

Bevy 核心团队对事件时序和状态生命周期时序的混乱已有明确认知,并通过版本迭代、里程碑 issue 和社区讨论逐步推进解决方案。以下是目前已落地、正在推进和已知的全部方案。

已落地的官方变更

0.17:Event 拆分为 Message + Event(Observer)

这是 Bevy 对”强制双缓冲”问题的第一次概念澄清:

概念机制时序
MessageMessageReader<T> 缓冲队列至少一帧延迟
EventObserver 触发即时响应(同帧)

关键 API:

  • World::trigger(event)同步即时触发所有匹配的 Observer
  • Commands::trigger(event) — 作为 Command 延迟到命令队列回放缓冲

这使开发者可以显式选择语义:需要缓冲时用 Message,需要即时响应用 Event + Observer。

0.18:Observer API 稳定化,Replace → Discard

0.18 没有引入新的时序架构,主要做了 API 清理:

  • TriggerOn
  • OnAddAdd, OnInsertInsert, OnReplaceReplace, OnRemoveRemove
  • 全局触发目标 Entity::PLACEHOLDERNone

但一个关键语义修正已落地:

  • **PR 22789 (Feb 12, 2026):OnReplace / on_replace 重命名为 OnDiscard / on_discard — 因为旧名字语义矛盾(它不仅在替换时触发,也在 remove/despawn 时触发)

0.19 前后:Observer deferred 修复

  • **Issue 14597 “Deferred doesn’t seem to work with observers” — Observer 中的 Deferred<T> 缓冲区 apply() 从未被调用,延迟事件永远不会触发
  • Resolution:通过 PR #22800 和 #22832(2026年2-3月)修复。Observer deferred 缓冲区现在会正确 flush 到世界命令队列

0.20:Observer EventPattern 简化泛型(PR #24013,2026-08-14 合并)

  • 问题On<E, B>B 类型参数是 bevy_ecs 中最常被误解的结构——大多数场景(picking、自定义事件)根本不需要它,只有生命周期过滤(On<Add, A>)才用。
  • 变更:新增 EventPattern trait(type Event: Event; type Components: Bundle),On 泛型改为 On<'w, 't, E: EventPattern>——不需要 B 参数的场景直接写 On<MyEvent>;生命周期 observer(On<Add, A>)保留过滤语义。
  • 意义:observer API 在 0.17(Event/Message 拆分)→ 0.18(On 稳定化)→ 0.20(泛型简化)连续三版打磨,On 从”最难用对的结构”逐步收敛为直观 API。M-Migration-Guide,0.20 迁移项。

正在推进的长期设计(Issue #20729)

这是目前最核心的官方设计讨论,由 ECS SME alice-i-cecile 强烈支持。

状态

  • 标签:A-ECS, C-Feature, D-Complex, S-Needs-Benchmarking, S-Needs-Design, X-Needs-SME
  • 里程碑:曾为 0.18/0.19现已移除里程碑(截至 2026-01-22),尚未确定落地版本

已完成的 Phase 1:语义重命名

  • ReplaceDiscard ✅ 已合并(PR #22789,2026-02-12)

待完成的 Phase 2:Before/After 结构化生命周期事件

核心问题:当前生命周期事件是复合组合(composite),开发者无法单独订阅”组件被添加之前”或”组件被覆盖之后”。

事件底层操作时机
AddAddAfter
InsertAdd + OverwriteAfter
DiscardOverwrite + RemoveBefore
RemoveRemoveBefore

缺失的原语(待添加):

新事件含义
Before<Add>组件即将被添加
After<Add>组件已被添加(现有 Add
Before<Discard>组件即将被覆盖/移除(现有 Discard
After<Insert>组件已被覆盖(现有 Insert
Before<Remove>组件即将被移除(现有 Remove
After<Remove>组件已被移除(待添加)

组合事件提案DataAdded):

observer.on::<DataAdded<(Option<&Disabled>, Has<Pressed>, &Hovered, &ButtonVariant)>>(|event| {
    let (disabled, pressed, hovered, variant) = event.data();
    *color = calculate_color(disabled, pressed, hovered, variant);
});

这可以将按钮状态爆炸(6 个独立 observer → 1 个组合 observer)大幅简化。

替代提案:统一枚举事件

viridia 提出另一种设计:用单一生命周期枚举事件替代多个独立事件:

enum LifecycleEvent {
    Added,
    Inserted,
    Discarded,
    Removed,
}
  • 缺点:Observer 会被更频繁地调用
  • 优点:注册的 Observer 总数更少

已知的状态时序 Issue

已修复

  • **Issue 10732 “Run all OnExit before all OnEnter” — 通过 StateTransitionSteps system sets 实现:所有 exit schedule 先于任何 enter schedule 执行

仍开放

Issue问题状态
#7633OnEnter 对默认状态的触发时机晚于预期已知问题,无近期修复计划
#14447同一状态转换(identity transition)不触发 OnExit/OnEnterOpen。社区提出 OnReset 新动词或 OnReenter/OnReexit 方案
#9130重新进入同一状态时应触发 OnEnter与 #14447 相关
#17708States / 生命周期钩子时序模糊与 #20729 重叠

当前 workaroundcustom_transitions 示例展示了手动添加 OnReenter / OnReexit schedule 的方法,但官方担心增加未使用时的 overhead,暂未纳入核心。

Hooks vs Observers 的执行顺序陷阱

根据 Discussion #19018,Bevy 存在一个严重且未文档化的执行顺序差异

场景执行顺序
Add/SpawnHook → Observer → Hook’s commands → Observer’s commands → 剩余命令队列
Remove/DespawnObserver → Hook → 后续命令

在 Remove/Despawn 场景下,Observer 先于 Hook 运行,与 Add 场景完全相反!

最佳实践

  • Hooks 应优先直接修改 DeferredWorld(而非使用 Commands)
  • 如果必须在 Hook 中使用 Commands 并确保它们在 Observer 之前执行,需要手动 flush 世界命令队列

社区方案

pyri_state — 计算状态 + 层级状态

Bevy 社区最成熟的状态机替代方案:

  • 计算状态(Computed States):状态可以由其他状态推导,而非显式设置
  • NextState 始终存储下一个状态:即使当前状态 == 下一个状态,也能检测到变化意图
  • 被官方 issue 多次引用(#14447, #18066)作为参考实现

bevy_picking_state_machine

Picking 系统的状态机替代方案,用于处理鼠标/触摸交互状态(Hover/Pressed/None),可作为 PickingInteraction 的 drop-in replacement。

事件驱动的 OnReset 模式(shanecelis 提案)

不修改核心 schedule,用事件实现重置语义:

#[derive(Event)]
pub struct ResetState<S>(pub S);
 
pub fn on_reset<T: States>(state: T) -> impl FnMut(EventReader<ResetState<T>>) -> bool + Clone {
    move |mut reader: EventReader<ResetState<T>>| reader.read().any(|e| e.0 == state)
}

然后:

.add_systems(Update, (cleanup_game, setup_game).chain().run_if(on_reset(AppState::InGame)))

run_if + System Sets 替代方案(benfrankel 提案)

“Users can have any verb they want (without adding overhead for users that don’t want them) if we use run conditions + system sets instead of schedules for ‘on enter’ / ‘on exit’ etc.”

核心思想:放弃 OnEnter/OnExit schedule 抽象,改用每帧查询当前/上一帧状态差异的 run conditions。

对 Bevy 开发者的实际建议

当前版本(0.18/0.19)

  1. 即时响应用 ObserverWorld::trigger 同帧即时,Commands::trigger 延迟到命令缓冲回放
  2. 广播通知用 Message:接受至少一帧延迟,不要对 MessageReaderrun_if
  3. 避免在 OnEnter 中发事件:两层延迟叠加导致难以调试的时序 bug
  4. Hooks 优先直接修改世界:不要用 Commands,执行顺序在 Add 和 Remove 场景下是反的
  5. 状态重置需求用 OnTransition + 手动检测OnTransition 可以检测 identity transition,虽然 OnEnter/OnExit 不行

未来展望

  • **Issue 20729 是官方承认时序混乱的核心 ticket,Phase 2(Before/After 结构化事件)一旦落地将根本性解决生命周期事件的模糊性
  • 但 #20729 已无 milestone,说明设计复杂度(benchmarking + SME review)导致不会很快落地
  • Bevy 的长期演进方向:Observer 逐步承担更多原本由回调式 schedule(OnEnter/OnExit)承担的职责,但两者并存期间语义混乱仍会持续

相关页面