Skip to content

系统与系统参数

“函数就是 System”这句话,现在可以说得更准确了:任何一个普通函数,只要每个参数都是合法的系统参数(SystemParam),就能注册为 SystemCommands 是系统参数,Query 是系统参数,第 2 章见过的 Res<Time> 也是。这个约束在编译期检查——往签名里塞一个 Stringadd_systems 那行直接编译不过。

引擎对系统参数的约定很清晰:你声明要什么,调度器在每次运行前替你备好什么。函数体里看不到任何“从 World 取数据”的代码,因为取数据这件事整个发生在函数被调用之前。

一只羊群,三帧观察

本章第一个程序就用上一个新的系统参数。先看骨架:

rust
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 章的示例都只能观察一帧,而本章的主角里有好几位(LocalChangedAdded)必须跨帧才能现出原形,手动驱动就是为它们准备的实验台。

三个系统如下:

rust
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。运行:

console
cargo run -p ch04-systems-queries --example listing-04-01
text
第 1 天:3 只羊,饥饿总和 18
第 2 天:3 只羊,饥饿总和 12
第 3 天:3 只羊,饥饿总和 6

天数从 1 数到 3——day 活过了三帧。总和每帧减 6,是 graze 在前面干的活(.chain() 保证它先跑)。另外注意 headcount 里的 flock.iter().map(...).sum():查询的迭代器是普通的 Rust Iteratormapsumcountmin_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 章埋下的那句话:调度器靠签名判断哪些系统能并行。现在可以把机制说完整了——每个系统参数都向引擎登记自己的访问集合:读哪些组件、写哪些组件。两个系统的访问集合不冲突(没有“一写一读”或“两写”撞在同一列上),就可能被扔到不同线程同时跑。grazeHungerheadcountHunger,所以它们天生不能并行——就算没有 .chain(),调度器也只会让它们先后执行(顺序不定)。

这套机制管的是系统之间。那系统内部呢?如果一个系统自己的两个参数就互相冲突——两个查询都想写同一列——会发生什么?这个问题先记下,本章最后一节专门回答。下一节先把 Query 修炼到位。