11.2 案例研究:从零到一的 Todo 应用

> **本节目标**:用一个中等复杂度的 Todo 应用串起全书知识点,演示完整的项目结构与开发流程。

11.2.1 需求分析

我们要构建一个 Todo 应用,功能包括:

- 添加 / 删除 / 编辑待办 - 标记完成 / 取消完成 - 按状态筛选(全部 / 未完成 / 已完成) - 本地持久化(重启不丢失) - 统计剩余数量

涉及的知识点:

- MVVM 三层 - 双向绑定与计算属性 - 事件系统 - 生命周期与持久化 - 组件封装 - 样式与主题

11.2.2 项目结构

todo-app/
├── Cargo.toml
├── build.rs
├── src/
│   ├── main.rs
│   ├── app.rml
│   ├── app.rml.rs
│   ├── models/
│   │   ├── todo.rs
│   │   └── mod.rs
│   ├── services/
│   │   ├── todo_store.rs
│   │   └── mod.rs
│   ├── views/
│   │   └── todo_list/
│   │       ├── todo_list.rml
│   │       ├── todo_list.rml.rs
│   │       └── mod.rs
│   ├── components/
│   │   ├── todo_item/
│   │   │   ├── todo_item.rml
│   │   │   ├── todo_item.rml.rs
│   │   │   └── mod.rs
│   │   └── mod.rs
│   └── styles/
│       ├── theme.rmlcss
│       └── mod.rs
└── tests/
    └── todo_flow.rs

11.2.3 Model 层

// src/models/todo.rs
#[derive(Model, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Todo {
    pub id: u64,
    pub title: SharedString,
    pub completed: bool,
    pub created_at: i64,
}

#[derive(Model, Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
pub enum TodoFilter {
    All,
    Active,
    Completed,
}

impl TodoFilter {
    pub fn matches(&self, todo: &Todo) -> bool {
        match self {
            TodoFilter::All => true,
            TodoFilter::Active => !todo.completed,
            TodoFilter::Completed => todo.completed,
        }
    }
}

Model 是纯数据,无 GPUI 依赖,可独立单测。

11.2.4 Service 层

// src/services/todo_store.rs
use std::path::PathBuf;

pub struct TodoStore { path: PathBuf }

impl TodoStore {
    pub fn new(app_data_dir: PathBuf) -> Self {
        Self { path: app_data_dir.join("todos.json") }
    }

    pub async fn load(&self) -> Result<Vec<Todo>> {
        match tokio::fs::read(&self.path).await {
            Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
            Err(e) => Err(e.into()),
        }
    }

    pub async fn save(&self, todos: &[Todo]) -> Result<()> {
        let bytes = serde_json::to_vec_pretty(todos)?;
        tokio::fs::write(&self.path, bytes).await?;
        Ok(())
    }
}

Service 只做 I/O,返回 `Result`,错误由 ViewModel 处理。

11.2.5 ViewModel 层

// src/views/todo_list/todo_list.rml.rs
use crate::models::*;
use crate::services::TodoStore;

#[derive(IModel)]
pub struct TodoListViewModel {
    pub todos: Vec<Todo>,
    pub filter: TodoFilter,
    pub new_todo_title: SharedString,
    pub is_loading: bool,
    pub error: Option<SharedString>,
    store: Arc<TodoStore>,
    save_task: Option<Task<()>>,
}

impl TodoListViewModel {
    pub fn new(store: Arc<TodoStore>) -> Self {
        Self {
            todos: vec![],
            filter: TodoFilter::All,
            new_todo_title: "".into(),
            is_loading: false,
            error: None,
            store,
            save_task: None,
        }
    }

    // 计算属性:根据筛选条件过滤
    #[computed]
    pub fn visible_todos(&self) -> Vec<Todo> {
        self.todos.iter()
            .filter(|t| self.filter.matches(t))
            .cloned()
            .collect()
    }

    #[computed]
    pub fn remaining_count(&self) -> usize {
        self.todos.iter().filter(|t| !t.completed).count()
    }

    #[computed]
    pub fn can_add(&self) -> bool {
        !self.new_todo_title.trim().is_empty()
    }

    // 命令:添加
    #[command]
    pub fn add_todo(&mut self, _ev: &SubmitEvent, cx: &mut ViewContext<Self>) {
        let title = self.new_todo_title.trim().to_string();
        if title.is_empty() { return; }
        let todo = Todo {
            id: next_id(),
            title: title.into(),
            completed: false,
            created_at: now_ts(),
        };
        self.todos.push(todo);
        self.new_todo_title = "".into();
        cx.notify();
        self.schedule_save(cx);
    }

    // 命令:切换完成
    #[command]
    pub fn toggle(&mut self, ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        let id = ev.data::<u64>();
        if let Some(t) = self.todos.iter_mut().find(|t| t.id == id) {
            t.completed = !t.completed;
            cx.notify();
            self.schedule_save(cx);
        }
    }

    // 命令:删除
    #[command]
    pub fn remove(&mut self, ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        let id = ev.data::<u64>();
        self.todos.retain(|t| t.id != id);
        cx.notify();
        self.schedule_save(cx);
    }

    // 命令:切换筛选
    #[command]
    pub fn set_filter(&mut self, ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        self.filter = ev.data::<TodoFilter>();
        cx.notify();
    }

    // 命令:清空已完成
    #[command]
    pub fn clear_completed(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        self.todos.retain(|t| !t.completed);
        cx.notify();
        self.schedule_save(cx);
    }

