9.1 职责归属与划分

> **本节目标**:明确 `.rml`、`.rml.rs`、Model、ViewModel、View 各自的职责边界,建立“什么代码该写在哪里”的判断准则。

9.1.1 为什么必须先讲职责

RML 的核心价值是**关注点分离**。如果开发者把业务逻辑塞进 `.rml` 模板、把 UI 结构写进 `.rml.rs`,RML 就退化成“带宏的 GPUI”,所有优势瞬间归零。

职责划分不是风格偏好,而是 RML 框架的**契约**:

- 编译器假设 `.rml` 只含声明式标记; - 绑定引擎假设 ViewModel 字段是纯数据; - 命令系统假设 `#[command]` 方法只修改状态、不直接操作 DOM; - 样式系统假设外观完全由样式表决定。

违反契约不会立即报错,但会让热重载失效、绑定追踪失真、测试无法独立运行。

9.1.2 五层职责一览

┌──────────────────────────────────────────────────────────────┐
│                       RML 职责分层                              │
├──────────────────────────────────────────────────────────────┤
│  ① .rml          视图结构:标签、属性、指令、插值、事件绑定              │
│  ② .rml.rs       业务逻辑:ViewModel 定义、命令、生命周期、元素引用      │
│  ③ Model         纯数据:不可变结构体,无 GPUI 依赖                    │
│  ④ ViewModel     视图状态:持有 Model,暴露绑定字段与命令,自身即 Entity  │
│  ⑤ View          渲染产物:编译期为 ViewModel 生成的 Render 实现        │
└──────────────────────────────────────────────────────────────┘

> **关键澄清**:在 RML 中,ViewModel 与 View **不是两个独立的类型**。ViewModel 结构体(标注 `#[derive(IModel)]` + `#[component]`)本身就是 GPUI 的 Entity;View 是编译器在 `OUT_DIR` 中为该 ViewModel 生成的 `impl Render` 代码块。开发者只写 ViewModel,不写 View。

9.1.3 各层职责详解

① `.rml` —— 视图结构层

**应该做的**:

- 描述 UI 的层级结构(`div`、`ul`、`button`…) - 通过属性配置控件(`class`、`type`、`placeholder`…) - 通过指令控制流(`if`、`each`、`show`…) - 通过插值显示数据(`{title}`、`{user.name}`…) - 通过 `on*` 绑定事件到命令

**禁止做的**:

- ❌ 在插值里写复杂表达式或函数调用(`{calculate_total(items)}`) - ❌ 在模板里写业务判断逻辑(应放进计算属性) - ❌ 在模板里直接修改状态(RML 模板是只读的) - ❌ 在模板里硬编码业务常量(应放进 ViewModel 或资源字典)

<!-- ✅ 正确:纯结构 + 绑定 -->
<div class="user-card">
  <img class="avatar" src="{user.avatar_url}" />
  <h3>{user.display_name}</h3>
  <p if={user.is_online} class="status online">在线</p>
  <button on-click={toggle_follow}>关注</button>
</div>

<!-- ❌ 错误:模板里塞业务逻辑 -->
<div class="user-card">
  <h3>{user.first_name + " " + user.last_name + " (" + user.role + ")"}</h3>
  <p if={user.posts.len() > 0 && user.is_active && !user.is_banned}>可见</p>
</div>

② `.rml.rs` —— 业务逻辑层

**应该做的**:

- 定义 ViewModel 结构体,标注 `#[derive(IModel)]` + `#[component]` - 实现 `#[command]` 命令方法 - 实现 `#[computed]` 计算属性 - 实现 `#[on_loaded]` / `#[on_unloaded]` 生命周期 - 通过 `ElementRef` 做命令式访问(仅在必要时)

**禁止做的**:

- ❌ 在命令方法里直接构造 GPUI `div()` 树 - ❌ 在 ViewModel 里持有 `ViewContext` 或 `AppContext`(应作为参数传入) - ❌ 在 ViewModel 里写网络/文件 I/O 细节(应委托给 Service 层) - ❌ 在命令方法里跨视图直接修改其他 ViewModel 的字段(应通过事件或 Context)

