第4章 初探
理论就聊到这里,该动手写代码了!
iced 推崇 Elm 架构,认为它是构建交互式应用最自然的方案。 因此,在使用 iced 时,我们将围绕上一章介绍的四大核心概念展开:状态、消息、更新逻辑和视图逻辑。
上一章中,我们拆解并研究了经典的计数器界面。现在,让我们利用 Elm 架构,用 Rust 来实现它。
状态
先从状态说起,也就是应用底层的数据。
在 Rust 中,受所有权和借用规则的限制,仔细思考应用的数据模型至关重要。
我建议大家始终从思考应用的数据及其不同状态入手——不仅包括可能出现的状态,也包括那些必须排除的状态。然后尽可能利用类型系统来让不可能的状态无法存在。
对于我们的计数器界面,只需要一个计数值。由于存在递增和递减两种交互,数值可能为负,因此我们需要一个带符号的整数。
此外,我们知道有些用户“疯狂”,他们可能想计数大量事物。那就给他们 64 个位来折腾吧:
struct Counter {
value: i64,
}
如果一个“疯狂”用户每秒计数 1000 次,耗尽数值大约需要 3 亿年。希望这够用了。
消息
接下来,我们需要定义应用的消息,也就是各种交互。
我们的计数器界面有两种交互:递增和递减。从技术上讲,我们当然可以用一个简单的布尔值来编码这些交互:例如用 true 表示递增,false 表示递减。
不过……在 Rust 里我们还能做得更好!各种交互是互斥的——一个交互本质上就是一组可能取值中的某一个值。而 Rust 恰好有一种完美的数据类型来建模这种概念:enum(枚举)。
于是,我们可以这样定义消息:
enum Message {
Increment,
Decrement,
}
很简单吧!而且这也为长期发展打下了基础。以后如果想给应用添加新的交互——比如一个 Reset 交互——只需在这个类型里增加新的变体即可。枚举既强大又好用。
Update 逻辑
接下来该写我们的 update 逻辑了——也就是消息如何改变应用的状态。
本质上,我们需要编写一段逻辑:给定任意一条消息,就能相应地更新应用状态。在 Rust 中表达这种逻辑最简单、最地道的方式,就是在应用状态上定义一个名为 update 的方法。
对我们的计数器界面来说,只需要根据刚定义的 Message,正确地对 Counter 结构体的 value 做加一或减一操作:
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => {
self.value += 1;
}
Message::Decrement => {
self.value -= 1;
}
}
}
}
很好!现在我们已经可以处理用户交互了。举个例子,假设我们这样初始化计数器:
let mut counter = Counter { value: 0 };
再假设我们想模拟一个用户操作界面的过程——先按两次加号按钮,再按一次减号按钮。借助 update 逻辑,我们可以轻松算出计数器的最终状态:
counter.update(Message::Increment);
counter.update(Message::Increment);
counter.update(Message::Decrement);
这样,Counter 的 value 最终会是 1:
assert_eq!(counter.value, 1);
事实上,我们已经为应用逻辑写了一个简单的测试:
#[test]
fn it_counts_properly() {
let mut counter = Counter { value: 0 };
counter.update(Message::Increment);
counter.update(Message::Increment);
counter.update(Message::Decrement);
assert_eq!(counter.value, 1);
}
注意写起来多轻松!到目前为止,我们只是在利用非常基础的 Rust 概念。完全没有依赖项! 你可能会纳闷……“GUI 代码在哪里?!”
这正是 Elm Architecture 的一大优势。正如前一章所述,控件(widgets)是界面中唯一本质可复用的基本概念。我们之前定义的所有部分都是应用特定的,因此完全不需要了解 UI 库!
Elm Architecture 妥善地拥抱了用户界面各部分不同的本质——将 状态、消息 和 更新逻辑 与 控件 和 视图逻辑 解耦。
视图逻辑
最后,我们要定义的只剩 视图逻辑 了——即状态如何决定应用的控件。
这里正是魔法发生的地方!在视图逻辑中,我们将应用的状态及其可能的交互结合起来,生成用户界面可视化表示,并展示给用户。
我们已经学过,这种可视化表示由控件构成——即界面中视觉上可区分的单元。大多数控件并非应用特定的,它们可以被抽象并打包进可复用的库中。这些库通常被称为 widget toolkits、GUI frameworks,或者干脆叫 GUI libraries。
这里终于要引出 iced 了!iced 是一个跨平台的 Rust GUI 库。它封装了一整套即开即用的控件,包括按钮和数字。这正是我们计数器所需要的。
按钮
我们的计数器界面有两个 按钮。让我们看看如何用 iced 定义它们。
在 iced 中,widget(控件)是独立的值。就像你 can 在变量中存储整数一样,你也可以将 widget 作为值来使用。通常,我们会使用 widget 模块中的辅助函数来创建这些值。
对于我们的按钮,可以使用 button 辅助函数:
use iced::widget::button;
let increment = button("+");
let decrement = button("-");
这很简单,不是吗?目前,我们只是为按钮定义了几个变量。
可以看到,widget 辅助函数可以接受参数来配置 widget 的各个部分。在这个例子中,button 函数接收一个参数,用于描述按钮的内容。
数字
我们的按钮已经妥善保存在 increment 和 decrement 变量中了。那如何以同样的方式处理计数器的值呢?
虽然 iced 并没有一个专门的 number widget,但它有一个更通用的 text widget,可以用来显示任何文本——包括数字:
use iced::widget::text;
let counter = text(15);
很好!和 button 一样,text 也接受一个参数来描述其内容。因为我们才刚开始学习,所以暂时先硬编码 15。
布局
好的!我们有了两个按钮(increment 和 decrement)以及计数值(counter)。这就全部齐了,对吗?
别急!我们计数器界面中的 widget 显示有特定的顺序。给定三个 widget,总共有六种不同的排列方式。但我们需要的顺序是:increment、counter 和 decrement。
描述这种顺序的一个非常简单的方法是创建一个包含这些 widget 的列表:
let interface = vec![increment, counter, decrement];
但我们仍然缺了一样东西!不仅顺序是特定的,我们的界面还有特定的视觉布局。
目前这些控件是上下堆叠的,但其实也完全可以改成从左到右排列。因为我们到目前为止的描述中,还没有涉及控件的布局(layout)。
在 iced 中,布局同样是用……控件来描述的!没错,并非所有控件都直接产生视觉效果,有些控件只负责管理其他控件的位置。而且控件本质上就是普通的值,可以自然地嵌套和组合。
计数器需要的这种垂直布局,可以用 column 控件实现:
use iced::widget::column;
let interface = column![increment, counter, decrement];
和之前的代码很相似。iced 提供了 column! 宏,用来按特定顺序把若干控件组合成一个 column——用法类似于 vec!。
交互
至此,我们的 interface 变量中已经有一个代表计数器界面的 column 了。但真运行起来,你会很快发现问题。
按钮完全无法点击。这是当然的——我们还没为它们定义任何交互。注意,到目前为止,视图逻辑里根本没用到 Message 枚举。如果不指明,界面又怎么知道该产生什么消息呢?现在就来补上。
在 iced 中,每个控件都有对应的配置类型,可以通过简单的 builder 方法进一步设置。button 辅助函数返回的是一个 Button 类型实例,它有一个 on_press 方法,可以用来指定用户按下按钮时应当产生什么消息:
use iced::widget::button;
let increment = button("+").on_press(Message::Increment);
let decrement = button("-").on_press(Message::Decrement);
很好,交互已经接上了。但还有一个小细节:按钮可以被按很多次,同一个按钮可能需要多次产生相同的 Message。因此,我们的 Message 类型必须可克隆(cloneable)。
我们可以轻松地为 Clone trait 派生实现——顺便也把 Debug 和 Copy 一起加上:
#[derive(Debug, Clone, Copy)]
enum Message {
Increment,
Decrement,
}
在 The Elm Architecture 中,消息代表事件——由纯数据构成。因此,为 Message 类型派生 Debug 和 Clone 应该总是很简单的。
View
我们快完成了!只剩下最后一件事:将应用状态连接到 view 逻辑。
让我们把到目前为止写的所有 view 逻辑整合起来:
use iced::widget::{button, column, text};
// 按钮
let increment = button("+").on_press(Message::Increment);
let decrement = button("-").on_press(Message::Decrement);
// 数字
let counter = text(15);
// 布局
let interface = column![increment, counter, decrement];
如果运行这段 view 逻辑,我们就能点击按钮了。但点击后什么也不会发生。计数器会卡住——始终显示数字 15。我们的界面是完全无状态的!
显然,问题在于我们的 counter 变量包含了一个硬编码 15 的文本 widget。相反,我们想要实际显示 Counter 状态中的 value 字段。这样,当按钮被按下、我们的更新逻辑被触发时,文本 widget 就会显示新的 value。
我们可以轻松做到这一点:将 view 逻辑放在 Counter 的方法中——就像我们处理更新逻辑那样:
use iced::widget::{button, column, text};
impl Counter {
fn view(&self) {
// 按钮
let increment = button("+").on_press(Message::Increment);
let decrement = button("-").on_press(Message::Decrement);
// 数字
let counter = text(self.value);
// 布局
let interface = column![increment, counter, decrement];
}
}
现在,我们的 counter 变量将始终包含一个显示 Counter 当前 value 的文本 widget。太棒了!
不过,你可能已经注意到了,这个 view 方法其实毫无用处——它构建了一个 interface,然后……什么都没做,直接丢弃了!
在 iced 中,构建和配置组件不会产生副作用。在你的视图代码中,不需要担心任何“全局上下文”。
我们不应该丢弃 interface,而是需要返回它。记住,我们 视图逻辑 的目的是定义用户界面的组件;而 interface 变量中的内容,正是我们想要的界面描述:
use iced::widget::{button, column, text, Column};
impl Counter {
fn view(&self) -> Column<Message> {
// 按钮
let increment = button("+").on_press(Message::Increment);
let decrement = button("-").on_press(Message::Decrement);
// 数字
let counter = text(self.value);
// 布局
let interface = column![increment, counter, decrement];
interface
}
}
搞定!注意 view 方法现在需要一个返回类型。返回类型是 Column,因为 column! 宏生成的是这种类型的组件——正如 button 生成 Button 类型的组件一样。
你可能也注意到了,Column 类型带有一个泛型类型参数。这个参数简单地指定了该组件可能产生的消息类型。在此例中,它接收我们的 Message,因为列内的 increment 和 decrement 按钮产生的正是这种类型的消息。
iced 非常重视类型安全——利用类型系统和编译期保证,尽可能减少运行时错误。
就这样……我们的视图逻辑完成了!但等等……现在写起来有点啰嗦。由于界面很简单,让我们把所有内容内联起来:
use iced::widget::{button, column, text, Column};
impl Counter {
fn view(&self) -> Column<Message> {
column![
button("+").on_press(Message::Increment),
text(self.value),
button("-").on_press(Message::Decrement),
]
}
}
这样简洁多了,甚至和实际界面看起来一模一样!由于创建 widget 只是生成值、没有任何副作用,我们可以随意调整视图逻辑,而不用担心弄坏别的东西。再也没有“幽灵般的远程作用”了!
计数器界面到此就完成了。相信你已经迫不及待想运行它了吧?走起!