消息工具箱
三辆车并排冲撞,每一帧三声巨响。实习 DJ 拿着 Listing 7-1 的剧本逐条放音效——砰、砰、砰,吵翻天。老板捂着耳朵提要求:音效这种事,“有没有”比“有几条”重要,一帧只准响一声。
这一节就从这个问题出发,把读端剩下的工具一次清点完。先看三位听众的不同做派:
/// 实习 DJ:逐条响应——一帧三撞就放三声,吵翻天
fn rookie_dj(mut hits: MessageReader<RailHit>) {
for hit in hits.read() {
println!("实习 DJ:给{}来一声砰!", hit.driver);
}
}
/// 老 DJ:有就响一声,几条不管——is_empty + clear 模式
fn veteran_dj(mut hits: MessageReader<RailHit>) {
if !hits.is_empty() {
hits.clear();
println!("老 DJ:砰!一声就够。");
}
}
/// 灯光师:函数体连消息都不碰,run_if(on_message) 替他把关
fn light_show() {
println!("灯光师:全场闪一下");
}接线照旧,唯一的新面孔挂在灯光师身上:
fn main() {
let mut app = App::new();
app.add_message::<RailHit>()
.add_systems(Startup, spawn_cars)
.add_systems(
Update,
(
drive,
rookie_dj,
veteran_dj,
// 没有新碰撞的帧,灯光师整帧不跑
light_show.run_if(on_message::<RailHit>),
)
.chain(),
);
for frame in 1..=2 {
println!("—— 第 {frame} 帧 ——");
app.update();
}
}Listing 7-6:实习 DJ 与老 DJ——“有就响一声”的两种写法
cargo run -p ch07-messages --example listing-07-06—— 第 1 帧 ——
实习 DJ:给阿莱来一声砰!
实习 DJ:给小柔来一声砰!
实习 DJ:给老高来一声砰!
老 DJ:砰!一声就够。
灯光师:全场闪一下
—— 第 2 帧 ——
实习 DJ:给阿莱来一声砰!
实习 DJ:给小柔来一声砰!
实习 DJ:给老高来一声砰!
老 DJ:砰!一声就够。
灯光师:全场闪一下三个人听的是同一条通道,各自的收成对账如下:
- 实习 DJ:
read()逐条遍历——这是 Listing 7-1 的正确写法用错了场合,三撞三响。 - 老 DJ:
is_empty()先不消费地探一眼有没有新货,有就clear()把游标一步推到底、响一声完事。这对组合就是“有就办一次,有几条不管”的标准写法。len()是同族工具,不消费地数个数。 - 灯光师:把同一个判断前移到调度层——运行条件
on_message::<M>在有新消息时放行,否则整帧不跑。第 6 章条件清单里欠的那一项,今天补上。函数体因此干干净净,连MessageReader参数都不用声明。
关键细节藏在执行顺序里:老 DJ 的 clear() 排在灯光师之前,灯光师却照样每帧点亮——因为 clear() 清的是老 DJ 自己的游标,不是缓冲。消息本体还在 Messages<M> 里,实习 DJ、灯光师的条件、以及任何别的读者照常读到。on_message 同理:条件内部自带一个游标,每次评估顺手消费新消息,但那是条件私有的,不影响任何真正的读者。
写端顺带补全两个变体:消息类型实现了 Default 时(空结构体最常见),write_default() 省去手写构造;一次写一批用 write_batch(iter),比逐条 write 高效。
途中改写:MessageMutator
维修工嫌右护栏挨撞太狠,给它装了一条缓冲垫。这道工序既不是写也不是读——它要在消息送达下游之前改掉里面的数:
/// 维修工给右护栏装了缓冲条:途经的碰撞消息被吸掉 2 点力道
fn cushion(mut hits: MessageMutator<RailHit>) {
for hit in hits.read() {
if let Rail::Right = hit.rail {
hit.force -= 2;
println!("缓冲条:噗——卸掉 2 点力道");
}
}
}MessageMutator<M> 像 MessageReader 一样带游标逐条遍历,但给出的是 &mut M。流水线照旧用 chain 排定,写 → 改 → 读:
fn main() {
let mut app = App::new();
app.add_message::<RailHit>()
.add_systems(Startup, spawn_car)
// 写 → 改 → 读,三段流水线
.add_systems(Update, (drive, cushion, play_sound).chain());
for frame in 1..=4 {
println!("—— 第 {frame} 帧 ——");
app.update();
}
}Listing 7-7:MessageMutator——缓冲条在途中卸力
cargo run -p ch07-messages --example listing-07-07—— 第 1 帧 ——
缓冲条:噗——卸掉 2 点力道
DJ:砰!(力道 2)
—— 第 2 帧 ——
DJ:砰!(力道 4)
—— 第 3 帧 ——
缓冲条:噗——卸掉 2 点力道
DJ:砰!(力道 2)
—— 第 4 帧 ——
DJ:砰!(力道 4)撞右护栏(第 1、3 帧)的消息写入时力道是 4,DJ 拿到手已被削成 2;左护栏(第 2、4 帧)没装垫子,原值送达。车手照旧不知道这道工序存在——加工序和加读者一样,是纯增量改动。
一张表与一条红线
三种参数的并发性质各不相同,根源都是第 4 章的借用规则——它们不过是 Messages<M> 这个资源的三种访问姿势:
| 参数 | 对 Messages<M> 的访问 | 并发性质 |
|---|---|---|
MessageReader<M> | Res + 私有游标 | 读者之间可并行 |
MessageWriter<M> | ResMut | 与同型一切读写互斥 |
MessageMutator<M> | ResMut + 私有游标 | 与同型一切读写互斥 |
这张表划出一条红线:同一个系统里同时声明同型的 MessageWriter<M> 和 MessageReader<M>,等于 ResMut 撞 Res。亲眼看一次:
//! Listing 7-8:同型消息一写一读挤进同一个系统——首帧 panic
use bevy::prelude::*;
#[derive(Message)]
struct RailHit;
fn main() {
let mut app = App::new();
app.add_message::<RailHit>();
// 想在一个系统里既写又读同一种消息——借用冲突
app.add_systems(Update, impossible);
app.update();
}
/// MessageWriter 内部是 ResMut,MessageReader 内部是 Res——同一资源一写一读
fn impossible(_writer: MessageWriter<RailHit>, _reader: MessageReader<RailHit>) {}Listing 7-8:同型消息一写一读挤进同一个系统——首帧 panic
cargo run -p ch07-messages --example listing-07-08error[B0002]: Res<bevy_ecs::message::messages::Messages<listing_07_08::RailHit>>
in system listing_07_08::impossible conflicts with a previous
ResMut<bevy_ecs::message::messages::Messages<listing_07_08::RailHit>> access.
Consider removing the duplicate access. See: https://bevy.org/learn/errors/b0002剧目编号 B0002——和第 5 章资源冲突同款,因为消息缓冲就是资源。报错原文还替本章第一节的说法验明正身:冲突双方写得明明白白,ResMut<Messages<RailHit>>(writer)撞上了 Res<Messages<RailHit>>(reader)。修法也是第 4 章的老朋友 ParamSet,把两者错开取用;至于不同类型之间读写混搭(读 RailHit 写 Combo),那是消息流水线的常态,毫无问题。
别的写入口
最后两个写入口,都来自“消息缓冲只是个资源”这一事实:
World的直接访问能写消息:world.write_message(...)(World API 在第 11 章展开)。Commands也能写:commands.write_message(...)。注意它走的是第 3 章的命令队列——消息不会当场入仓,要等命令在同步点应用时才真正写入,第 6 章的三条规则全部适用。它的用武之地是类型擦除的场合(运行期才决定写什么类型);常规代码一律用MessageWriter,更快也更直白。
大批量消息还有一个
par_read(),把读取分摊到多个线程,与第 4 章的并行查询同族,第 34 章谈并行时再见。
工具齐了。回到碰碰车场,把全章拼成一个会自己打烊的程序。