Featured image of post 对 Relm4 的一次尝试

对 Relm4 的一次尝试

不~准~说~我~代~码~写~得~烂!

最近看了 Rustcc 一位大佬写的Rust 数据结构与算法,将其中的“递归解汉诺塔问题”稍作修改,改成了一个接收汉诺塔层数后输出需要步骤数而不是输出具体解法的程序。

看起来是一个非常不值一提的简单小程序。然而我突然想到:以前学 Rust+GTK 开发的时候总是苦于没有对一个功能明确的 GUI 程序的构想,没法“在用中学”——把默认的 counter 搞懂就不知道怎么前进了。不如趁此机会,把这个简单的不能再简单的小工具包装成 GUI,正好也学习一下 Relm4 这个库。

Relm4 是什么

Relm4是一个深受Elm的思想启发创作的基于 GTK4 的 Rust GUI 框架,并完全兼容 GTK4 和 libAdwaita。它使简单而快速地开发优雅的跨平台应用程序成为一种习惯,并使你在短短几个小时内就能提高工作效率。

说句题外话,如果你是一名 Gnome 用户,Gnome/GTK 统一而优雅的观感与使用体验,一定是令你难以忘怀的:流畅的动效,一致的窗口布局,明暗主题的自动切换,简单干净的用户界面。一个字,爽。

分析程序

首先,一个汉诺塔程序应该有这些元素:

  1. 一段提示文本,告诉用户需要做什么
  2. 一个数值的输入框,用来输入塔的层数
  3. 一个用来触发计算事件的按钮。
  4. 一个输出框,可以输出对应的数据

于是,我们可以画出如下草图:

草图

对应的,最后我们将做出这样的界面:

实物图

构建 Relm4 程序的基本框架

该部分根据 Relm4 官方教程书写。

一个基本的 Relm 程序应该由三个部分组成:AppModel、AppMsg 和 AppWidget。

AppModel

正如一个人需要脑来记忆和处理信息,在 Relm 程序中,我们也需要一个叫做 AppModel 的结构,用以存储程序中需要用到的信息和对信息的处理方法。比如,在本程序中,我们需要一个整数类型的变量 counter 来存储移动汉诺塔需要的步骤数。那么我们将定义如下结构体:

1
2
3
struct AppModel {
	counter:u128 //用于存储移动汉诺塔需要的步骤数
}

AppMsg

因为我以前经常使用 Qt,所以我想用 Qt 中“信号和槽”的概念类比以下 AppMsg:

我们将“点击 btn_1 这个按钮”这个行为作为“CountSteps”信号的触发条件(将点击 btn_1 和发送“CountSteps”这一信号绑定起来),则当用户点击该按钮时,按钮就会广播这个名为 CountSteps 的信号。而其他组件对于这个信号的处理方法则会在之后的步骤中在 AppModel 中编写

1
2
3
enum AppMsg {
	CountSteps, //此信号当按下按钮时将被广播
}

AppWidgets

让我们再贴一次我们程序的效果图:

没想到吧,我是不可能两次用同一个标签的

这一部分的编写最简单:想要什么组件,在里面声明一下就行了。

1
2
3
4
5
6
7
8
struct AppWidgets {
	window: gtk4::ApplicationWindow, //很明显,我们先得有一个窗口
	vbox: gtk4::Box, //使用一个盒状布局来排列窗口上的组件
	counter_button: gtk4::Button, //需要用户点击的按钮
	label: gtk4::Label, //显示提示文字
	ipt_area: gtk4::SpinButton, //获取用户的输入(ipt -> input)
	opt_area: gtk4::Label, //显示计算后的输出值(opt -> output)
}

填入组件

声明窗口

上面的步骤仅仅只是告诉我们的程序“有什么”,现在我们将利用上面写好的组件模板创建一个窗口。我们通过为 AppWidgets 引入 Widgets 特征实现这一目的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
impl Widgets<AppModel, ()> for AppWidgets {
	type Root = gtk4::ApplicationWindow;
	fn init_view(
		model: &AppModel,
		_parent_widgets: &(),
		sender: Sender<AppMsg>
	) -> Self {
	let vbox = gtk4::Box::builder()
		.orientation(gtk4::Orientation::Vertical)
		.spacing(5)
		.build();

	let window = gtk4::ApplicationWindow::builder
		.title("TowerDemo")
		.build();

	window.set_child(Some(&vbox));
	vbox.set_margin_all(5);
}

可以看出,我们首先实现了 AppWidgets 的关联函数 init_view,它新建了一个窗口实例(window)和一个竖排盒状布局(vbox)并分别定义了他们的属性。最后,它使 vbox 成为了 window 的子内容。

布局已经规划好了,接下来就开始填组件吧!

声明组件

1
2
3
4
5
6
7
8
9
	window.set_child(Some(&vbox));
	vbox.set_margin_all(5);

+	let counter_button = gtk4::Button::with_label("计算");
+	let ipt_area = gtk4::SpinButton::new(Some(&gtk4::Adjustment::new(1.0, 1.0, 20.0, 1.0, 1.0, 0.0)),0.0, 0);
+	let otp_area = gtk4::Label::new(Some(&format!("需要{}步", model.counter)));
+	let label = gtk4::Label::new(Some("请输入塔的层数"));
+	label.set_margin_all(5);
}

