进阶 iced.rs 2026-09-13 21:01:26 · 0 阅读

第4章 初探

理论就聊到这里,该动手写代码了!

iced 推崇 Elm 架构,认为它是构建交互式应用最自然的方案。 因此,在使用 iced 时,我们将围绕上一章介绍的四大核心概念展开:状态消息更新逻辑视图逻辑

上一章中,我们拆解并研究了经典的计数器界面。现在,让我们利用 Elm 架构,用 Rust 来实现它。

A classical counter interface

状态

先从状态说起,也就是应用底层的数据。

在 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);

这样,Countervalue 最终会是 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 妥善地拥抱了用户界面各部分不同的本质——将 状态消息更新逻辑控件视图逻辑 解耦。

视图逻辑

最后,我们要定义的只剩 视图逻辑 了——即状态如何决定应用的控件。

这里正是魔法发生的地方!在视图逻辑中,我们将应用的状态及其可能的交互结合起来,生成用户界面可视化表示,并展示给用户。

A classical counter interface

我们已经学过,这种可视化表示由控件构成——即界面中视觉上可区分的单元。大多数控件并非应用特定的,它们可以被抽象并打包进可复用的库中。这些库通常被称为 widget toolkitsGUI 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 函数接收一个参数,用于描述按钮的内容。

数字

我们的按钮已经妥善保存在 incrementdecrement 变量中了。那如何以同样的方式处理计数器的值呢?

虽然 iced 并没有一个专门的 number widget,但它有一个更通用的 text widget,可以用来显示任何文本——包括数字:

use iced::widget::text;

let counter = text(15);

很好!和 button 一样,text 也接受一个参数来描述其内容。因为我们才刚开始学习,所以暂时先硬编码 15

布局

好的!我们有了两个按钮(incrementdecrement)以及计数值(counter)。这就全部齐了,对吗?

别急!我们计数器界面中的 widget 显示有特定的顺序。给定三个 widget,总共有六种不同的排列方式。但我们需要的顺序是:incrementcounterdecrement

描述这种顺序的一个非常简单的方法是创建一个包含这些 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 派生实现——顺便也把 DebugCopy 一起加上:

#[derive(Debug, Clone, Copy)]
enum Message {
    Increment,
    Decrement,
}

在 The Elm Architecture 中,消息代表事件——由纯数据构成。因此,为 Message 类型派生 DebugClone 应该总是很简单的。

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,因为列内的 incrementdecrement 按钮产生的正是这种类型的消息。

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 只是生成值、没有任何副作用,我们可以随意调整视图逻辑,而不用担心弄坏别的东西。再也没有“幽灵般的远程作用”了!

计数器界面到此就完成了。相信你已经迫不及待想运行它了吧?走起!

评论 (0)