4.4 命令系统

> **本节目标**:完整掌握 RML 的命令系统——ICommand trait、`#[command]` 宏、命令参数、命令的启用条件。

4.4.0 MVVM 数据驱动:无需手动 notify

RML 实现了完整的 MVVM 数据驱动模式:`#[command]` 宏在编译期自动追踪方法体内对 `self.<field>` 的赋值/复合赋值操作,自动注入两件事——

1. **字段版本号 bump**:`self.__rml_bump_version("<field>")`,触发依赖该字段的 `#[computed]` 缓存失效与双向绑定正向同步。 2. **视图重绘 notify**:`cx.notify()`(仅当方法返回 `()` 且存在 `&mut Context<Self>` 参数时)。

**因此 `#[command]` 方法体内一般无需手写 `cx.notify()`**——只管改字段,框架自动驱动 UI 更新。本章所有示例遵循此约定。

> 例外场景:批量更新后想控制 notify 时机、或方法返回非 `()` 类型——使用 `#[command(no_notify)]` 显式禁用自动 notify,详见 4.4.3.2。 > 异步任务(`cx.spawn`)闭包内的 `cx.notify()` 仍需手写,因为宏只注入命令方法体本身,不递归进入闭包。

4.4.1 命令的定义

命令(Command)是 ViewModel 中可被 UI 直接调用的方法。命令是 RML 事件处理的核心抽象:

#[command]
pub fn increment(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
    self.count += 1;
    // 宏自动注入:self.__rml_bump_version("count"); cx.notify();
}
<button on-click={increment}>+1</button>

4.4.2 ICommand trait

RML 的命令系统基于 `ICommand` trait,借鉴自 WPF。`ICommand` 继承 `IContribution`——命令本身即是贡献点,可经统一 `register` 路由到 host:

pub trait ICommand: IContribution {
    /// 执行命令(WPF: Execute)。ctx 提供 Window/App 访问能力。
    fn execute(&self, ctx: &mut CallContext);

    /// 是否可执行(WPF: CanExecute)。返回 false 时 UI 应禁用对应控件。
    fn can_execute(&self, _ctx: &mut CallContext) -> bool { true }
}

`CallContext` 封装 `&mut Window` + `&mut App` + 可选 `parameter`(对齐 WPF `Execute(parameter)`),替代旧的弱类型 `(&dyn Any, &mut App)` 参数。

`#[command]` 宏生成的事件绑定直接调用强类型方法(绕过 trait,保留类型安全);`ICommand` trait 用于声明式命令绑定(`command={field}`)、快捷键、命令面板等动态调度场景。开发者通常不需要手动实现这个 trait——框架提供 `RelayCommand` 默认实现。

4.4.3 `#[command]` 宏

`#[command]` 标记方法为命令,自动生成:

1. `ICommand` trait 的实现 2. 事件绑定的连接代码 3. 命令参数的解析代码

基础用法

#[command]
pub fn submit(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
    // 业务逻辑:修改 self 字段,宏自动注入 bump_version + cx.notify()
}

命令的签名

命令方法必须满足以下签名:

// 无参数命令
#[command]
pub fn method_name(&mut self, event: &EventType, cx: &mut Context<Self>)

// 带参数命令
#[command]
pub fn method_name(&mut self, param: T, event: &EventType, cx: &mut Context<Self>)

// 多参数命令
#[command]
pub fn method_name(&mut self, p1: T1, p2: T2, event: &EventType, cx: &mut Context<Self>)

其中 `EventType` 是事件对象类型,如 `ClickEvent`、`ChangeEvent`、`KeyDownEvent` 等。

4.4.4 命令参数

无参数命令

#[command]
pub fn increment(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
    self.count += 1;
    // 宏自动注入:bump_version("count") + cx.notify()
}
<button on-click={increment}>+1</button>

单参数命令

#[command]
pub fn delete_item(&mut self, id: u64, _: &ClickEvent, cx: &mut Context<Self>) {
    self.items.retain(|i| i.id != id);
    // 宏自动注入:bump_version("items") + cx.notify()
}
<button on-click={delete_item, {item.id}}>删除</button>

多参数命令

