组件钩子:长在组件上的规矩
公会有条铁规:武器入库必登记、出库必销册,一件不漏。用上一节的 observer 实现——On<Add, Weapon> 登记、On<Remove, Weapon> 销册——能跑,但总让账房不踏实:observer 是外挂的,挂多少个、什么时候挂、会不会被人 despawn 掉,组件自己一概不知情。账房想要的是把规矩写进武器的出厂设定:只要 Weapon 这个组件存在一天,登记销册就雷打不动。
这就是组件钩子(component hooks)的定位:声明在组件定义上的生命周期回调,相当于组件的构造函数与析构函数:
//! Listing 8-8:组件钩子——长在 Weapon 组件上的登记规矩
use bevy::ecs::lifecycle::HookContext;
use bevy::ecs::world::DeferredWorld;
use bevy::prelude::*;
/// 武器组件:钩子直接声明在组件定义上
#[derive(Component)]
#[component(on_add = register_weapon, on_remove = deregister_weapon)]
struct Weapon {
name: &'static str,
}
/// 公会的武器登记簿
#[derive(Resource, Default)]
struct Ledger(Vec<&'static str>);
/// 钩子:Weapon 一上身就登记。签名是固定的 fn(DeferredWorld, HookContext)
fn register_weapon(mut world: DeferredWorld, ctx: HookContext) {
let name = world.get::<Weapon>(ctx.entity).unwrap().name;
world.resource_mut::<Ledger>().0.push(name);
println!(" 账房(钩子):{name} 登记入册。");
}
/// 钩子:Weapon 移除(含销毁)时注销
fn deregister_weapon(mut world: DeferredWorld, ctx: HookContext) {
let name = world.get::<Weapon>(ctx.entity).unwrap().name;
world.resource_mut::<Ledger>().0.retain(|n| *n != name);
println!(" 账房(钩子):{name} 销册。");
}
fn main() {
let mut app = App::new();
app.init_resource::<Ledger>()
// 同一对生命周期事件,再各挂一个 observer,看谁先谁后
.add_observer(|add: On<Add, Weapon>, q: Query<&Weapon>| {
println!(" 巡查员(observer):看到 {} 入库。", q.get(add.entity).unwrap().name);
})
.add_observer(|remove: On<Remove, Weapon>, q: Query<&Weapon>| {
println!(" 巡查员(observer):看到 {} 出库。", q.get(remove.entity).unwrap().name);
})
.add_systems(Update, workshop_script);
for frame in 1..=2 {
println!("—— 第 {frame} 帧 ——");
app.update();
}
}
fn workshop_script(
weapons: Query<Entity, With<Weapon>>,
ledger: Res<Ledger>,
mut commands: Commands,
mut frame: Local<u32>,
) {
*frame += 1;
match *frame {
1 => {
println!("老锤:打好两把武器。");
commands.spawn(Weapon { name: "铁剑" });
commands.spawn(Weapon { name: "长戟" });
}
2 => {
println!("老锤:两把都出货了。出货前在册:{:?}", ledger.0);
for entity in &weapons {
commands.entity(entity).despawn();
}
}
_ => {}
}
}Listing 8-8:组件钩子——长在 Weapon 组件上的登记规矩
cargo run -p ch08-events-observers --example listing-08-08—— 第 1 帧 ——
老锤:打好两把武器。
账房(钩子):铁剑 登记入册。
巡查员(observer):看到 铁剑 入库。
账房(钩子):长戟 登记入册。
巡查员(observer):看到 长戟 入库。
—— 第 2 帧 ——
老锤:两把都出货了。出货前在册:["铁剑", "长戟"]
巡查员(observer):看到 铁剑 出库。
账房(钩子):铁剑 销册。
巡查员(observer):看到 长戟 出库。
账房(钩子):长戟 销册。先对账写法,再看输出里藏的那条铁律。
- 声明:
#[component(on_add = 函数名, on_remove = 函数名)]直接挂在derive(Component)旁边。五个钩子位与上一节的五个事件一一对应:on_add、on_insert、on_replace、on_remove、on_despawn,触发时机也完全相同。 - 签名是死的:
fn(DeferredWorld, HookContext)——一个普通函数指针。钩子不是系统,没有资格声明Query、Res那一套参数,也不能用捕获环境的闭包。 DeferredWorld:受限版的世界访问。读写现成的组件和资源没问题(world.get、world.resource_mut),但结构性改动——增删组件、生成销毁实体——必须经world.commands()排队。毕竟钩子运行在世界变更的半道上,不能再当场大兴土木。HookContext:告诉你这次触发涉及哪个实体(ctx.entity)、哪个组件。
现在看输出顺序。第 1 帧添加时,钩子先于 observer;第 2 帧移除时,observer 先于钩子。这不是巧合而是设计:钩子既然是构造与析构函数,就理应第一个到场、最后一个锁门——构造时它先把组件的配套状态立好,observer 们才进场;析构时 observer 们先办完各自的事,它最后清场关灯。顺带留意第 2 帧没有出现 Despawn 字样的行——despawn 在这里是以 Remove 的身份被两边感知的,和上一节五幕剧的结论一致。
一个萝卜一个坑
钩子与 observer 的另一处分野:同一个组件的同一种钩子只能有一个。observer 爱挂几个挂几个,钩子的坑位是独占的——再塞一个,引擎当场翻脸:
//! Listing 8-9:一个组件、同种钩子只能有一个
use bevy::ecs::lifecycle::HookContext;
use bevy::ecs::world::DeferredWorld;
use bevy::prelude::*;
/// 组件定义里已经声明了 on_add 钩子
#[derive(Component)]
#[component(on_add = register_weapon)]
struct Weapon;
fn register_weapon(_world: DeferredWorld, _ctx: HookContext) {
println!("账房:登记入册。");
}
fn second_hook(_world: DeferredWorld, _ctx: HookContext) {
println!("二账房:我也想记一笔。");
}
fn main() {
let mut app = App::new();
// 试图给 Weapon 再挂第二个 on_add 钩子
app.world_mut()
.register_component_hooks::<Weapon>()
.on_add(second_hook);
}Listing 8-9:一个组件、同种钩子只能有一个
cargo run -p ch08-events-observers --example listing-08-09thread 'main' panicked at ...\bevy_ecs-0.18.1\src\lifecycle.rs:187:14:
Component already has an on_add hookregister_component_hooks 是钩子的运行时注册入口(不改组件定义、给第三方组件补钩子时用得上),但它的两条前提都写在 panic 的剧本里:该种钩子还没被占坑——derive 上声明过的也算占坑,这正是上面这一幕的死因;以及该组件还没有被任何实体使用。这两条限制让钩子保持“出厂设定”的本色:它描述的是组件固有的行为,不该在世界已经跑起来之后被偷偷换掉。
两件工具放在一起,分工就清楚了:
| 组件钩子 | Observer | |
|---|---|---|
| 数量 | 每组件每种至多一个 | 不限 |
| 何时确定 | 组件定义时(或世界启动前) | 运行时随挂随卸 |
| 本职 | 维护组件的不变式:索引、登记、配套状态 | 业务联动:特效、音效、玩法逻辑 |
经验法则:钩子管“这个组件自身必须成立的事”,observer 管“别人关心这个组件的事”。登记簿跟着 Weapon 走天经地义,写成钩子;点火特效是火焰附魔的外延玩法,留给 observer。Bevy 自己也是这么分的:第 9 章要讲的父子关系,靠的正是关系组件上的钩子在维护双向链接——到时候你会认出这套手法。