    // 生命周期:加载持久化数据
    #[on_loaded]
    pub fn on_loaded(&mut self, cx: &mut ViewContext<Self>) {
        self.is_loading = true;
        cx.notify();
        let store = self.store.clone();
        cx.spawn(|this, mut cx| async move {
            let result = store.load().await;
            let _ = this.update(&mut cx, |this, cx| {
                this.is_loading = false;
                match result {
                    Ok(todos) => { this.todos = todos; this.error = None; }
                    Err(e) => { this.error = Some(e.to_string().into()); }
                }
                cx.notify();
            });
        }).detach();
    }

    // 防抖保存:避免每次修改都写盘
    fn schedule_save(&mut self, cx: &mut ViewContext<Self>) {
        if let Some(t) = self.save_task.take() { t.abort(); }
        let store = self.store.clone();
        let todos = self.todos.clone();
        self.save_task = Some(cx.spawn(|_this, mut cx| async move {
            cx.background_executor().timer(Duration::from_millis(500)).await;
            if let Err(e) = store.save(&todos).await {
                log::error!("保存失败: {e}");
            }
        }));
    }
}

11.2.6 View 层

<!-- src/views/todo_list/todo_list.rml -->
<div class="todo-app">
  <header>
    <h1>待办</h1>
    <p if={is_loading}>加载中…</p>
    <p if={error} class="error">{error}</p>
  </header>

  <div class="add-form">
    <input value={new_todo_title} placeholder="添加待办…" />
    <Button label="添加" on-click={add_todo} disabled={!can_add} />
  </div>

  <div class="filters button-row">
    <Button each={filter in filters} key={filter.id}
            on-click={set_filter}
            label={filter.label} />
  </div>

  <ul class="todo-list">
    <li each={todo in visible_todos} key={todo.id}>
      <TodoItem todo={todo} on-toggle={toggle} on-remove={remove} />
    </li>
  </ul>

  <footer if={!todos.is_empty()}>
    <span>{remaining_count} 项剩余</span>
    <Button if={remaining_count < todos.len()} label="清除已完成" on-click={clear_completed} />
  </footer>
</div>

11.2.7 组件封装

// src/components/todo_item/todo_item.rml.rs
#[derive(IModel)]
pub struct TodoItem {
    pub todo: Todo,
    on_toggle: Option<Command>,
    on_remove: Option<Command>,
}
<!-- src/components/todo_item/todo_item.rml -->
<div class="todo-item" r:class:completed="todo.completed">
  <input type="checkbox" r:checked="todo.completed" on:change="on_toggle" />
  <span class="title">{todo.title}</span>
  <Button class="remove" label="✕" on-click={on_remove} />
</div>

组件只接受 props + 触发事件,不直接操作父状态。

11.2.8 样式与主题

/* src/styles/theme.rmlcss */
:root {
  --color-primary: #4f46e5;
  --color-danger: #ef4444;
  --color-bg: #ffffff;
  --color-text: #1f2937;
  --color-muted: #6b7280;
  --space-1: 4px;
  --space-2: 8px;
  --space-4: 16px;
}

.todo-app {
  max-width: 480px;
  margin: 0 auto;
  padding: var(--space-4);
  background: var(--color-bg);
  color: var(--color-text);
}

.todo-item.completed .title {
  text-decoration: line-through;
  color: var(--color-muted);
}

11.2.9 测试

Model 单测

#[test]
fn filter_active_excludes_completed() {
    let todo = Todo { completed: true, ..Default::default() };
    assert!(!TodoFilter::Active.matches(&todo));
    assert!(TodoFilter::Completed.matches(&todo));
    assert!(TodoFilter::All.matches(&todo));
}

ViewModel 单测

#[test]
fn remaining_count_excludes_completed() {
    let vm = TodoListViewModel::new(mock_store());
    let _ = vm.todos = vec![
        Todo { completed: false, ..Default::default() },
        Todo { completed: true, ..Default::default() },
        Todo { completed: false, ..Default::default() },
    ];
    assert_eq!(vm.remaining_count(), 2);
}

#[test]
fn visible_todos_respects_filter() {
    let mut vm = TodoListViewModel::new(mock_store());
    vm.todos = vec![
        Todo { id: 1, completed: false, ..Default::default() },
        Todo { id: 2, completed: true, ..Default::default() },
    ];
    vm.filter = TodoFilter::Active;
    assert_eq!(vm.visible_todos().len(), 1);
    assert_eq!(vm.visible_todos()[0].id, 1);
}

集成测试

#[test]
fn add_persists_to_store() {
    let store = Arc::new(TodoStore::new(tempdir()));
    let mut cx = TestContext::new();
    let mut vm = TodoListViewModel::new(store.clone());
    vm.new_todo_title = "买菜".into();
    vm.add_todo(&SubmitEvent::default(), &mut cx.with_view(&mut vm));
    cx.run_until_parked();

    let loaded = store.load().blocking_recv().unwrap();
    assert_eq!(loaded.len(), 1);
    assert_eq!(loaded[0].title, "买菜");
}

11.2.10 开发流程回顾

1. **设计 Model**:纯数据,可序列化 2. **设计 Service**:I/O 隔离,返回 Result 3. **设计 ViewModel**:状态 + 命令 + 计算属性 4. **写模板**:纯结构 + 绑定 5. **抽组件**:复用片段封装 6. **加样式**:主题变量 + 组件样式 7. **写测试**:Model 单测 → ViewModel 单测 → 集成测试 8. **热重载调试**:改模板实时看效果 9. **性能检查**:列表 key、notify 频率

每一步都对应前十章的知识点,案例把散落的知识串成了完整能力。

下一节 → [11.3 迁移指南](./migration-guide.md)