值得一提的是,我在创建 SpinButton 时,根据编写 window 与 vbox 的经验和编译器的智能提示,靠“猜”写出了如下代码:

1
	let ipt_area = gtk4::SpinButton::builder().build();

程序倒是没报错,界面也顺利跑起来了,但 SpinButton 却处于不可操作状态。

经过痛苦地查阅文档(不得不说,跟 Relm 的文档比起来 Qt 的文档显得像诗一样优雅直观,引人入胜)发现,定义 SpinButton 需要一个关键参数:gtk4::Adjustment “它表示一个值,该值具有关联的下限和上限,以及步长和页面增量以及页面大小。”,说人话就是,这个莫名其妙的结构体用来告诉我们一个控件值的可调节范围和调节时的基本单位。于是,修改代码如下:

1
2
-	let ipt_area = gtk4::SpinButton::builder().build();
+	let ipt_area = gtk4::SpinButton::new(Some(&gtk4::Adjustment::new(1.0, 1.0, 20.0, 1.0, 1.0, 0.0)),0.0, 0);

在本例中,前面四个值分别表示:初始数值(1.0)、最低数值(1.0)、最高数值(20.0)、步长(1.0),后面两个量与 SpinButton 这个控件关系不大,暂且略过。

最后,我们将这些组件放入 vbox 中:

1
2
3
4
5
6
	label.set_margin_all(5);

+	vbox.append(&label);
+	vbox.append(&ipt_area);
+	vbox.append(&opt_area);
+	vbox.append(&counter);

好了,一个漂亮的壳子搭好了。

编写事件逻辑

处理信号

现在,我们已经有了一个漂亮的界面,但是,如何实现程序的功能呢?

回到程序本身,我们需要一个用来计算汉诺塔移动次数的函数 movetower:

1
2
3
fn movetower(height: u64) -> u128 {
	代码太烂了,无伤大雅,暂且略过
}

那么,我们这一阶段的任务就是:使按钮在被点击的时候广播 CountSteps 这个信号,而 AppModel 接收到该信号的时候就会作出对应的响应。

首先,绑定按钮的点击事件和 CountSteps 信号:

1
2
3
4
5
6
7
8
impl ... for AppWidgets {
...
	vbox.append(&counter);

+	let sender = sender.clone();
+	counter_button.connect_clicked(move |_| {
		send!(sender, AppMsg::CountSteps());
	});

现在,我们已经绑定好了触发事件和信号。我们将回到 AppModel,对信号进行处理。为了做到这一点,我们对 AppModel 引入 AppUpdate 事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
impl AppUpdate for AppModel {
    fn update(
            &mut self,
            msg: AppMsg,
            _components: &(),
            _sender: relm4::Sender<AppMsg>,
        ) -> bool {
        match msg {
            AppMsg::CountSteps() => {
                self.counter = movetower( ??? );
                dbg!(self.counter);
            }
        }
        true
    }
}

可是,movetower 需要一个参数啊!我们怎么把属于 SpinButton 的数据交给 AppModel 处理呢。

答:把它作为 CountSteps 的参数广播出来。

我们在 AppMsg 的定义中修改 CountSteps:

1
2
3
4
enum AppMsg {
-	CountSteps(),
+	CountSteps(u64), //生成需要的步数
}

然后在 init_view 中修改绑定语句:

1
2
3
4
5
-	counter_button.connect_clicked(move |_| {
		send!(sender, AppMsg::CountSteps());
+	let ipt_area_cp = ipt_area.clone();
+	counter_button.connect_clicked(move |_| {
		send!(sender, AppMsg::CountSteps(ipt_area_cp.value_as_int() as u64));

这里我遇到了一个没有找到解决办法的问题。就是对 ipt_area 的所有权处理。最后只能采用多创建一个变量规避所有权问题的丑陋解法。如果有朋友能解决这里的所有权问题,直接让 ipt_area 传值给 CountSteps,请在评论区告知!谢谢!

不过总得来说,我们至少成功的获取到了 ipt_area 的值,并将其转换为 u64 后作为 CountSteps 的参数一起广播出去。我们的程序逻辑就快完成了!

更新组件

Relm4 的组件更新机制

还记得我们前面为 AppWidgets 引入的 Widgets 特征吗,现在我们需要为其编写 root_widget 和 view 两个方法:

1
2
3
4
5
6
    fn root_widget(&self) -> Self::Root {
        self.window.clone()
    }
    fn view(&mut self, model: &AppModel, sender: Sender<<AppModel as Model>::Msg>) {
        self.opt_area.set_label(&format!("需要{}步",model.counter));
    }

root_widget 这个方法返回了程序的根窗口,而 view 方法则负责根据模型的变化来修改 UI 的视图功能。

显然,我们通过传入 AppModel,成功让程序读到了 SpinButton 中的值并处理后打印在输出区域。至此,我们程序的基本逻辑完成了。

现在,我们为其添加 main 函数。

1
2
3
4
5
6
7
fn main (){
    let model = AppModel {
        counter: 0,
    };
    let app = RelmApp::new(model);
    app.run();
}

至此,我们有了一个可运行的,完整的 Rust+GTK 程序。