Skip to content

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 次,全场打烊:

rust
//! 第 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)

console
cargo run -p ch07-messages
text
碰碰车场开业!三位车手上场。
—— 第 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 注册(忘了注册,首帧 panic Message 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 返回它时映射为进程退出码

练习

  1. 零改动扩容:给 Listing 7-3 加一位“广播员”:金护栏被撞时播报“金护栏又立功了!”,普通护栏不出声。要求不改动 drive、DJ 和记分员的任何一行。写完数一数总共新增了几行代码——这个数字就是消息解耦的价码。
  2. 寿命实测:把 Listing 7-5 记分员的瞌睡周期从每 3 帧改成每 2 帧,先预测他能记下哪些、丢哪些,再运行验证;然后改成每 4 帧再来一轮。最后思考:每 2 帧恰好不丢的结论依赖代码里哪一行排序约束?(提示:把 chain 里两个系统对调,再跑一次每 2 帧的版本。)
  3. 退出码:把 Listing 7-9 的 main 返回类型改成 AppExit 并返回 run() 的结果,再把老板写的消息换成 AppExit::error()。运行后在 PowerShell 里执行 echo $LASTEXITCODE,确认进程退出码从 0 变成了 1——AppExit 实现了 Termination,从 main 返回时会变成真正的进程退出码。

下一章是消息的镜像。Message 是模式:写者投递,读者下次轮到自己时才来取,中间隔着调度——这让批量处理高效,也意味着响应永远慢半拍,且无法瞄准“某一个实体”。当你需要“事情一发生就立刻处理”“精确通知挂在这个实体上的逻辑”时,就轮到 Event 与 Observer——0.17 之后,Event 这个名字专属于它们。