todos-iced 是一个 iced 项目的示例,它实现了待办管理,并且持久化待办任务到一个 JSON 文件里。
iced 是一个 GUI 框架,卖点是跨平台、Rust、受 Elm 启发。Elm 是一门设计成编译产出 Javascript 的函数式编程语言,它特化为要解决 GUI 问题。它有 view、update、model 三部分设计,这都被 iced 吸收了。
todos-iced(下面简称 todos)(目前)代码有六百多行,是中型的示例,涵盖了很多 iced 的功能。阅读 todos 有助于了解如何使用 iced 开发 GUI 工具。
主函数和 Application
主函数里只有 Todos::run(Settings::default())
一行,返回的是 iced::Result
,其中的 Settings
是 iced 提供的,既然使用了默认值,那么忽略它即可。Todos
是 todos 定义的类型,它是整个应用的核心抽象。但 run
函数并不是 todos 定义的,而是因为 Todos
实现了 iced::Application
,后者提供了这个函数。
Todos
是:
enum Todos {
Loading,
Loaded(State),
}
todos 启动时会从文件系统里加载之前保存的 todos.json,这里保存了 todos 的状态。从启动到加载结束这个过程中,todos 处在 Loading
状态,之后就处于 Loaded
状态,Loaded
状态里自然就获得了 todos 的状态信息,用 State
表示。State
之后再解析其内容。
Todos
对 Application
的实现是应用的核心。这个实现绑定了三个关联类型:
type Executor = iced::executor::Default;
type Message = Message;
type Flags = ();
第一第三条类型都是很平凡的类型,所以略过不看。第二条的 Message
则是 todos 实现的类型,它是一个 enum,输入事件产生的消息都是 Message
类型(的变体)。
MVC(模型、视图、控制器)的经典设计是,输入事件产生消息(Message
),应用(Application
)会处理消息,更新用户可以看到的图形(视图),这样用户就认为自己的输入事件(鼠标点击、键盘输入)产生了响应。
Application
还实现了以下函数:
fn new(_flags: ()) -> (Todos, Command<Message>)
fn title(&self) -> String
fn update(&mut self, message: Message, _: &mut Clipboard) -> Command<Message>
fn view(&mut self) -> Element<Message>
下面会详细介绍这些函数。
Application 的 new 函数和 title 函数
new
显然是创建 Todos
的函数,只是同时还返回了 Command<Message>
。Command
是 iced 的一部分,Message
则是上面提到的消息类型。这个函数只有一句:
fn new(_flags: ()) -> (Todos, Command<Message>) {
(
Todos::Loading,
Command::perform(SavedState::load(), Message::Loaded),
)
}
应用启动的时候处于 Loading
状态我们已经知道了。Command::perform
的第一个参数其实是一个 Future
,第二个参数是 Message
的一个变体构造器,这个变体有一个未命名的字段,所以变体名字也是一个函数。而且,第一个 Future
的结果正是第二个函数的参数类型,第二个函数的返回值是一条消息。也就是说好似在某个异步上下文里执行了:
let result = SavedState::load().await;
return Message::Loaded(result);
用常见的语言描述,这个函数实际上是运行一个 Future,注册一个对 Future 结果的回调函数,回调函数返回的消息则会触发消息处理。
具体功能上,SavedState::load()
则是从文件系统加载 todos.json 里包含的应用状态。因为这是一个可能耗时比较长的 IO 操作,所以不能将这个动作写在 UI 循环里;具体在此处,不能让 new
函数同步等待加载和解析文件,不然我们的 GUI 程序可能要因等待加载文件而卡住。
title
函数比较简单,它返回应用的标题。它也是应用视图的一部分,所以可以在每次更新视图的时候返回不同的标题。
Application 的 update 函数
update
是应用处理消息的函数。再看一下这个函数的签名:
fn update(&mut self, message: Message, _: &mut Clipboard) -> Command<Message>
剪切板参数在这里比较醒目,但是 todos 不使用它,所以我们也不关心这部分。它的 self
是可变引用,我们可以根据消息来改变应用的状态,例如说上面说过的加载 todos.json 过程完成之后,我们就需要将应用从 Loading
改变成 Loaded
,这个动作就只需要实现:
match (self, message) {
(Todos::Loading, Message::Loaded(state)) => {
*self = Todos::Loaded(state);
Command::none()
}
_ => todo!(),
}
这里的 Command::none()
返回一个什么都不做的 Command
。
update
函数还可以也返回 Command::perform
,这样就驱动应用再处理新产生的事件(还是用 update
函数)。
我们可以总结 Command
的设计,它描述一种多半是因为 IO 而需要在 UI 循环外面执行的动作,这种动作最终会产生一个消息,等待应用继续处理。这种要执行的动作可以退化成什么都不做,这时也就不产生消息。
如果你熟悉 Monad,那么可以将 Command<Message>
视作包裹了 Message
的 Monad,update
函数就是定义这种 Monad 的 bind 函数。在 Monad 展开的时候产生消息,同时会更新视图,对视图的更新就是 Monad 所屏蔽的副作用(的一部分)。
Application 的 view 函数
view
函数根据应用当前的状态来绘制 UI。它返回的是 Element<Message>
,代表对视图的抽象 iced 提供了按钮、输入框等等组件,可以用它们组合成 Element
。在 Todos
是 Loaded
时,Element
里包含 TextInput
:
let input = TextInput::new(
input,
"What needs to be done?",
input_value,
Message::InputChanged,
)
.padding(15)
.size(30)
.on_submit(Message::CreateTask);
这里出现了两个消息变体,第一个是 InputChanged
,它注册在 TextInput
的构造器里。第二个是 CreateTask
,它用 on_submit
方法注册。这代表当输入改变时,会产生第一个消息,当(按下回车)提交消息的时候会产生第二个事件。注意第一个消息是以 String
为参数的。
对应地在 update
里要实现处理这两个消息的方法,这里只解释第一个消息。当输入框字符串改变了,我们需要更新视图,显示用户更新之后的字符串。注意上面的 TextInput
是用 input_value
来构造的。它是输入框里显示的字符串,来自 Todos::Loaded
的状态。所以 InputChanged
消息来之后,在 update
里只需要更新 input_value
即可:
match (self, message) {
(Todos::Loaded(_), Message::InputChanged(text)) => {
self.input_value = text
return Command::none()
}
_ => todo!()
}
这样下次视图更新就会将输入改变的值绘制好了。
更多
还有一些细节没有介绍,主要包括样式设计、字体和其他更多消息的处理。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于