Ribir 实践: 完整开发一个 Todos 应用
本教程将通过构建一个简单的 Todos 应用来向你展示一个 Ribir 应用的开发方式,同时帮你巩固 Ribir 的基本概念和使用方法。
该应用将允许你添加、删除、编辑和标记任务,并提供自动保存功能。
你将了解:
- 如何用 Ribir 推荐的方式开发设 计一个 Todos 引用
前提条件
为了完成本教程,我们假设你:
最终效果展示
代码结构
作为一个 GUI 框架,Ribir 最重要的一个目标就是让你在应用设计之初,可以专注于数据结构和算法(业务逻辑)的抽象,而完全不用考虑 UI。UI 则作为一个完全独立的模块开发,两者之间通过前者定义的 API 完成连接。
因此,在 Ribir 仓库中,你会发现几乎所有非纯粹的界面展示的例子都有这样两个主要的文件:一个和应用同名的 xxx.rs
文件,实现了应用的核心数据和逻辑;一个 ui.rs
文件实现了对核心数据的 UI 描述。另外,还有一个 main.rs
文件作为应用的入口。
在本教程中,我们也用同 样的方式来组织我们的 Todos 应用:
- src
- main.rs
- todos.rs
- ui.rs
内核开发
Ribir 不会一开始就考虑做控件的划分、层级结构的组织,UI 状态的管理等。Ribir 会推荐你先抽象好应用的核心数据结构和逻辑,设计定义好 API,再基于你的数据和视觉效果来组织你的 UI。
当然,如果是多人开发,上面这些工作可以是并行展开的。因为你需要独自完成全本章教程,所以让我们按 顺序一步步来。第一步先来完成核心数据结构部分的开发,并完全不去考虑 UI 的事情。
// todos.rs
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs::File,
io::{self, BufWriter, Write},
};
#[derive(Debug, Serialize, Deserialize)]
pub struct Todos {
tasks: BTreeMap<TaskId, Task>,
next_id: TaskId,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task {
id: TaskId,
pub complete: bool,
pub label: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct TaskId(usize);
impl Todos {
pub fn new_task(&mut self, label: String) -> TaskId {
let id = self.next_id;
self.next_id = self.next_id.next();
self.tasks.insert(id, Task { id, label, complete: false });
id
}
pub fn remove(&mut self, id: TaskId) { self.tasks.remove(&id); }
pub fn get_task(&self, id: TaskId) -> Option<&Task> { self.tasks.get(&id) }
pub fn get_task_mut(&mut self, id: TaskId) -> Option<&mut Task> { self.tasks.get_mut(&id) }
pub fn all_tasks(&self) -> impl Iterator<Item = TaskId> + '_ { self.tasks.keys().copied() }
}
impl Task {
pub fn id(&self) -> TaskId { self.id }
}
impl Todos {
pub fn load() -> Self {
std::fs::read(Self::store_path())
.map(|v| serde_json::from_slice(v.as_slice()).unwrap())
.unwrap_or_else(|_| Todos {
tasks: BTreeMap::new(),
next_id: TaskId(0),
})
}
pub fn save(&self) -> Result<(), io::Error> {
let file = File::create(Self::store_path())?;
let mut writer = BufWriter::new(file);
serde_json::to_writer(&mut writer, self)?;
writer.flush()?;
Ok(())
}
fn store_path() -> std::path::PathBuf { std::env::temp_dir().join("ribir_todos.json") }
}
impl TaskId {
pub fn next(&self) -> Self { Self(self.0 + 1) }
}
Todos
内核主要由 Todos
, Task
和 TaskId
三个类型组成,其中 Todos
是一个包含了所有任务的列表,Task
是一个任务的结构体,TaskId
是任务的唯一标识符。Todos
提供了对任务的增删改查的方法,并提供了保存到文件的能力。通常情况下,你还需要编写完备的单元测试来保证你的代码的正确性。
这部分工作与你平时写无界面的 Rust 代码的方式没有什么不同,你可以按照自己的习惯来组织代码,只要最后能够提供完整能力的 API 即可。在 Ribir 应用的的设计理念中,这部分工作非常重要,但却不是本教程的重点,如果你熟悉 Rust 语法,你应该能够轻易理解,这里就不再赘述了。
Tips
基于这样一个结构,完成这部分工作后,你可以轻易将你的核心部分变成一个库,并以此创建一个 CLI 应用,来给你的用户提供更好的开发体验和更多的使用场景。
现在你的应用,已经有了完备的逻辑,但是还没有任何界面。下一步,让我们用 Ribir 来为它构建一个界面。
描述 UI
在正式进入 UI 开发之前,我们先对照原型图划分几个主要部分,以方便后文的交流:
- Title 标题区,展示应用的名称
- Input 区,输入任务内容,按回车键添加任务
- Task Tabs,任务选项卡,分为 All, Active 和 Completed 三个选项卡,分别展示对应任务列表
- Task,单个任务的展示,提供编辑,标记完成和删除功能。
用 Ribir 搭建出整体结构
我们已经在 内核开发 中定义好 Todos
类型作为根数据结构,现在可以直接通过 Compose
从它开始对整个 UI 进行描述了。在此之前,你需要先在 main.rs
中引入 todos.rs
和 ui.rs
,并添加一个 main
函数作为应用入口:
// main.rs
mod todos;
mod ui;
use ribir::prelude::*;
use std::time::Duration;
fn main() {
let todos = State::value(todos::Todos::load());
// save changes to disk every 5 seconds .
let save_todos = todos.clone_reader();
watch!($todos;)
.debounce(Duration::from_secs(5), AppCtx::scheduler())
.subscribe(move |_| {
if let Err(err) = save_todos.read().save() {
log::error!("Save tasks failed: {}", err);
}
});
App::run(fn_widget! { todos })
}
在 main.rs
中,先创建了一个 State
来保存 Todos
数据,并将它当做根 widget 传递给 App::run
方法,这样应用就可以运行起来了。
同时对 todos
的变更进行了监听,并将其每隔 5 秒钟保存到磁盘上。当然,你的应用现在还没有任何交互,无法对 todos
进行修改,所以保存逻辑不会触发,但很快当你添加了交互,就能用的上这个自动保存的功能了。
注意到 watch!($todo;)
中的 ;
号了吗? 这是故意的,因为不想接收 todos
的变更结果,而只想知道它发生了变化,因为我们要在订阅函数中去读取它的最新值去保存。
接下来,在 ui.rs
中添加如下代码,来将 Todos
描述为一个 widget:
// ui.rs
use ribir::prelude::*;
impl Compose for Todos {
fn compose(this: impl StateWriter<Value = Self>) -> impl IntoWidget<FN> {
fn_widget! {
@Column {
align_items: Align::Center,
item_gap: 12.,
@H1 { text: "Todo" }
}
}
}
}
现在,当你通过 cargo run
运行时,你将看到窗口上面仅有一个标题 "Todo"。上面代码中,我们将 Column
作为 Todo
的根 widget,它是一个 Render
类型的 widget,能够将它的孩子按照垂直方向排列,并提供了一些相关属性,这里我们设置了 align_items
为 Align::Center
,表示将孩子们在垂直方向上居中对齐,item_gap
为 12.
,表示孩子之间的间隔为 12 个逻辑像素。
下一步,我们先往 Column
中添加一个空的任务选项卡,撑起我们整个结构:
@Tabs {
@Tab {
@ { "ALL" }
@text!{ text: "Coming Soon!" }
}
@Tab {
@ { "ACTIVE" }
@text!{ text: "Coming Soon!" }
}
@Tab {
@ { "DONE" }
@text! { text: "Coming Soon!" }
}
}
同样 Tabs
也是 Ribir widgets 库为我们提供的,它是一个 ComposeChild
widget,并且规定了它的孩子必须是 Tab
类型。因为,我们现在还没有准备好 Tab
中要展示的内容,所以用了一个 “Coming soon!” 的 Text
来占位。不过,我们没有直接使用 @Text
来声明控件,而是用text!
来声明了一个函数 widget 来作为孩子,这是因为 Tab
规定了它的内容必须是一个 GenWidget
, 因为 Tabs
只想构建活动 Tab
对应的内容,而不是所 有 Tab
。而一个支持多次调用的函数 widget 可以转换成 GenWidget
。
增加任务录入能力
现在,我们来添加录入数据的能力: 在 Column
中添加一个 Input
,响应回车按钮将 Input
中的内容作为任务添加到 Todos
中。 等等,我们要怎么在一个 Input
中的事件回调中,访问 Input
自己呢?
@Input {
on_key_down: move |e| {
if e.key_code() == &PhysicalKey::Code(KeyCode::Enter) {
// 如何获得 Input 自己?
}
}
}