Rust 为什么不适合开发 GUI

前言

在当今科技蓬勃发展的时代,Rust 编程语言正崭露头角,逐步为世界上诸多重要基础设施提供动力支持。从存储海量信息到应用于 Linux 内核,Rust 展现出强大的实力。然而,当涉及构建 GUI(图形用户界面)时,Rust 却面临着诸多挑战。据数据显示,超过 56% 的 Rust 开发者认为其 GUI 开发亟待大幅改进,这也是许多人起初不愿采用 Rust 进行相关开发的重要原因。

Rust 的独特之处

Rust 自诞生之初,便以独特的姿态区别于其他编程语言。在众多编程语言中,垃圾回收机制较为常见,它能自动管理内存的分配与释放,极大减轻了开发者的负担。而 Rust 采用了所有权机制,这一机制在编译时生效。也就是说,值由变量拥有,变量可对值进行引用,当拥有变量超出作用域时,其所拥有的值会被自动释放。

此外,Rust 能够有效防范多线程同时访问相同数据的情况,即数据竞争问题。它通过确保同一时刻要么只有一个可变引用,要么有多个不可变引用,保证引用始终有效,并且当存在有效引用时,相关值不能被修改。同时,Rust 并非像 Java、C++ 或 JavaScript 那样的面向对象语言,它不支持抽象类和类继承。

例如,在面向对象语言中,通常会有一个顶层类 Component,其中包含 draw 方法,像按钮(Button)或文本(Text)等组件会继承自这个类并复用其函数。但在 Rust 中,情况有所不同,它使用 traits。开发者可以在库中添加一个名为 draw 的通用 trait,只要按钮对象、文本对象和图像对象实现了这个 Draw trait,它们就会被视为 UI 组件。甚至可以将一个随机的 sandwich 对象添加到 UI 组件库中,只要它实现了 Draw trait,当然这在实际开发中不太可能通过代码评审。

Rust 构建 GUI 之难

那么,究竟是什么让用 Rust 构建 GUI 如此困难呢?前面提到的 Rust 的独特之处,恰恰也是构建 GUI 时的阻碍因素。在编程领域,UI 通常被设计为树状结构,但使用 Rust 的继承机制构建树状结构极为困难。

以构建一个简单的登录界面为例,在 Android 开发中,视图树形结构有着清晰的层级关系。Android 的视图体系基于 View 和 ViewGroup 类。ViewGroup 是一个特殊的 View,它可以包含多个子 View,就像是树枝可以长出许多树叶一样,这就形成了一个树形结构。

比如,登录界面的最外层可能是一个 LinearLayout(线性布局,属于 ViewGroup 的一种),它决定了内部组件的排列方式是水平还是垂直。在这个 LinearLayout 里,可能有两个 EditText(输入框,属于 View)用于输入用户名和密码,还有一个 Button(按钮,同样属于 View)用于触发登录操作。

在代码实现上,开发者会在 XML 布局文件中描述这个树形结构。假设布局文件名为 activity_login.xml,代码可能如下:

xml 复制代码
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <EditText
        android:id="@+id/username_edit_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="用户名"/>
    <EditText
        android:id="@+id/password_edit_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="密码"
        android:inputType="textPassword"/>
    <Button
        android:id="@+id/login_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="登录"/>
</LinearLayout>

在运行时,Android 系统会根据这个 XML 文件构建出相应的视图树形结构。当用户进行操作,比如点击按钮时,事件会从最上层的视图容器开始,沿着树形结构向下传递,找到对应的按钮 View 并触发相应的点击事件处理逻辑。

而在 Rust 中,由于缺乏像 Android 这种基于类继承的成熟视图体系,构建类似的树形结构就变得复杂许多。Rust 的 trait 不存储数据,导致每个组件需要自行管理其下的子组件,这使得遍历树状结构变得困难。

以构建登录界面为例,假设我们定义一个用于绘制 UI 组件的 trait,比如Draw:

rust 复制代码
trait Draw {
    fn draw(&self);
}

这里的 Draw trait 规定了实现它的类型必须拥有draw方法,但它并没有为实现它的类型提供存储数据的空间。当我们创建登录界面的各个组件(如输入框和按钮)并让它们实现 Draw trait 时,每个组件都需要自行处理数据存储的问题。

rust 复制代码
struct LoginButton {
    // 按钮的相关数据,如文本、位置等
    text: String,
    x: i32,
    y: i32,
}
impl Draw for LoginButton {
    fn draw(&self) {
        // 绘制按钮的逻辑,使用自身存储的数据
        println!("Drawing button with text: {}", self.text);
    }
}

在上述代码中,LoginButton 结构体实现了 Draw trait,它需要自己定义和管理数据(text、x、y)。相比之下,在 Android 中,视图类(如 EditText、Button)继承自 View 类,View 类及其父类会为子类提供一些默认的数据存储和管理机制,例如位置、大小等属性,子类可以直接使用或继承这些数据。

