AppExit:用消息谢幕
从第 3 章起,所有实验都靠 main 里的 for 循环手动驱动——不是不会用 run(),而是一直缺一样东西:让 App 从内部决定何时结束的手段。现在它有名字了。
AppExit 是一条引擎自用的消息。App::new() 在构建时就替你调过 add_message::<AppExit>();runner(第 2 章装上的那位)每跑完一帧 update() 就检查一次这条通道,发现消息就结束循环。也就是说,任何系统只要持有 MessageWriter<AppExit>,就拥有了关掉整个程序的权力——和 RailHit 用的是同一套机制,没有任何特殊待遇。它有两个变体:AppExit::Success 表示正常退出,AppExit::Error(非零错误码) 表示出错收场。
把它交到老板手里,凑齐全章人马——三位车手、安静下来的 DJ、灯光师、记分员,外加真正的 run() 主循环。撞满 10 次,全场打烊:
//! 第 7 章综合示例:碰碰车场开业
//! 三位车手各自撞护栏写消息;DJ、灯光师、记分员互不相识地各读各的;
//! 撞满 10 次,老板写一条 AppExit,全场打烊
use bevy::app::ScheduleRunnerPlugin;
use bevy::prelude::*;
use std::time::Duration;
/// 消息:有车撞上了护栏,带上车手名字
#[derive(Message)]
struct RailHit {
driver: &'static str,
}
/// 全场累计撞击数
#[derive(Resource, Default)]
struct HitCount(u32);
#[derive(Component)]
struct Car {
driver: &'static str,
pos: i32,
velocity: i32,
}
fn main() {
App::new()
// 真正的主循环:每 100 毫秒跑一帧,直到读到 AppExit
.add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
Duration::from_millis(100),
)))
.add_message::<RailHit>()
.init_resource::<HitCount>()
.add_systems(Startup, opening)
.add_systems(
Update,
(
banner,
drive,
play_sound,
// 没有新碰撞的帧,灯光师整帧不跑
light_show.run_if(on_message::<RailHit>),
update_score,
close_up,
)
.chain(),
)
.run();
println!("(run() 返回,进程结束)");
}
fn opening(mut commands: Commands) {
println!("碰碰车场开业!三位车手上场。");
// 12 格的大直道,三辆车速度各异,撞护栏的节奏就此岔开
for (driver, velocity) in [("阿莱", 4), ("小柔", 6), ("老高", 3)] {
commands.spawn(Car {
driver,
pos: 0,
velocity,
});
}
}
/// 报幕:让"同一帧"在输出里看得见
fn banner(mut frame: Local<u32>) {
*frame += 1;
println!("—— 第 {} 帧 ——", *frame);
}
/// 唯一的写者:谁撞护栏就替谁写一条,同一帧可能写多条
fn drive(mut cars: Query<&mut Car>, mut hits: MessageWriter<RailHit>) {
for mut car in &mut cars {
car.pos += car.velocity;
if car.pos == 0 || car.pos == 12 {
car.velocity = -car.velocity;
hits.write(RailHit { driver: car.driver });
}
}
}
/// DJ:同一帧几辆车齐撞也只响一声——is_empty + clear 模式
fn play_sound(mut hits: MessageReader<RailHit>) {
if !hits.is_empty() {
hits.clear();
println!("DJ:砰!");
}
}
/// 灯光师:函数体不用碰消息,run_if(on_message) 替他把关
fn light_show() {
println!("灯光师:闪一下");
}
/// 记分员:一条消息记一笔,认得每位车手
fn update_score(mut hits: MessageReader<RailHit>, mut count: ResMut<HitCount>) {
for hit in hits.read() {
count.0 += 1;
println!("记分员:{}撞护栏,全场第 {} 次", hit.driver, count.0);
}
}
/// 老板:撞满 10 次该歇业了——写一条 AppExit
fn close_up(count: Res<HitCount>, mut exit: MessageWriter<AppExit>) {
if count.0 >= 10 {
println!("老板:撞满 10 次,打烊!");
exit.write(AppExit::Success);
}
}Listing 7-9:完整示例——会自己打烊的碰碰车场(src/main.rs)
cargo run -p ch07-messages碰碰车场开业!三位车手上场。
—— 第 1 帧 ——
—— 第 2 帧 ——
DJ:砰!
灯光师:闪一下
记分员:小柔撞护栏,全场第 1 次
—— 第 3 帧 ——
DJ:砰!
灯光师:闪一下
记分员:阿莱撞护栏,全场第 2 次
—— 第 4 帧 ——
DJ:砰!
灯光师:闪一下
记分员:小柔撞护栏,全场第 3 次
记分员:老高撞护栏,全场第 4 次
—— 第 5 帧 ——
—— 第 6 帧 ——
DJ:砰!
灯光师:闪一下
记分员:阿莱撞护栏,全场第 5 次
记分员:小柔撞护栏,全场第 6 次
—— 第 7 帧 ——
—— 第 8 帧 ——
DJ:砰!
灯光师:闪一下
记分员:小柔撞护栏,全场第 7 次
记分员:老高撞护栏,全场第 8 次
—— 第 9 帧 ——
DJ:砰!
灯光师:闪一下
记分员:阿莱撞护栏,全场第 9 次
—— 第 10 帧 ——
DJ:砰!
灯光师:闪一下
记分员:小柔撞护栏,全场第 10 次
老板:撞满 10 次,打烊!
(run() 返回,进程结束)三辆车速度各异(4、6、3),撞护栏的节奏就此岔开。对着输出清点本章的工具,重点看第 4、6、8 帧——两辆车同帧撞墙的时刻:
- 唯一的写者,多条消息:
drive巡视三辆车,谁撞护栏就替谁write一条,第 4、6、8 帧各写了两条;消息带着车手名字,这是 Listing 7-3 的数据派上了用场; - DJ 只响一声:
is_empty+clear,有就响、几条不管——第 4 帧两次碰撞,一声“砰”; - 灯光师整帧免打扰:
run_if(on_message)把关,第 1、5、7 帧没有新消息,他根本没上班; - 记分员逐条入账:常规
read()循环,第 4 帧老老实实记了小柔、老高各一笔——DJ 的clear()和灯光师条件里的消费都没影响他,每个读者的游标互不相干; - 打烊走的也是消息:老板看到第 10 次,写一条
AppExit::Success;本帧结束后 runner 检查到它,循环终止,run()返回,main的最后一行得以执行。第 2 章 Listing 2-3 那个“每秒跑一轮”的ScheduleRunnerPlugin,至此终于知道怎么停。
五个系统,没有谁认识谁;车手与三位听众之间隔着一条 RailHit 通道,老板与整个主循环之间隔着一条 AppExit 通道——这就是消息作为“解耦利器”的全部含义。
小结
- Message 是缓冲消息:
#[derive(Message)]定义、add_message注册(忘了注册,首帧 panicMessage not initialized),MessageWriter写、MessageReader读;类型就是频道,写者与读者唯一的共同知识是消息类型本身 - 一写多读:每个读者(含
on_message条件)各有私有游标,人人读到全部消息、各恰好一次;读者之间可并行,写者ResMut独占;加功能 = 加读者,写者零改动 - 双缓冲定寿命:消息可见两帧——写入帧加下一帧,
First里的清理系统每帧换仓倒旧。读者排在写者后面当帧送达,排在前面慢一帧但不丢;读得更慢就静默丢弃。带TimePlugin时,清理会等FixedUpdate看过一眼再动手 - 消息是通知,不是存档:需要留底的信息读到后写进 Resource/Component
- 读端三姿势:逐条
read();is_empty+clear“有就办一次”(只动自己的游标);run_if(on_message)整帧把关。外加在途改写的MessageMutator - 缓冲就是资源:同型读写同系统撞出 B0002,
ParamSet化解;Commands::write_message走命令队列、同步点才入仓 AppExit:引擎自用的退出消息,任何系统写一条即可结束程序;Success/Error(码)在main返回它时映射为进程退出码
练习
- 零改动扩容:给 Listing 7-3 加一位“广播员”:金护栏被撞时播报“金护栏又立功了!”,普通护栏不出声。要求不改动
drive、DJ 和记分员的任何一行。写完数一数总共新增了几行代码——这个数字就是消息解耦的价码。 - 寿命实测:把 Listing 7-5 记分员的瞌睡周期从每 3 帧改成每 2 帧,先预测他能记下哪些、丢哪些,再运行验证;然后改成每 4 帧再来一轮。最后思考:每 2 帧恰好不丢的结论依赖代码里哪一行排序约束?(提示:把
chain里两个系统对调,再跑一次每 2 帧的版本。) - 退出码:把 Listing 7-9 的
main返回类型改成AppExit并返回run()的结果,再把老板写的消息换成AppExit::error()。运行后在 PowerShell 里执行echo $LASTEXITCODE,确认进程退出码从 0 变成了 1——AppExit实现了Termination,从main返回时会变成真正的进程退出码。
下一章是消息的镜像。Message 是拉模式:写者投递,读者下次轮到自己时才来取,中间隔着调度——这让批量处理高效,也意味着响应永远慢半拍,且无法瞄准“某一个实体”。当你需要“事情一发生就立刻处理”“精确通知挂在这个实体上的逻辑”时,就轮到 Event 与 Observer——0.17 之后,Event 这个名字专属于它们。