// ✅ 正确:命令只改状态,I/O 委托给 service
//
// 【对比传统 MVVM】在 WPF/UWP 中,ViewModel 通常要实现 INotifyPropertyChanged,
// 每个属性 setter 都要手动触发 OnPropertyChanged("IsFollowing"),字符串拼写错误
// 不会编译报错,只能在运行时发现绑定失效。RML 的优势:
//   1. #[derive(IModel)] 让所有 pub 字段自动成为响应式状态,无需手写通知样板代码;
//   2. cx.notify() 一次调用即可触发对所有绑定的重新求值,由编译期生成的 Render
//      实现负责差异渲染,开发者不必关心"哪个字段变了要通知谁";
//   3. #[command] 把方法显式标记为 UI 可调用,编译器据此生成事件分发代码,
//      避免了 WPF 中 ICommand.Execute/CanExecute 的样板实现;
//   4. cx.spawn 把异步 I/O 委托给 service,命令方法本身保持同步且可单测——
//      传统 MVVM 常常把网络请求直接写进 ViewModel,导致单测必须 mock HTTP。
#[derive(IModel)]
#[component]
pub struct UserViewModel {
    pub user: User,
    pub is_following: bool,  // pub 字段自动响应式,.rml 中 {is_following} 即可绑定
}

impl UserViewModel {
    // #[command] 让此方法可被 .rml 的 on-click={toggle_follow} 直接调用
    // 编译器会生成事件分发胶水代码,无需手写 ICommand 实现
    #[command]
    pub fn toggle_follow(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        let user_id = self.user.id;
        // 乐观更新:先改本地状态,UI 立即响应
        self.is_following = !self.is_following;
        // 一次 notify 触发所有依赖 is_following 的绑定重算
        // 传统 MVVM 需要对每个变更属性单独触发通知
        cx.notify();
        // 异步 I/O 委托给 service,命令方法本身保持可单测
        cx.spawn(|this, mut cx| async move {
            let _ = cx.update(|cx| follow_service::toggle(user_id, cx)).await;
            // 失败时回滚状态由 service 通过事件通知
        }).detach();
    }
}

// ❌ 错误:命令里直接拼 GPUI 树
// 这正是传统 MVVM 在 ViewModel 中用 Code Behind 拼控件树的坏习惯,
// RML 通过编译期生成 Render 彻底杜绝了这种写法
#[command]
pub fn render_detail(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
    let detail = div().child(Label::new("...")); // 不应出现在 .rml.rs
    cx.show_view(detail); // 这种 API 在 RML 中不存在
}

③ Model —— 纯数据层

**特征**:

- `#[derive(IModel)]` 派生的普通结构体(可按需附加 `Clone`、`Debug`、`Serialize` 等) - 字段是纯数据:`String`、`i32`、`Vec<T>`、`Option<T>`、嵌套 Model - **不依赖** `gpui::*`、不持有 `ViewContext`、不实现 `Render` - **不标注** `#[component]`(那是 ViewModel 的标记) - 通常是 API 响应、配置、领域模型的反序列化目标

**职责**:

- 承载数据 - 提供纯函数式的派生计算(如 `full_name(&self) -> String`) - 可被序列化 / 反序列化

// ✅ Model:纯数据,无 GPUI 依赖,不标注 #[component]
//
// 【对比传统 MVVM】在传统 WPF MVVM 中,Model 与 ViewModel 的边界经常模糊——
// 开发者为了让数据能绑定,常常让 Model 也实现 INotifyPropertyChanged,
// 导致数据层被 UI 框架污染,无法在控制台、服务端、测试用例中复用。
// RML 的设计更纯粹:
//   1. Model 只 #[derive(IModel)],不标注 #[component],编译器不会为它生成 Render;
//   2. Model 的方法(如 full_name/is_visible)是纯函数,无副作用,可自由单测;
//   3. Model 不依赖 gpui::*,可以放进 core crate,被 CLI/服务端/桌面端共享;
//   4. 当 Model 需要被 UI 消费时,由 ViewModel 持有它并把字段暴露为绑定源,
//      数据的"纯洁性"和"可绑定性"解耦,各司其职。
#[derive(Model, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct User {
    pub id: u64,
    pub first_name: SharedString,
    pub last_name: SharedString,
    pub role: UserRole,
    pub posts: Vec<Post>,
}

