Relm:一个基于 GTK+ 和 futures 的 Rust GUI 库

更新于 2026-01-18

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 函数、ContainerModel 类型,以及 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)),
        }
    }
}

(完整示例请参见此处。)

此示例中有两个新方法:subscriptionsupdate_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 的详细信息。