Relm 是一个新的 crate(Rust 库),用于在 Rust 中开发异步 GUI 应用程序。
简介
Relm 提供了一种将 futures/tokio 与 GTK+ 结合的优雅方式,从而轻松编写异步 GUI 应用程序。该库受到 Elm 编程语言的启发,采用了 The Elm Architecture(TEA)模式,并针对 Rust 语言和桌面应用程序进行了适配。
在 Rust 中使用 GTK+ 的痛点
我之所以创建 relm,是因为在用 Rust 编写一个相对复杂的 GTK+ 应用程序时,反复遇到了一些问题。这些痛点如下所述。
状态变更(State mutation)
通常的做法是将某些数据(称为 model)与 widget 关联起来,并在用户执行操作(例如点击按钮)时对其进行修改。然而,在 gtk-rs 中,无法直接在响应事件时修改状态。为此,必须使用 Rc<RefCell<Model>>:
let model = Rc::new(RefCell::new(Model { count: 0 }));
button.connect_clicked(move |_| {
(*model.borrow_mut()).count += 1;
label.set_text(&format!("{}", (*model.borrow()).count));
});
这种方式不够直观,而且容易出错,因为错误只能在运行时被发现,而不能在编译时捕获。
异步 UI
这个问题更多与 GTK+ 本身有关。有时我们希望等待一个 HTTP 请求完成或某个超时发生后再更新 UI,同时又不冻结界面。GTK+ 本身支持这种功能。但我认为,如果能提供一种抽象机制来简化异步编程,将更容易编写不会阻塞 UI 的 GUI 应用。
无法轻松创建新控件(widgets)
在 Vala(以及其他面向对象语言)中,创建新的控件非常简单,只需继承某个已有控件类即可:
class MyWidget : DrawingArea {
}
然后就可以像使用其他控件一样使用它:
var widget = new MyWidget();
window.add(widget);
但在 Rust 中却没那么容易,因为 Rust 不支持子类化(subclassing)。
一种变通方法是为你的控件实现 Deref trait:
struct MyWidget {
drawing_area: DrawingArea,
}
impl Deref for MyWidget {
type Target = DrawingArea;
fn deref(&self) -> &DrawingArea {
&self.drawing_area
}
}
但这样你就需要手动解引用变量才能使用控件:
window.add(&*widget);
这同样不够直观。尽管还有其他解决方案,但目前在 gtk-rs 中并不是推荐做法。
出于以上所有原因,我决定编写 relm,并结合 futures 和 tokio。这使得 relm 的用户可以使用 tokio 发起 HTTP 请求而不会冻结 UI。
受 Elm 启发
Relm 的灵感来源于 Elm 编程语言。在我使用 Elm 一段时间后,我发现它提供了一种很好的 MVC 实现方式。在 Elm 中,你只需提供一个 model、一个 update 函数(用于在事件发生时转换 model)以及一个 view 函数(以声明式方式创建视图)。视图通过消息传递与 update 函数通信。
在 relm 中,情况非常类似,但我对这一模式进行了针对 Rust 的调整。例如,update 和 view 是 Widget trait 的方法。此外,在合适的地方使用了可变性(Elm 是纯函数式语言,因此不使用可变性)。
使用 #[widget] 属性的示例
让我们看一个使用 relm 创建 GUI 应用程序的示例:
// 省略导入语句。
// 定义 model 的结构。
#[derive(Clone)]
struct Model {
counter: i32,
}
// 可发送给 update 函数的消息。
#[derive(Msg)]
enum Msg {
Decrement,
Increment,
Quit,
}
#[widget]
impl Widget<Msg> for Win {
// 初始 model。
fn model() -> Model {
Model {
counter: 0,
}
}
// 根据接收到的消息更新 model。
fn update(&mut self, event: Msg, model: &mut Model) {
match event {
Decrement => model.counter -= 1,
Increment => model.counter += 1,
Quit => gtk::main_quit(),
}
}
view! {
gtk::Window {
gtk::Box {
// 设置 Box 的 orientation 属性。
orientation: Vertical,
// 在 Box 内创建一个 Button。
gtk::Button {
// 当按钮被点击时,发送 Increment 消息。
clicked => Increment,
label: "+",
},
gtk::Label {
// 将 label 的 text 属性绑定到 model 的 counter 字段。
text: &model.counter.to_string(),
},
gtk::Button {
clicked => Decrement,
label: "-",
},
},
delete_event(_, _) => (Quit, Inhibit(false)),
}
}
}
fn main() {
Relm::run::<Win>().unwrap();
}
(此示例已截断,完整代码请参见此处。)
熟悉 Elm 的读者会注意到它与 relm 的相似之处。大量“魔法”发生在 #[widget] 属性中。[1] 例如,该属性会自动生成包含 GTK+ 和 relm 控件的 Win 结构体。
view! 宏允许以声明式的方式编写视图。你可以使用胖箭头语法(=>)将 GTK+ 信号连接到消息发送。例如,以下代码:
gtk::Button {
clicked => Increment,
}
表示当按钮被点击时,会向 update 函数发送 Increment 消息。
此外,该属性还会在 model 的 counter 字段更新时,自动插入对 gtk::Label::set_text() 的调用。
不使用属性的示例
如果你无法依赖 Rust nightly 版本,则可以避免使用 #[widget] 属性。不过,这需要编写一些样板代码。以下是不使用该属性的相同示例:
#[derive(Clone)]
struct Model {
counter: i32,
}
#[derive(Msg)]
enum Msg {
Decrement,
Increment,
Quit,
}
// 创建一个结构体,用于保存视图中使用的控件。
struct Win {
counter_label: Label,
window: Window,
}
impl Widget<Msg> for Win {
// 指定外部控件的类型。
type Container = Window;
// 指定此控件使用的 model 类型。
type Model = Model;
// 返回外部控件。
fn container(&self) -> &Self::Container {
&self.window
}
fn model() -> Model {
Model {
counter: 0,
}
}
fn update(&mut self, event: Msg, model: &mut Model) {
let label = &self.counter_label;
match event {
Decrement => {
model.counter -= 1;
// 手动更新视图。
label.set_text(&model.counter.to_string());
},
Increment => {
model.counter += 1;
label.set_text(&model.counter.to_string());
},
Quit => gtk::main_quit(),
}
}
fn view(relm: RemoteRelm<Msg>, _model: &Self::Model) -> Self {
// 使用常规的 GTK+ 方法调用创建视图。
let vbox = gtk::Box::new(Vertical, 0);
let plus_button = Button::new_with_label("+");
vbox.add(&plus_button);
let counter_label = Label::new("0");
vbox.add(&counter_label);
let minus_button = Button::new_with_label("-");
vbox.add(&minus_button);
let window = Window::new(WindowType::Toplevel);
window.add(&vbox);
window.show_all();
// 当按钮被点击时,发送 Increment 消息。
connect!(relm, plus_button, connect_clicked(_), Increment);
connect!(relm, minus_button, connect_clicked(_), Decrement);
connect!(relm, window, connect_delete_event(_, _) (Some(Quit), Inhibit(false)));
Win {
counter_label: counter_label,
window: window,
}
}
}
fn main() {
Relm::run::<Win>().unwrap();
}
(此示例已截断,完整代码请参见此处。)
你可以看到它与前一个示例有些相似,但现在你需要像直接使用 gtk-rs 那样创建控件,即调用如 Button::new_with_label() 这样的构造函数。此外,你还需要在 update 方法中手动更新视图:
label.set_text(&model.counter.to_string());
你还必须手动实现 container 函数、Container 和 Model 类型,以及 Win 结构体——这些在使用 #[widget] 属性时都是自动生成的。
与 gtk-rs 的一个区别在于信号连接方式:
connect!(relm, plus_button, connect_clicked(_), Increment);
这等价于前一个示例中使用的:
clicked => Increment
使用 tokio 的示例
以上是一个基本示例。现在来看一个更复杂的示例,实际使用 tokio 向 WebSocket 服务器发送消息:
type WSService = ClientService<TcpStream, WebSocketProtocol>;
#[derive(Clone)]
struct Model {
// 要发送的消息。
message: String,
service: Option<WSService>,
// 包含从 WebSocket 服务器接收到的所有消息。
text: String,
}
#[derive(Msg)]
enum Msg {
// 用户更改了要发送的消息。
Change(String),
// 成功连接到服务器。
Connected(WSService),
// 从服务器接收到的消息。
Message(String),
// 向服务器发送消息。
Send,
Quit,
}
#[widget]
impl Widget<Msg> for Win {
fn model() -> Model {
Model {
message: String::new(),
service: None,
text: String::new(),
}
}
fn subscriptions(relm: &Relm<Msg>) {
// 连接到 WebSocket 服务器。
let handshake_future = ws_handshake(relm.handle());
let future = relm.connect_ignore_err(handshake_future, Connected);
relm.exec(future);
}
fn update(&mut self, event: Msg, model: &mut Model) {
match event {
Change(message) => model.message = message,
Connected(service) => model.service = Some(service),
Message(message) => model.text += &format!("{}\n", message),
Send => {
model.message = String::new();
self.entry.grab_focus();
},
Quit => gtk::main_quit(),
}
}
fn update_command(relm: &Relm<Msg>, event: Msg, model: &mut Model) {
if let Send = event {
if let Some(ref service) = model.service {
// 向服务器发送消息。
let send_future = ws_send(service, &model.message);
relm.connect_exec_ignore_err(send_future, Message);
}
}
}
view! {
gtk::Window {
gtk::Box {
orientation: Vertical,
gtk::Label {
text: &model.text,
},
// 为此控件命名,以便在 update 函数中使用。
#[name="entry"]
gtk::Entry {
activate => Send,
changed(entry) => Change(entry.get_text().unwrap_or_else(String::new)),
text: &model.message,
},
gtk::Button {
clicked => Send,
label: "Send",
},
},
delete_event(_, _) => (Quit, Inhibit(false)),
}
}
}
(完整示例请参见此处。)
此示例中有两个新方法:subscriptions 和 update_command。
前者用于在应用程序启动时执行 futures。在此例中,我们发起 WebSocket 连接,并将 future 与 Connected 消息关联。此示例忽略了可能的错误,但也可以处理 future 解析为错误的情况。
update_command 方法用于在 relm 中接收到消息时执行 futures。此示例发送 Message(message) 消息,其中 message 是来自 WebSocket 服务器的响应。Relm 在另一个线程中执行 update_command 方法,该线程运行 tokio 事件循环:这就是为什么我们不能在 update 方法中执行 futures 的原因。
如你所见,来自 GTK+ 控件的事件和来自 futures 的事件以相同的方式进行管理。
关于 API 不稳定性的警告
需要注意的是,relm 目前正处于积极开发阶段,尚未经过充分测试。此外,其 API 目前不稳定,将在后续版本中更新。例如,当该 crate 切换到使用 futures-glib 而不是在另一个线程中运行 tokio 事件循环时,update_command 方法将被移除(并合并到 update 方法中)。
结论
以上是对 relm 的简要介绍。如果你想了解更多,请查看示例代码。README 和文档也提供了关于如何使用此 crate 的详细信息。