#[command]
pub fn update_status(
    &mut self,
    id: u64,
    status: SharedString,
    _: &ClickEvent,
    cx: &mut Context<Self>,
) {
    if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
        item.status = status;
        // 宏自动注入:bump_version("items") + cx.notify()
    }
}
<button on-click={update_status, {item.id}, 'completed'}>完成</button>

参数类型

命令参数可以是任意 `Send + Clone` 类型:

| 参数类型 | 在 `.rml` 中的写法 | | ------------- | ------------------------------ | | `i32`、`u64` 等 | `on-click={fn, 42}` | | `SharedString` | `on-click={fn, 'hello'}` 或 `on-click={fn, {field}}` | | `bool` | `on-click={fn, true}` | | 自定义类型 | 需要实现 `FromStr` 或提供转换 |

4.4.5 命令的启用条件

用 `can_execute` 控制启用状态

#[command(can_execute = "can_increment")]
pub fn increment(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
    self.count += 1;
    // 宏自动注入:bump_version("count") + cx.notify()
}

pub fn can_increment(&self) -> bool {
    self.count < 100  // 超过 100 时禁用
}
<!-- 按钮在 can_increment 返回 false 时自动禁用 -->
<button on-click={increment}>+1</button>

用计算属性控制启用状态

也可以在 `.rml` 中用 `disabled` 属性:

#[computed]
pub fn can_submit(&self) -> bool {
    !self.user_name.is_empty() && !self.password.is_empty()
}
<button disabled={!can_submit} on-click={submit}>提交</button>

两种方式的对比

| 方式 | 优点 | 缺点 | | ----------------- | --------------- | ------------------- | | `can_execute` | 命令与条件绑定,内聚 | 需要额外方法 | | `disabled={...}` | 灵活,可组合多个条件 | 条件与命令分离,可能不一致 |

💡 **最佳实践**:简单条件用 `disabled={}`,复杂业务规则用 `can_execute`。

4.4.6 命令与事件对象

命令方法接收事件对象作为参数,可以访问事件信息:

#[command]
pub fn on_click(&mut self, ev: &ClickEvent, cx: &mut Context<Self>) {
    println!("点击位置: {:?}", ev.position);
    self.count += 1;
    // 宏自动注入:bump_version("count") + cx.notify()
}

#[command]
pub fn on_key_down(&mut self, ev: &KeyDownEvent, cx: &mut Context<Self>) {
    match ev.key {
        Key::Enter => self.submit(cx),
        Key::Escape => self.cancel(cx),
        _ => {}
    }
}

常用事件对象

| 事件对象 | 触发事件 | 常用属性 | | -------------- | ---------- | ----------------------- | | `ClickEvent` | `on-click` | `position`、`button` | | `ChangeEvent` | `onchange` | `value` | | `InputEvent` | `oninput` | `value` | | `KeyDownEvent` | `onkeydown`| `key`、`modifiers` | | `KeyUpEvent` | `onkeyup` | `key`、`modifiers` | | `MouseEvent` | `onmouseenter` 等 | `position`、`buttons` | | `FocusEvent` | `onfocus`、`onblur` | 无 |

详见 [第 5 章 · 事件对象](../05-events/event-objects.md)。

4.4.7 命令的命名约定

用动词开头

// ✅ 动词开头,描述动作
#[command]
pub fn submit(&mut self, ...) { ... }

#[command]
pub fn delete_item(&mut self, ...) { ... }

#[command]
pub fn toggle_complete(&mut self, ...) { ... }

// ❌ 名词开头,描述状态
#[command]
pub fn button_click(&mut self, ...) { ... }  // 应为 submit 或 click_submit

// ❌ handle_ 前缀,冗余
#[command]
pub fn handle_click(&mut self, ...) { ... }  // 应为 submit

命名风格

- 用 `snake_case` - 避免缩写:`delete_item` 而非 `del_item` - 避免泛化:`submit` 而非 `click`、`action`

4.4.8 命令的复用

命令可以在多个事件中复用:

<!-- 同一个命令绑定到不同事件 -->
<button on-click={submit}>提交</button>
<input onkeydown={on_enter_key} />

<!-- 也可以直接绑定到回车键 -->
<input onkeydown={submit_on_enter} />
#[command]
pub fn submit(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
    // 提交逻辑
}

