为什么用 Rust 构建 UI 如此困难? | Warp


尽管 Rust 很棒——它还没有成为构建 UI 的通用语言。

Warp,我们一直在用 Rust 构建一个自定义 UI 框架1,我们用它来在 GPU 上进行渲染。构建这个框架非常棘手并且是一项巨大的投资,但它很好地帮助我们构建了一个具有丰富 UI 元素并且与地球上任何其他终端一样快的终端。如果我们使用像 Electron 或 Flutter 这样的 UI 库,这种性能水平几乎是不可能的。

在这篇文章中,我将讨论为什么 Rust 独特的内存管理模型和缺乏继承性使得传统技术难以构建 UI 框架,以及我们一直在努力解决的一些方法。我相信这些方法中的一种,或者它们的某种组合,最终将导致一个稳定的跨平台 UI 工具包,用于每个人都可以使用的高性能 UI 渲染。

是什么让 Rust 与众不同?
‍Rust 通过在编译时强制执行的称为“所有权”的概念来处理内存管理。这不同于其他语言,它们通过使用垃圾收集器在运行时删除未使用的对象来提供自动内存管理。

Rust 所有权通过执行以下规则来工作:

  • 值由变量拥有
  • 值可以被其他变量引用(下面提到一些注意事项)
  • 当拥有变量超出范围时,该值占用的内存将被释放

Rust 不像 Java、C++ 或 Javascript 那样是一种面向对象的语言——它不支持类继承或抽象类。这是一个有意的设计决定:Rust 是为组合而不是继承而设计的。

值得庆幸的是,仍然可以通过使用trait特征(Rust 的接口版本)和特征对象在 Rust 中实现多态性。

假设我们想要构建一个 UI 库2 ,用于在屏幕上绘制不同的 UI 组件(例如Button,Text和)。在传统的 OOP 语言中,您可能会从一个带有方法的基类Image开始。这些组件中的每一个都将从基类继承,我们将使用通用方法将每个组件绘制到屏幕上。

在 Rust 中,我们可以通过使用特征trait和特征对象来实现一些非常简单的事情。

我们可以在我们的库中添加一个名为 Draw 的通用特征:

pub  trait  Draw {
     fn  draw (& self );
}

我们的 UI 框架中的组件都将实现此特性并定义自己的逻辑以将组件的内容绘制到屏幕上。 

为了将所有组件渲染到屏幕上,我们希望能够以一种与组件类型无关的抽象方式引用所有组件。

在 Rust 中,我们会使用特征对象 ( ) 来做到这一点:

pub  struct  Screen {
     pub components: Vec < Box <dyn Draw>>,
}

这里的关键是,我们可以将我们的组件列表作为一个Box类型的向量来引用--任何实现了Draw特性的对象。
我们必须在这里使用一个Box(一个指向堆上对象的指针),因为我们在编译时不知道实现Draw的实际对象的大小。这让我们在不知道每个对象的类型的情况下,使用trait函数(本例中为draw)与这些组件进行交互。在我们的例子中,我们可以在每个组件上调用draw来实际绘制屏幕。

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这种方法可以作为一种适当的解决方案,在没有继承的情况下实现多态性。然而,它并没有给我们提供OOP或继承的所有功能:我们不能定义一个普通的类并扩展其功能,同时继续引用基类的字段或方法。

trait只是定义了一组共同的功能(一个函数列表),但并没有指定在特质的每个实现中定义的任何数据。在这种情况下,没有什么可以阻止我们在一个与UI组件无关的随机对象上实现Draw trait。例如,我们可以在这个名为Foo的随机结构上实现它,它绝对不是一个有效的UI组件。

struct Foo;

impl Draw for Foo {
    fn draw(&self) {}
}

为什么 Rust 中的 UI 这么难?
几乎所有的 UI 都可以建模为一棵树——或者更抽象地说,可以建模为一张图。树是建模 UI 的一种自然方式:它可以很容易地将不同的组件组合在一起以构建视觉上复杂的东西。它也是最常见的 UI 编程建模方法之一,至少从 HTML 的存在开始,如果不是更早的话。

Rust 中的 UI 很困难,因为在没有继承的情况下很难跨组件树共享数据。此外,在普通的 UI 框架中,有各种各样的地方需要改变元素树,但由于 Rust 的可变性规则,这种“随心所欲地改变树”的方法行不通。

在大多数 UI 框架中,组件树的概念内置于框架中。该框架包含根组件,每个组件都继承自一个公共基础组件,该基础组件跟踪其所有子组件以及如何遍历子组件。遍历树对于事件处理至关重要:框架需要能够遍历树以确定哪个组件应该接收事件。这方面的一个例子是 DOM API 中的事件冒泡和捕获:事件冒泡(默认)事件由树中最深的组件处理,然后“冒泡”到父元素。

解决方案
大多数 UI 框架都是为面向对象编程设计的,但 UI 编程本身并不需要面向对象。

一个很好的例子是Elm 架构,它大量使用函数式、反应式编程。Iced是受此架构启发的最受欢迎的 Rust 框架。该体系结构将 UI 程序分为三个高级组件:模型类型、函数view和update函数。

该模型是一个简单的哑数据对象,它包含视图的所有状态。在渲染时,view负责将模型转换为屏幕上显示的内容(在本例中,通过输出 HTML)。update负责使用程序员定义的Msgs 改变模型。当用户与应用程序交互时,程序员指定Msg应该使用哪个来更新模型。框架知道它需要重新渲染(通过调用view),因为模型已更改。

Elm 模型在 Rust 中运行良好,原因如下:

  1. 函数性和不可变性:没有必要处理可变性问题,因为一切都流经update,您将拥有的价值带入模型并返回模型的新拥有价值。这很好地映射到 Rust 的所有权模型,因为模型只有一个所有者。
  2. 可以使用 Rust 枚举清晰地表达消息: Rust 具有非常有表现力的枚举支持,这让您可以非常轻松地为具有不同数据类型的消息建模。这最终会生成清晰的声明性代码,您可以在其中对每个变体进行模式匹配。

如果 Elm 让你想起 Redux,那么你就走对了。Redux 的灵感来自 Elm——您可以将Redux 解析器视为类似于 Elm 中的更新程序。

这种架构确实有一些缺点:组件化组件不像在其他框架中那样直观。事实上,Elm 文档明确反对组件化。Elm 鼓励添加从模型中获取特定参数的辅助视图和更新函数,而不是创建具有自己模型的新组件。例如,您可以添加一个header_view函数,该函数采用呈现标题所需的特定参数。这种方法工作得很好,但不像在 React 或 Flutter 中那样基于应用程序的视觉结构进行组件化那么直观。

详细点击标题

总结
在 Rust 中构建合适的 UI 框架很困难,而且通常不直观。在使用框架时,它还没有为良好的开发人员体验而设置:它不支持热重新加载,这可能导致重新编译代码以进行最小的 UI 更改的缓慢而笨拙的体验。

Rust 对可移植性和性能的坚定承诺以及活跃的生态系统仍然使其成为 UI 编程的一个引人注目的选择:特别是对于高性能至关重要的情况。