系统与系统参数
“函数就是 System”这句话,现在可以说得更准确了:任何一个普通函数,只要每个参数都是合法的系统参数(SystemParam),就能注册为 System。Commands 是系统参数,Query 是系统参数,第 2 章见过的 Res<Time> 也是。这个约束在编译期检查——往签名里塞一个 String,add_systems 那行直接编译不过。
引擎对系统参数的约定很清晰:你声明要什么,调度器在每次运行前替你备好什么。函数体里看不到任何“从 World 取数据”的代码,因为取数据这件事整个发生在函数被调用之前。
一只羊群,三帧观察
本章第一个程序就用上一个新的系统参数。先看骨架:
fn main() {
let mut app = App::new();
app.add_systems(Startup, spawn_flock)
.add_systems(Update, (graze, headcount).chain());
app.update(); // 第 1 帧
app.update(); // 第 2 帧
app.update(); // 第 3 帧
}Listing 4-1(节选):不调 run(),手动驱动三帧
这里没有调 run(),而是连调了三次 app.update()——它的意思是“把 Main 调度完整跑一遍”,也就是一帧。第 2 章说过默认 runner 把所有调度跑一遍就退出,背后正是这个函数:run() 的默认实现就是调用一次 update(),窗口程序的事件循环则是反复调用它。Startup 只在第一次 update() 时运行,之后每次只跑 Update 这一族——所以三次调用等于“生成一次、模拟三帧”。
第 3 章的示例都只能观察一帧,而本章的主角里有好几位(Local、Changed、Added)必须跨帧才能现出原形,手动驱动就是为它们准备的实验台。
三个系统如下:
fn spawn_flock(mut commands: Commands) {
commands.spawn_batch([
(Name::new("小白"), Sheep, Hunger(10)),
(Name::new("小黑"), Sheep, Hunger(8)),
(Name::new("卷卷"), Sheep, Hunger(6)),
]);
}
/// 吃草:每帧每只羊饥饿 -2
fn graze(mut flock: Query<&mut Hunger, With<Sheep>>) {
for mut hunger in &mut flock {
hunger.0 -= 2;
}
}
/// 点名:Local<u32> 记着这是第几天
fn headcount(flock: Query<&Hunger, With<Sheep>>, mut day: Local<u32>) {
*day += 1;
let total: i32 = flock.iter().map(|hunger| hunger.0).sum();
println!("第 {} 天:{} 只羊,饥饿总和 {}", *day, flock.iter().count(), total);
}Listing 4-1(节选):graze 改数据,headcount 用 Local 跨帧计数
graze 没有新东西:可变查询,逐行扣饥饿值。headcount 的第二个参数是新面孔——Local<T>,属于这个系统私有的一块状态,在两次运行之间保持值不灭。初始值来自 Default(准确地说是 FromWorld,第 11 章细讲),之后每次运行拿到的都是同一份数据的 &mut。运行:
cargo run -p ch04-systems-queries --example listing-04-01第 1 天:3 只羊,饥饿总和 18
第 2 天:3 只羊,饥饿总和 12
第 3 天:3 只羊,饥饿总和 6天数从 1 数到 3——day 活过了三帧。总和每帧减 6,是 graze 在前面干的活(.chain() 保证它先跑)。另外注意 headcount 里的 flock.iter().map(...).sum():查询的迭代器是普通的 Rust Iterator,map、sum、count、min_by_key 这些适配器全都能用。
关于 Local 还有两条规则,决定了它的用途边界:
- 私有:两个系统各自声明
Local<u32>,得到的是两份互不相干的数据;没有任何办法从外部读写别人的Local。 - 按系统实例分配:同一个函数注册两次就是两个系统实例,各有一份
Local。
一句话:Local 适合“只有我自己关心的记忆”——帧计数、只做一次的开关。需要多个系统共享的数据,它无能为力,那是 Resource 的领地(第 5 章)。
系统参数家族
把已经见过的和即将见到的排成一张地图:
| 系统参数 | 给系统什么 | 出场 |
|---|---|---|
Commands | 排队结构性修改 | 第 3 章 |
Query<D, F> | 按行读写组件 | 本章 |
Single<D, F> | 恰好一个匹配实体的数据 | 本章 |
Populated<D, F> | 保证非空的 Query | 本章(一笔带过) |
ParamSet | 让冲突的参数分时复用 | 本章 |
Local<T> | 系统私有状态 | 刚刚 |
Res<T> / ResMut<T> | 全局唯一数据 | 第 5 章(Res<Time> 第 2 章已露面) |
MessageReader / MessageWriter | 收发缓冲消息 | 第 7 章 |
&World、自定义参数等 | 直接访问世界 | 第 11 章 |
再补几条签名层面的细则:
- 参数顺序任意,引擎按类型逐个准备,谁先谁后无所谓;
- 最多 16 个参数;不够用就把若干参数包成元组(元组也是系统参数,可以嵌套)——真到那一步,更该考虑的是拆系统;
- 返回值通常是
();也可以返回Result,把错误交给引擎统一处理(错误处理策略在第 33 章)。
签名就是访问声明
最后回到第 1 章埋下的那句话:调度器靠签名判断哪些系统能并行。现在可以把机制说完整了——每个系统参数都向引擎登记自己的访问集合:读哪些组件、写哪些组件。两个系统的访问集合不冲突(没有“一写一读”或“两写”撞在同一列上),就可能被扔到不同线程同时跑。graze 写 Hunger,headcount 读 Hunger,所以它们天生不能并行——就算没有 .chain(),调度器也只会让它们先后执行(顺序不定)。
这套机制管的是系统之间。那系统内部呢?如果一个系统自己的两个参数就互相冲突——两个查询都想写同一列——会发生什么?这个问题先记下,本章最后一节专门回答。下一节先把 Query 修炼到位。