[Rust翻译]为什么在Rust中构建一个UI这么难?

1,338 阅读16分钟

本文由 简悦SimpRead 转码,原文地址 www.warp.dev

是什么让Rust与众不同?

是什么让Rust与众不同?

锈迹斑斑的用户界面为什么这么难?

功能性UI的拯救

‍ 如果你最近读过Hacker News,你很难不认为Rust是未来的趋势:它被用于Linux内核Android 操作系统,被AWS用于关键基础设施,以及ChromeOSFirefox中。然而,尽管Rust很好,但它还没有作为构建用户界面的通用语言起飞。在2019年,"GUI "是阻碍Rust被采用的第六大需求功能。这从根本上说是Rust的局限性:语言本身的设计使得对构建UI的常见方法进行建模很困难。

‍ 在Warp,我们一直在用Rust构建一个自定义的UI框架1,用来在GPU上渲染。构建这个框架是非常棘手的,也是一项巨大的投资,但它在构建一个拥有丰富UI元素的终端时发挥了很好的作用,并且与地球上任何其他终端一样快 。如果我们使用Electron或Flutter这样的UI库,这种水平的性能几乎是不可能的。 ‍ 在这篇文章中,我将讨论为什么Rust独特的内存管理模型和缺乏继承性使得传统的技术难以建立一个UI框架,以及我们一直在解决这个问题的一些方法。我相信这些方法中的一种,或者它们的某种组合,最终将导致一个稳定的跨平台的UI工具包,用于高性能的UI渲染,每个人都可以使用。

是什么让Rust与众不同?

Rust通过一个叫做 "所有权 "的概念来处理内存管理,这个概念在编译时就被强制执行。这与其他语言不同,后者通过使用垃圾收集器提供自动内存管理,在运行时删除未使用的对象。

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

  • 值是由变量拥有的
  • 值可以被其他变量引用(下面提到的一些注意事项)。
  • 当拥有的变量超出范围时,该值所占用的内存将被取消分配。
fn main() {
    let mut original_owner = format!("Hello world");
    
    // move occurs to new owner    
    let new_owner = original_owner;
    
    // attempt to use original_owner will 
    // lead to compile time error    
    println!("{}", original_owner)
}
error[E0382]: borrow of moved value: `original_owner`

以上面的例子为例,Rust编译器强制要求在任何时候一个给定值只有一个所有者。Rust阻止我们将new_owner分配给original_owner的值,因为该值在同一时间会有两个所有者。

‍Rust还在编译时通过对一个值何时可以被可变和不可变地引用的规则来防止数据竞赛。同时,这些规则强制要求两个线程在同一时间更新同一个值,不会出现数据竞赛。 

  • 在任何时候,你可以有一个可变的引用或任何数量的不可变的引用。
  • 引用必须始终是有效的。
  • 当有有效的引用时,值不能被变异。

来源:rufflewind.com/2017-02-15/…

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

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

假设我们想建立一个UI库2,在屏幕上画出不同的UI组件(如按钮文本图像)。在传统的OOP语言中,你可能会从一个带有 "draw "方法的 "Component "基类开始做这件事。每个组件都将继承自基类Component,我们将使用通用的draw方法将每个组件绘制到屏幕上。

在Rust中,我们可以通过使用trait和trait对象来实现非常简单的东西。

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

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

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

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

在Rust中,我们将使用一个特征对象(Box)来实现这一目标。

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

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

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

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

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

struct Foo;

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

为什么Rust中的UI这么难?

几乎所有的用户界面都可以被建模为树状,或者更抽象地说是图状。树形是一种自然的UI建模方式:它使不同的组件很容易组合在一起,从而构建出视觉上复杂的东西。如果不是更早的话,至少从HTML的存在开始,它也是最常见的UI编程建模方式之一。

Rust中的UI很困难,因为如果没有继承,就很难在这个组件树上分享数据。此外,在普通的UI框架中,有很多地方需要改变元素树,但由于Rust的可改变性规则,这种 "随心所欲地改变元素树 "的方法并不可行。

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

Flutter是一个很好的框架,它有一个 "Widget "抽象类,还有一些从 "Widget "延伸出来的抽象类,用于处理Widget没有孩子(LeafRenderObjectElement)、一个孩子(SingleChildRenderObjectElement)和许多孩子(MultiChildRenderObjectElement)的情况。这些额外的继承层确保了叶子组件不需要处理任何关于行走组件树的逻辑,因为这都是由超类处理的。

让我们用一个定时器,即7GUIs中的任务之一,作为这个树状结构如何有用的例子。我们的定时器将有一个进度条来显示经过的时间,一个滑块来调整持续时间,以及一个按钮来重置定时器。

我们可以这样为这棵树建模。