除了状态管理的不方便,Rust 的可变性规则也给 UI 组件状态的动态更新带来了挑战。Rust 的可变性规则主要用于确保内存安全和避免数据竞争。简单来说,在同一时间内,一个数据要么有多个不可变引用(可以理解为只读访问),要么只有一个可变引用(可以修改数据),但不能同时存在可变和不可变引用。

例如,在登录界面的场景中,如果我们要根据用户输入实时显示错误提示信息,在 Rust 中实现起来就不像在 Android 开发中那么直观。因为可能会涉及到状态的动态更新,此时遇到可变性规则的挑战。

假设我们有一个登录逻辑,需要根据用户名和密码的输入情况更新错误提示信息:

rust 复制代码
fn login(username: &str, password: &str) -> String {
    let mut error_message = String::new();
    if username.is_empty() {
        error_message.push_str("用户名不能为空");
    }
    if password.len() < 6 {
        if!error_message.is_empty() {
            error_message.push_str(", ");
        }
        error_message.push_str("密码长度至少为6位");
    }
    error_message
}

在这个例子中,error_message 是可变的,以便在不同的条件下添加错误信息。这个代码在单一线程运行,同一时刻只有一个可变引用指向 error_message,是可以通过编译的。但如果在更复杂的 UI 场景中,多个线程或不同的代码块同时尝试访问和修改 error_message,就会违反 Rust 的可变性规则,导致编译错误。因为 Rust 要保证数据在任何时刻的状态都是可预测的,避免出现数据竞争和未定义行为。

比如下面这样

rust 复制代码
use std::thread;

fn main() {
    let mut error_message = String::new();

    let handle1 = thread::spawn(move || {
        error_message.push_str("线程 1 产生的错误");
    });

    let handle2 = thread::spawn(move || {
        error_message.push_str("线程 2 产生的错误");
    });

    handle1.join().unwrap();
    handle2.join().unwrap();

    println!("{}", error_message);
}

在这个示例里,多个线程同时尝试修改 error_message,Rust 编译器会检测到这种情况并报错,因为这违反了 Rust 的可变性规则,可能会引发数据竞争问题。

当然,多线程更新字符串的情况不多,也许有人说这个例子不具代表性,再看一个更具场景的情况。在 GUI 开发里,常常需要根据用户的操作动态更新 UI 状态,并且重新渲染视图。假设我们要开发一个简单的计数器界面,用户点击按钮时,计数器的值会增加。

在 Rust 里,为了保证内存安全,可变性规则会对状态更新和视图渲染之间的交互产生影响。以下是一个简化的示例代码:

rust 复制代码
// 假设这是一个简单的 UI 组件
struct Counter {
    value: u32,
}

impl Counter {
    fn increment(&mut self) {
        self.value += 1;
    }
    fn draw(&self) {
        println!("当前计数器的值: {}", self.value);
    }
}

fn main() {
    let mut counter = Counter { value: 0 };
    // 模拟用户点击按钮
    counter.increment();
    counter.draw();
}

在这个示例中,Counter 结构体表示一个计数器组件,increment 方法用于增加计数器的值,draw 方法用于渲染计数器的当前值。编译正常。

如果稍不注意, main 函数写成下面这样,编译就出错了

rust 复制代码
fn main() {
    let mut counter = Counter { value: 0 };
    let mut_ref = &mut counter;
    mut_ref.increment();

    // 这里会产生编译错误
    counter.draw(); 
}

上面 main 函数中,我们首先对 counter 进行了可变借用,创建了可变引用 mut_ref,并调用 mut_ref.increment() 方法对 counter 的值进行修改。

接着,我们尝试直接调用 counter.draw() 方法。但由于此时 counter 仍处于被可变借用的状态(mut_ref 的生命周期还未结束),Rust 的可变性规则不允许在可变借用期间对同一个数据进行不可变借用。因此,counter.draw() 这行代码会导致编译错误。

复制代码
error[E0502]: cannot borrow `counter` as immutable because it is also borrowed as mutable
  --> src/main.rs:17:5
   |
15 |     let mut_ref = &mut counter;
   |                   -------- mutable borrow occurs here
16 |     mut_ref.increment();
17 |     counter.draw(); 
   |     ^^^^^^^ immutable borrow occurs here
18 | }
   | - mutable borrow ends here

通过正确和错误两个版本代码的对比,可以看出

  • 错误版本:对 counter 进行了可变借用,创建了可变引用 mut_ref,并且在可变借用的生命周期内尝试对 counter 进行不可变借用,违反了 Rust 的可变性规则。
  • 正确版本:没有同时存在可变借用和不可变借用的冲突情况。先直接调用 counter.increment() 方法对 counter 进行可变操作,操作完成后,可变借用结束,再调用 counter.draw() 方法进行不可变操作,符合 Rust 的可变性规则。