#[command]
pub fn submit_on_enter(&mut self, ev: &KeyDownEvent, cx: &mut Context<Self>) {
    if ev.key == Key::Enter {
        self.submit(&ClickEvent::default(), cx);
    }
}

4.4.9 命令与异步操作

命令可以启动异步任务。注意:宏只会自动注入命令方法体本身的 `bump_version` + `cx.notify()`,**异步闭包内(`cx.spawn` / `this.update`)的修改不会自动注入**,需要手写 `cx.notify()`:

#[command]
pub fn load_data(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
    self.is_loading = true;
    // 宏自动注入:bump_version("is_loading") + cx.notify()

    cx.spawn(|this, mut cx| async move {
        let data = fetch_data_from_server().await;

        // 异步闭包内:宏不注入,需手动 notify
        let _ = this.update(&mut cx, |this, cx| {
            this.data = data;
            this.is_loading = false;
            cx.notify();
        });
    }).detach();
}

⚠️ **注意**:异步任务中修改 ViewModel 必须通过 `this.update`,不能直接捕获 `&mut self`。详见 [第 8 章 · 状态生命周期](../08-lifecycle/state-lifecycle.md)。

4.4.10 命令的测试

命令方法可以独立测试,无需 UI:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_increment() {
        let mut view = MyView::new();
        view.count = 5;

        // 直接调用命令方法(需要模拟 cx)
        // view.increment(&ClickEvent::default(), &mut cx);

        assert_eq!(view.count, 6);
    }

    #[test]
    fn test_can_increment() {
        let mut view = MyView::new();
        view.count = 100;
        assert!(!view.can_increment());

        view.count = 99;
        assert!(view.can_increment());
    }
}

详见 [第 9 章 · 可测试性设计](../09-architecture/testability.md)。

4.4.11 完整示例:购物车

#[derive(IModel)]
pub struct CartItem {
    pub id: u64,
    pub name: SharedString,
    pub price: f64,
    pub quantity: u32,
}

#[derive(IModel)]
#[component]
pub struct CartView {
    pub items: Vec<CartItem>,
    pub is_checking_out: bool,

    next_id: u64,
}

impl CartView {
    pub fn new() -> Self {
        Self {
            items: Vec::new(),
            is_checking_out: false,
            next_id: 1,
        }
    }

    #[computed]
    pub fn total_price(&self) -> f64 {
        self.items.iter().map(|i| i.price * i.quantity as f64).sum()
    }

    #[computed]
    pub fn total_count(&self) -> u32 {
        self.items.iter().map(|i| i.quantity).sum()
    }

    #[computed]
    pub fn can_checkout(&self) -> bool {
        !self.items.is_empty() && !self.is_checking_out
    }

    #[command]
    pub fn add_item(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
        self.items.push(CartItem {
            id: self.next_id,
            name: format!("商品 {}", self.next_id).into(),
            price: 99.0,
            quantity: 1,
        });
        self.next_id += 1;
        // 宏检测到 self.next_id += 1,自动注入 bump_version("next_id") + cx.notify()
        // 注:self.items.push() 是方法调用,宏无法识别,但 next_id 的修改已触发重绘
    }

    #[command]
    pub fn increase_quantity(&mut self, id: u64, _: &ClickEvent, cx: &mut Context<Self>) {
        if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
            item.quantity += 1;
            // ⚠️ item.quantity 是局部借用变量,宏的 AST 模式匹配只识别 self.<field>,
            // 不会检测到 items 内部数据已变。此场景需手动 notify。
            cx.notify();
        }
    }

    #[command]
    pub fn decrease_quantity(&mut self, id: u64, _: &ClickEvent, cx: &mut Context<Self>) {
        if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
            if item.quantity > 1 {
                item.quantity -= 1;
            } else {
                // 数量为 0 时移除
                self.items.retain(|i| i.id != id);
            }
            // 同上:局部借用修改,宏不注入,需手动 notify
            cx.notify();
        }
    }

    #[command]
    pub fn remove_item(&mut self, id: u64, _: &ClickEvent, cx: &mut Context<Self>) {
        self.items.retain(|i| i.id != id);
        // ⚠️ self.items.retain() 是方法调用,宏不识别为字段修改,需手动 notify
        cx.notify();
    }

    #[command]
    pub fn checkout(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
        if !self.can_checkout() {
            return;
        }

        self.is_checking_out = true;
        // 宏自动注入:bump_version("is_checking_out") + cx.notify()

        let total = self.total_price();

        cx.spawn(|this, mut cx| async move {
            let result = process_payment(total).await;

            // 异步闭包内:宏不注入,需手动 notify
            let _ = this.update(&mut cx, |this, cx| {
                this.is_checking_out = false;
                match result {
                    Ok(_) => {
                        this.items.clear();
                        // 显示成功提示...
                    }
                    Err(e) => {
                        // 显示错误提示...
                        log::error!("结账失败: {}", e);
                    }
                }
                cx.notify();
            });
        }).detach();
    }
}