这种树状的方法并不能很好地映射到Rust中。由于缺乏OOP,所以很难设计出像上面的结构那样可以有_n_个孩子的组件。使用我们上面的trait例子,这不是给我们的trait添加一个额外的函数那么简单。

pub trait Draw {
    fn draw(&self);

    fn children(&self) -> Vec<Box<dyn Draw>>;
}

由于traits不能保存数据,这就要求每个组件都要单独存储它的孩子。由于我们只是添加了一个children函数--没有什么可以阻止一个写得不好的组件在这里返回一个空的向量,即使该组件存储并绘制了多个组件。这些不一致使树的行走变得更加困难--在面向对象的语言中,所有这些逻辑都会被抽象成一个超类,该超类可以使用它的任何子类(字段本身)的真相来源来行走树。

如果你想对树进行突变(这是必须的,因为我们需要添加和删除组件,以及突变实际的组件本身),Rust在这里对突变性的限制也使得树很难建模。Rust的规则是防止对一个值的多个可变引用,因此不鼓励使用共享可变状态,但这在树中往往是必要的,因为树拥有并变异了节点,但其他应用逻辑也需要变异树中的每个节点。

在处理事件时,处理共享的可变状态也是一个问题。大多数UI框架通过使用一个轮询输入的事件循环来处理用户交互。该框架可以在收到事件后的任何时候突变任何数量的组件。

在Rust中,有一些方法可以解决共享变异状态的问题,但它会产生非人为的代码,将许多检查推迟到运行时。

一个常见的解决方案是使用Rust标准库中提供的RefCell类型,使用内部可变性RefCell的工作原理是将Rust的所有权检查转移到运行时而不是编译时。为了获得一个对象的可变引用,你可以调用borrow_mut

pub fn borrow_mut(&self) -> RefMut<'_, T>

如果已经有一个对底层对象的可变引用- borrow_mut会恐慌,以确保所有权保证不被违反。

使用 "RefCell "大多数情况下是有效的,但绝非符合人体工程学。它也有安全问题--将所有权检查推迟到运行时,如果有两个对borrow_mut的调用,应用程序会恐慌。

功能性UI的拯救

现在我们对在Rust中构建UI框架的一些困难有了足够的了解,让我们快速地谈谈一些在Rust中运行良好的方法。

简而言之,这些问题有许多不同的解决方案,这就是为什么Rust中的UI景观是如此的支离破碎,在Rust中没有一个明确的、适合所有的UI框架解决方案。

在Rust中,解决这些问题最常见的方法之一是完全避免使用这些面向对象的模式。尽管大多数UI框架是为面向对象编程而设计的,但UI编程本质上并不需要面向对象。

这方面的一个很好的例子是Elm架构,它大量使用了函数式、反应式编程。Iced是最流行的Rust框架,其灵感来自这种架构。这种架构将UI程序分离成三个高级组件:Model类型、view函数和update函数。

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

由于一些原因,Elm模型在Rust中工作得非常好。

  1. 功能性和不可变性: 没有必要处理可变性问题,因为一切都通过update来进行,在这里你把一个拥有的值带到一个模型,并返回一个新的模型拥有的值。这与Rust的所有权模型有很好的映射,因为模型只有一个所有者。
  2. 消息可以用Rust枚举干净地表达: Rust有非常丰富的枚举支持,这让你可以非常容易地用不同的数据类型对消息进行建模。这最终会产生清晰和声明性的代码,你可以对每个变量进行模式匹配。

一个很好的例子是iced's,它是一个数字输入的例子,用户可以用输入法指定一个数字,或者用按钮增加和减少这个数字,这样就可以很干净地将消息映射到Rust枚举中。

信息可以被定义为

#[derive(Debug, Clone)]
pub enum Event {
    InputChanged(String),
    IncrementPressed,
    DecrementPressed,
}

而我们将推导出update如下。

fn update(
    &mut self,
    _state: &mut Self::State,
    event: Event,
) -> Option<Message> {
    match event {
        Event::IncrementPressed => Some((self.on_change)(Some(
            self.value.unwrap_or_default().saturating_add(1),
        ))),
        Event::DecrementPressed => Some((self.on_change)(Some(
            self.value.unwrap_or_default().saturating_sub(1),
        ))),
        Event::InputChanged(value) => {
            if value.is_empty() {
                Some((self.on_change)(None))
            } else {
                value
                    .parse()
                    .ok()
                    .map(Some)
                    .map(self.on_change.as_ref())
            }
        }
    }
}

如果Elm让你想起了Redux,那么你就走对了路。Redux的灵感来自于Elm--你可以把Redux resolvers看作是类似于Elm的updaters。