impl User {
    // 纯函数式派生计算:无 &mut self、无 cx、无 I/O
    // 可在任何无 UI 环境下调用,单测只需构造 User 实例
    pub fn full_name(&self) -> String {
        format!("{} {}", self.first_name, self.last_name)
    }

    // 业务规则也放在 Model,而不是塞进 .rml 模板表达式
    // 这样规则可被复用、可被测试,而不是散落在视图里
    pub fn is_visible(&self) -> bool {
        !self.posts.is_empty() && self.is_active() && !self.is_banned
    }
}

④ ViewModel —— 视图状态层

**特征**:

- `#[derive(IModel)]` 派生,并标注 `#[component]`(标记为 RML 视图的 Code-Behind) - **持有视图专属状态** - 通过 `#[command]` 暴露可被 UI 触发的方法 - 通过 `#[computed]` 暴露派生数据 - 通过 `#[element]` 持有对模板中元素的引用 - 实现 `#[on_loaded]` / `#[on_unloaded]`

**职责**:

- 把 Model 翻译成"视图可消费"的形态 - 管理视图本地状态(loading、error、selected_index…) - 协调 Service 调用并把结果回写到状态 - 通过 `cx.notify()` 通知重渲染(触发编译期生成的 `Render` 实现)

**ViewModel 不应该**:

- ❌ 持有其他 ViewModel 的强引用(用 `WeakEntity` 或 Context 事件) - ❌ 手写 `impl Render`(由编译器根据 `.rml` 自动生成,开发者只需标注 `#[component]`) - ❌ 跨越多个视图共享(每个视图一个 ViewModel;跨视图共享用 Context / 全局 Model)

⑤ View —— 渲染产物层

**特征**:

- 由 RML 编译器在 `OUT_DIR` 中生成,**开发者不手写** - 为 ViewModel 生成 `impl RmlView`(声明 `.rml` 模板路径)和 `impl Render`(构建 GPUI 元素树) - **不是独立的类型**——ViewModel 结构体自身即是 GPUI Entity,View 只是附加在它身上的 `Render` 实现 - 在 `render` 中调用绑定引擎、构建 GPUI 树

**职责**:

- 把 `.rml` 模板编译成 GPUI 调用 - 监听 ViewModel 的 `notify`,触发重渲染 - 把 UI 事件转发给 ViewModel 的命令

**开发者与 View 的关系**:

- 永远不直接构造 View,也不手写 `impl Render` - 通过 `RmlApplication::new().main_window::<MyWindow>().run()` 启动根窗口 - 通过 `ElementRef` 在 ViewModel 中拿到元素句柄,但**不修改结构**

9.1.4 职责速查表

| 需求 | 该写在哪里 | 反例 | | ------------------------ | ----------------------------- | ------------------------------- | | 改一段文字的颜色 | `.rml` 的 `class` 或样式表 | 在命令里 `el.set_style(...)` | | 显示用户全名 | ViewModel 的 `#[computed]` | 模板里 `{first + " " + last}` | | 点击按钮后请求 API | `#[command]` + Service | 模板里 `{fetch_user()}` | | 列表为空时显示占位 | `.rml` 的 `if` + 计算属性 | 命令里手动 `show` / `hide` 元素 | | 输入框自动聚焦 | `#[on_loaded]` + `ElementRef` | 模板里写 `autofocus` 然后在命令里二次操作 | | 主题切换 | Context 事件 + CSS 变量 | 在每个 ViewModel 里监听并改字段 | | 跨视图共享登录态 | 全局 Model + `cx.observe` | 把 `UserViewModel` 到处传 | | 表单校验 | ViewModel 的 `#[computed]` | 模板里写 `{validate(email)}` |

9.1.5 一个完整例子的分层

需求:一个登录表单,输入邮箱密码,点击登录后请求后端,失败时显示错误。

**`login.rml`** —— 只描述结构和绑定:

<!--
  ✅ 模板只描述"界面长什么样",完全不关心数据从哪来、怎么校验、怎么提交。
  对比传统 MVVM:WPF 的 XAML 常常混入 Converter 链、Trigger、Style 重写,
  最终变成"XAML 里写逻辑"。RML 的 .rml 强制保持声明式,业务判断全部上推到
  ViewModel 的 #[computed],模板可读性接近纯 HTML,设计师可直接编辑。