async fn process_payment(amount: f64) -> Result<(), String> {
    // 模拟支付
    Ok(())
}

4.4.12 声明式命令绑定(command={field})

`on-click={method}` 是强类型直接调用——codegen 生成 `this.method(&ev, cx)`,类型安全但命令与事件绑定耦合。`command={field}` 则是声明式命令绑定(对齐 WPF `ICommand`):ViewModel 持有 `Arc<RelayCommand>` 字段,UI 通过 `command={field}` 绑定,点击时经 `ICommand::can_execute` / `execute` 动态调度。

RelayCommand

`RelayCommand` 是框架提供的 `ICommand` 默认实现(WPF `RelayCommand` / `DelegateCommand` 等价物),持有 `WeakEntity<T>` + 闭包:

pub struct MainWindow {
    pub save_command: Arc<RelayCommand>,  // 框架提供 Default(no-op 空对象)
}

impl ILifecycle for MainWindow {
    fn on_loaded(&mut self, _: &mut Window, cx: &mut Context<Self>) {
        self.save_command = Arc::new(RelayCommand::new(cx, |this, cx| {
            this.save(cx);
        }));
    }
}
<menu-item label="Save" command={save_command} />

字段类型选择

| 字段类型 | `#[derive(Default)]` | 适用场景 | |---|---|---| | `Arc<RelayCommand>` | ✅(框架提供 `Default: no-op`) | 命令字段为单一 `RelayCommand`(推荐) | | `Arc<dyn ICommand>` | ❌(`dyn` 无 `Default`) | 需多态(字段可能持有不同命令实现) |

`RelayCommand` 实现了 `Default`(空对象模式——返回 no-op 命令),使 `Arc<RelayCommand>` 字段可随 ViewModel `#[derive(Default)]` 自动初始化,`on_loaded` 中再替换为真实命令。若字段需多态(`Arc<dyn ICommand>`),则需手写 `Default` 并显式初始化宏注入的 `__rml_*` 字段。

codegen 行为

`command={save_command}` 生成 clone-Arc-out 闭包,点击时构造 `CallContext` 调度:

.on_click({
    let weak = __rml_menu_weak.clone();
    move |_ev, window, app| {
        if let Some(entity) = weak.upgrade() {
            let __rml_cmd = entity.update(app, |this, _cx| this.save_command.clone());
            let mut __rml_ctx = rml_core::command::CallContext::new(window, app);
            if __rml_cmd.can_execute(&mut __rml_ctx) {
                __rml_cmd.execute(&mut __rml_ctx);
            }
        }
    }
})

`command` 与 `on-click` 同时声明时,`command` 优先。详见 [6.x menu-item · command 属性](../06-components/reference/menu-items.md)。

4.4.13 小结

命令系统是 RML 事件处理的核心抽象:

- **`#[command]`**:标记方法为 UI 可调用的命令 - **命令参数**:支持无参、单参、多参 - **`can_execute`**:控制命令的启用状态 - **事件对象**:命令方法接收事件对象,可访问事件信息 - **异步支持**:命令可以启动异步任务 - **声明式绑定**:`command={field}` 经 `ICommand` 动态调度(`RelayCommand` + `Arc<RelayCommand>` 字段)

掌握命令系统,你就能把任何业务逻辑暴露给 UI 调用。

下一节 → [4.5 状态管理](./state-management.md)