这种架构确实有一些缺点:组件化并不像其他框架那样直观。事实上,Elm的文档明确不鼓励组件化。Elm鼓励添加辅助视图和更新函数,从模型中获取特定的参数,而不是创建拥有自己的模型的新组件。例如,你可以添加一个header_view函数,接收渲染标题所需的特定参数。这种方法很好用,但不像React或Flutter中那样根据应用程序的视觉结构进行组件化那样直观。

类似于Elm的方法绝不是唯一的方法,它似乎正在受到欢迎。实体-组件-系统(ECS)架构在Rust中也能很好地避免围绕共享可变状态的问题。

在ECS中,框架拥有所有的组件。组件继续负责保持自己的状态,响应用户的输入,并在屏幕上绘画,但框架负责存储组件之间的关系(我们上面提到的树)和组件之间的任何互动。这有助于解决Rust编译器中围绕可变性的很多问题:所有组件的唯一所有者是框架。这种方法并不完美--系统现在需要跟踪何时删除旧组件。它还在你的代码库中散布了对这个中央存储的引用,因为这是从任何组件中读出状态的唯一方法。

这大概是我们在Warp选择的方法:我们用一个独特的ID(称为 "EntityId")代表每个组件(我们称之为 "View")。每个窗口都存储了一个 "EntityId "到实际 "View "的映射,这个 "View "是由ID识别的。

/// A structure holding all application state that is linked to a particular
/// window.
#[derive(Default)]
pub(super) struct Window {
    /// The set of views owned by this window, keyed by view ID.
    pub views: HashMap<EntityId, Box<dyn AnyView>>,

    /// A handle to the window's root view (top of the view hierarchy), if any.
    pub root_view: Option<AnyViewHandle>,

    /// The ID of the currently focused view, if any.
    pub focused_view: Option<EntityId>,
}

我们使用这个EntityID作为键来存储任何引用该视图的状态。例如,我们存储每个视图到其父视图的映射(见parents字段),这样我们就可以向上遍历视图树。我们还存储了每个视图在最后一帧渲染到屏幕上的内容的映射(见rendered_views字段)。

pub struct Presenter {
    window_id: WindowId,
    scene: Option<Rc<Scene>>,
    rendered_views: HashMap<EntityId, Box<dyn Element>>,
    parents: HashMap<EntityId, EntityId>,
    font_cache: Arc<FontCache>,
    asset_cache: Arc<assets::Cache>,
    text_layout_cache: LayoutCache,
    stack_context: StackContext,
}

在面向对象的世界里,每个组件的所有状态都会被编码在组件内。在ECS中,这些数据被规范化为一系列由系统拥有的地图和列表。我们使用这个 "EntityID "作为任何持有视图状态的结构的关键。在下面这张Warp的截图中,我们有三个不同的视图,每个都有唯一的EntityID

使用这些 "EntityIds",我们可以编码一个没有继承性的组件树结构。上面的截图的实际视图树看起来就像。

Entity-Component-Systems和Elm绝不是解决这个问题的唯一两种方法。其他人已经研究了使用即时模式GUI,甚至使用DOM来渲染,同时将应用逻辑保留在Rust中(见Tauri)。

展望未来

在Rust中建立一个合适的UI框架是很难的,而且往往是不直观的。在使用框架时,它也还没有为开发者提供良好的体验:它不支持热重载,这可能会导致重新编译代码以进行最小的UI变化的缓慢和笨拙的体验。

Rust对可移植性和性能的强烈承诺,以及活跃的生态系统仍然使它成为UI编程的一个令人信服的选择:尤其是在高性能至关重要的情况下。

在我们的案例中,我们的UI框架很好地实现了我们的性能目标,并且不是我们开发人员速度问题的主要来源。但它也有缺点--有些是设计上的缺点,有些则是Rust的原因。例如,我们没有一个简单的方法来遍历我们画在屏幕上的元素树的任何方向的顺序--这使得事件处理更加困难。我们还使用RefCell来处理从事件循环中接收平台事件时的共享突变性--虽然这很罕见,但我们在野外有几个用户因为同时调用borrow_mut而崩溃了。

随着社区对许多不同方法的实验,Rust中的用户界面的未来可能会继续创新,但也是零散的。我们还没有一个放之四海而皆准的框架,但我毫不怀疑,即使没有很多,也会有一个在未来几年内取得成功。随着硬件的发展,Rust将是一个很好的选择,可以以跨平台的方式建立一个奶油般光滑的用户界面。如果你对尝试我们基于Rust的用户界面感到好奇,你可以在这里下载Warp。

我们与Atom的联合创始人Nathan Sobozed.dev合作建立了这个框架。

例子来自doc.rust-lang.org/book/ch17-0…


www.deepl.com 翻译