-->
<form class="login-form" onsubmit={login}>
  <!-- value={email} 是双向绑定:输入回写 ViewModel,ViewModel 变更同步到 value -->
  <input type="email" value={email} placeholder="邮箱" />
  <input type="password" value={password} placeholder="密码" />
  <!-- if={error} 条件渲染:error 为 None 时此节点不存在,而非 display:none -->
  <p if={error} class="error">{error}</p>
  <!-- disabled={is_loading} 是单向绑定:ViewModel 状态驱动 UI 禁用态 -->
  <button type="submit" disabled={is_loading}>
    {is_loading ? "登录中…" : "登录"}
  </button>
</form>

**`login.rml.rs`** —— 只处理状态和命令:

// 【对比传统 MVVM】这个登录示例集中体现了 RML 相对 WPF MVVM 的优雅:
//   1. 无样板:不需要 INotifyPropertyChanged、ICommand、RelayCommand、DependencyProperty
//      这一整套基础设施,#[derive(IModel)] + #[command] + #[computed] 三个宏全搞定;
//   2. 可单测:can_submit 是纯函数,login 命令把 I/O 委托给 auth_service,
//      单测时注入 mock service 即可,无需启动 GPUI 窗口;
//   3. 异步安全:cx.spawn 闭包持有 weak entity 引用,视图卸载时自动取消,
//      不会出现"ViewModel 已销毁但网络回调还在写状态"的悬空访问——
//      这是传统 MVVM 异步编程的高发 bug 区;
//   4. 状态机清晰:is_loading/error/email/password 四个字段就是完整状态空间,
//      UI 的所有外观都由这四个字段推导,不存在隐式的"控件可见性"等命令式状态。
#[derive(IModel)]
#[component]
pub struct LoginViewModel {
    pub email: SharedString,       // 双向绑定:input ↔ ViewModel
    pub password: SharedString,    // 双向绑定:input ↔ ViewModel
    pub error: Option<SharedString>, // 单向绑定:ViewModel → <p if={error}>
    pub is_loading: bool,          // 单向绑定:ViewModel → button disabled + 文案
}

impl LoginViewModel {
    // #[computed] 自动追踪依赖:email/password/is_loading 任一变化都触发重算
    // 传统 MVVM 需要手动在 setter 里调用 CanExecuteChanged 事件
    #[computed]
    pub fn can_submit(&self) -> bool {
        !self.email.is_empty() && !self.password.is_empty() && !self.is_loading
    }

    #[command]
    pub fn login(&mut self, _ev: &SubmitEvent, cx: &mut ViewContext<Self>) {
        // 进入 loading 态:UI 立即禁用按钮、文案变为"登录中…"
        self.is_loading = true;
        self.error = None;
        cx.notify();
        // 克隆数据进闭包,避免 &self 跨 await 生命周期问题(Rust 所有权保障)
        let email = self.email.clone();
        let password = self.password.clone();
        // cx.spawn 持有 weak entity,视图卸载时自动中止,无悬空回调
        cx.spawn(|this, mut cx| async move {
            match auth_service::login(&email, &password, &cx).await {
                Ok(_) => cx.update(|cx| cx.dispatch(LoginSuccess)).ok(),
                Err(e) => this.update(&mut cx, |this, cx| {
                    // 失败回滚:error 字段驱动 <p if={error}> 显示错误文案
                    this.error = Some(e.to_string().into());
                    this.is_loading = false;
                    cx.notify();
                }).err(),
            }
        }).detach();
    }
}

**`auth_service.rs`** —— 真正的 I/O:

pub async fn login(email: &str, password: &str, cx: &mut AsyncApp) -> Result<Token> { ... }

三层各司其职:模板可被设计师改、ViewModel 可被工程师单测、Service 可被集成测试。

9.1.6 判断准则

当你在写代码时,问自己三个问题:

1. **这段代码描述的是 UI 结构吗?** → 写进 `.rml` 2. **这段代码会修改视图状态吗?** → 写进 ViewModel 的 `#[command]` 或 `#[computed]` 3. **这段代码做的是 I/O 或领域计算吗?** → 写进 Service / Model

如果答案是“都沾点边”,说明职责正在混淆——停下来拆分。

下一节 → [9.2 MVVM 模式实践](./mvvm-practice.md)