但在实际的 GUI 应用中,UI 组件的状态可能会受到多个因素的影响,状态更新和视图渲染的逻辑也会更加复杂,很可能需要根据不同的条件更新多个 UI 组件的状态,并且在合适的时机进行视图渲染。Rust 的可变性规则会让这种状态管理变得更加困难,稍有不慎就会出现编译错误,增加了开发者的心智负担。

应对之策与实践探索

尽管困难重重,但并非毫无解决办法。有一个专门的网站 https://areweguiyet.com/ 致力于更新 Rust 在 GUI 开发方面的进展情况。在开源社区,也有许多项目取得了显著进展,比如 ICED 或 Tauri,它们使用 Rust 为原生 Web 视图提供支持。

另一种有效的解决方案是完全摒弃面向对象编程,深入采用 Rust 的方式来处理问题。例如使用 ELM 架构,它由模型(Model)、视图(View)和更新(Update)组成。模型存储视图的所有状态,视图将模型数据转换为屏幕上可见的内容,更新则负责使用程序员定义的对象 "MSGs" 来修改模型。

这种架构其实就是 Android 近年来推崇的 UDF(单项数据流),在 Rust 中实现这种架构有诸多优势,它是功能性且可变的,开发者无需直接修改数据,因为数据始终通过更新函数进行处理。例如,可以插入一个全新的值,由于模型只有一个单一所有者,不会触发任何警报。此外,Rust 的枚举(Enums)使得确定不同数据类型变得容易,开发者可以在代码中轻松进行模式匹配,ICED 项目就有很好的示例展示如何使用 Rust 枚举通过按钮来增加或减少数字。

但是 ELM 架构并非完美无缺,也有一些尝试对其进行替代的方案,其中一种替代方案是实体组件系统架构(Entity Component System Architecture)。在这种架构中,Entity(实体)和 Component(组件)是两个核心概念。

Entity 可以理解为一个唯一的标识符,它本身不包含任何数据或行为。在 ECS 架构里,Entity 就像是一个容器或者一个 "占位符",用于将不同的 Component 组合在一起。例如一个游戏中,它代表其中一个角色、一个道具,或者 GUI 界面中的一个按钮、一个文本框等。在 Rust 中,Entity 通常用一个简单的整数 ID 来表示。

Component 是包含数据的最小单元,它只负责存储特定类型的数据,而不包含任何行为。例如,在一个游戏中,可能有表示位置的 PositionComponent、表示速度的 VelocityComponent;在 GUI 开发中,可能有表示文本内容的 TextComponent、表示颜色的 ColorComponent 等。每个 Component 专注于一种特定的属性或状态。

著名的 Warp 终端项目就采用了这种方式实现。将每个组件称为 view,并赋予其一个唯一的 ID,即 entity id。每个窗口存储实体 ID 到实际视图的映射,通过这种方式存储与视图相关的任何状态,并且可以存储每个视图到父视图的映射,以便在树状结构中向上遍历。这些数据以一系列由系统拥有的映射和列表形式存储,这是目前在 Rust 中模拟面向对象编程语言最接近的方式。

通过这种实现方式,Warp 能够创建丰富的 UI 元素,并且性能几乎可与其他任何终端媲美。如果读者对此感兴趣,可以通过视频描述中的链接免费下载 Warp 来体验其 GUI。

展望

在 Rust 构建 GUI 的领域中,尽管充满挑战,但通过不断探索和创新,开发者们已经找到多种有效的解决途径,并且在实践中取得了不错的成果,未来 Rust 在 GUI 开发方面有望迎来更广阔的发展前景 。

相关推荐
Source.Liu28 分钟前
【学Rust写CAD】20 平铺模式结构体(spread.rs)
rust·cad
Android洋芋1 小时前
C语言深度解析:从零到系统级开发的完整指南
c语言·开发语言·stm32·条件语句·循环语句·结构体与联合体·指针基础
bjxiaxueliang1 小时前
一文详解QT环境搭建:Windows使用CLion配置QT开发环境
开发语言·windows·qt
草捏子1 小时前
从CPU原理看:为什么你的代码会让CPU"原地爆炸"?
后端·cpu
Run_Teenage2 小时前
C语言 【初始指针】【指针一】
c语言·开发语言
嘟嘟MD2 小时前
程序员副业 | 2025年3月复盘
后端·创业
苹果.Python.八宝粥2 小时前
Python第七章02:文件读取的练习
开发语言·python
胡图蛋.2 小时前
Spring Boot 支持哪些日志框架?推荐和默认的日志框架是哪个?
java·spring boot·后端
无责任此方_修行中2 小时前
关于 Node.js 原生支持 TypeScript 的总结
后端·typescript·node.js
J不A秃V头A2 小时前
Redis批量操作详解
开发语言·redis