• 为什么在Rust中构建UI如此困难?
  • 发布于 1周前
  • 55 热度
    0 评论
是什么让Rust与众不同?为什么Rust中的UI这么难?

功能性UI的拯救
如果你最近读过Hacker News,很难不认为Rust是未来:它被用于Linux内核和Android操作系统,被AWS用于关键基础设施,以及ChromeOS和Firefox。然而,尽管Rust很棒,但它还没有成为构建UI的通用语言。在2019年,“GUI”是阻止Rust采用的第六个最高要求的功能。这从根本上说是Rust的一个局限性:语言本身的设计使得构建UI的常见方法建模变得困难。

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

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

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

Rust所有权通过执行以下规则来工作:
1.值由变量拥有
2.值可以被其他变量引用(下面提到一些警告)
3.当拥有变量超出作用域时,值占用的内存将被释放
fn main() {
    let mut original_owner = format!("Hello world");
    // 堆代码 duidaima.com
    // 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还通过关于何时可以可变和不变地引用值的规则来防止编译时的数据竞争。在串联中,这些规则强制执行两个线程同时更新相同值不会导致数据竞争:
1.在任何给定的时间,您可以有一个可变引用或任意数量的不可变引用。
2.引用必须始终有效。
3.当存在有效引用时,不能改变该值。

Rust也不像Java、C++或Javascript那样是一种面向对象的语言,它不支持类继承或抽象类。这是一个有意的设计决策:Rust是为组合而不是继承而设计的。值得庆幸的是,通过使用traits(Rust的接口版本)和trait对象,仍然可以在Rust中实现多态性。假设我们想要构建一个UI库2,将不同的UI组件(例如按钮、文本和图像)绘制到屏幕上。在传统的OOP语言中,您可能会从一个带有draw方法的基本Component类开始。这些组件中的每一个都继承自Component基类,我们将使用通用的draw方法将每个组件绘制到屏幕上。

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

我们可以在库中添加一个名为Draw的公共trait:
pub trait Draw {
    fn draw(&self);
}
我们的UI框架中的组件都将实现这个trait,并定义它们自己的逻辑来将组件的内容绘制到屏幕上。为了将所有组件呈现到屏幕上,我们希望能够以一种抽象的方式引用所有组件,这种方式与组件的类型无关。

在Rust中,我们会使用trait对象(Box)来实现:
pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
这里的关键部分是我们可以引用我们的组件列表作为Box类型的向量--任何实现Draw trait的对象。我们必须在这里使用Box(指向堆上对象的指针),因为我们不知道在编译时实现Draw的实际对象的大小。这让我们可以使用trait上的函数(在本例中为draw)与这些组件进行交互,而无需知道每个对象的类型。在我们的例子中,我们可以在每个组件上调用draw来实际绘制屏幕:
impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
这种方法是实现多态性而不需要继承的一种适当的解决方案。然而,它并没有给我们OOP或继承的所有特性:我们不能定义一个公共类并扩展它的功能,同时继续引用基类的字段或方法。

trait只是定义了一组公共功能(一个函数列表),但不指定trait的每个实现中定义的任何数据。在这种情况下,没有什么可以阻止我们在一个与UI组件无关的随机对象上实现Drawtrait。例如,我们可以在这个名为Foo的随机结构上实现它,它肯定不是一个有效的UI组件:
struct Foo;
impl Draw for Foo {
    fn draw(&self) {}
}
几乎所有的UI都可以建模为树,或者更抽象地建模为图。树是一种自然的UI建模方式:它可以轻松地将不同的组件组合在一起,以构建视觉上复杂的东西。它也是建模UI编程的最常见方法之一,至少从HTML的存在开始,如果不是更早的话。Rust中的UI很困难,因为在没有继承的情况下很难在这个组件树中共享数据。此外,在一个普通的UI框架中,有各种各样的地方需要改变元素树,但是由于Rust的可变性规则,这种“随心所欲地改变树”的方法不起作用。

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

Flutter是一个很好的框架,它有一个Widget抽象类,以及从Widget扩展的其他抽象类,用于Widget没有子级(`LeafRenderObjectElement`)、只有一个子级(`SingleChildRenderObjectElement`)和多个子级(`MultiChildRenderObjectElement`)的情况。这些额外的继承层确保叶组件不需要处理围绕组件树的任何逻辑,因为它全部由超类处理。

让我们使用一个计时器,这是7GUI中的一个任务,作为这个树结构如何有用的一个例子。我们的计时器将有一个进度条来显示经过的时间,一个滑块来调整持续时间,和一个按钮来重置计时器。

我们可以这样构建这棵树:

这种树方法并不能清晰地映射到Rust。缺乏OOP使得设计一个像上面结构中那样可以有n个子级的组件变得更加困难。使用上面的trait示例,这并不像在trait中添加一个额外的函数那么简单:
pub trait Draw {
    fn draw(&self);

    fn children(&self) -> Vec<Box<dyn Draw>>;
}
由于trait不保存数据,这需要每个组件单独存储其子组件。由于我们刚刚添加了一个子函数,所以没有什么可以阻止一个写得很差的组件返回一个空向量,即使该组件存储和绘制多个组件。这些不一致性使得遍历树变得更加困难在面向对象的语言中,所有这些逻辑都将被抽象到一个超类中,这个超类可以使用它的任何一个孩子的真实源(字段本身)遍历树。

Rust对可变性的限制也使得树很难建模,如果你想能够改变树的话(这是必须的,因为我们需要添加和删除组件,以及改变实际的组件本身)。Rust的规则禁止对单个值的多个可变引用,不鼓励使用共享可变状态,但这在树中通常是必要的,树拥有并改变节点,但其他应用程序逻辑也需要改变树中的每个节点。

在处理事件时,处理共享的可变状态也是一个问题。大多数UI框架通过使用轮询输入的事件循环来处理用户交互。框架可以在接收到事件时随时改变任意数量的组件。在Rust中,有一些方法可以解决共享可变状态,但它会产生非人体工程学的代码,将许多检查推迟到运行时。

一个常见的解决方案是使用Rust标准库中提供的RefCell类型来使用内部可变性。RefCell通过将Rust所有权检查移动到运行时而不是编译时来工作。要获取对象的可变引用,可以调用borrow_mut:如果已经存在对底层对象的可变引用-borrow_mut将出现异常,以确保不违反所有权保证。使用RefCell基本上是可行的,但绝不是人体工程学。它也有安全方面的顾虑通过将所有权检查推迟到运行时,如果有两次对borrow_mut的调用,应用程序可能会死机。

功能性UI的拯救
现在,我们已经有了足够的上下文来说明在Rust中构建UI框架的一些困难,让我们快速讨论一下在Rust中工作得很好的一些方法。简短的回答是,这些问题有许多不同的解决方案,这就是为什么Rust中的UI景观如此破碎,并且Rust中没有一个明确的一刀切的UI框架解决方案。在Rust中,解决这些问题的最常见的方法之一就是完全避免使用这些面向对象的模式。尽管大多数UI框架都是为面向对象编程而设计的,但UI编程本身并不需要面向对象。

这方面的一个很好的例子是Elm架构,它大量使用了函数式、反应式编程。Iced是受此架构启发的最受欢迎的Rust框架。这种架构将UI程序分为三个高级组件:Model类型、视图函数和更新函数。

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

Elm模型在Rust中工作得很好,原因如下:
1.函数式和不可变:不需要处理可变性问题,因为所有内容都通过update进行,在update中,您将拥有的值带到模型并返回模型的新拥有值。这很好地映射到Rust的所有权模型,因为模型只有一个所有者。

2.消息可以用Rust枚举干净地表达:Rust有非常有表现力的枚举支持,这让你可以非常容易地用不同的数据类型建模消息。这最终会产生清晰的声明性代码,您可以在其中对每个变体进行模式匹配。

如何将消息干净地映射到Rust枚举的一个很好的例子是iced的数字输入示例,其中用户可以使用输入指定一个数字,也可以使用按钮递增和递减数字。

消息可以定义为:
#[derive(Debug, Clone)]
pub enum Event {
    InputChanged(String),
    IncrementPressed,
    DecrementPressed,
}
我们将得到更新如下:
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解析器想象成Elm中的更新器。

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

榆树式的方法绝不是唯一一种似乎正在获得牵引力的方法。一个独立组件系统(ECS)架构在Rust中也能很好地工作,以避免共享可变状态的问题。

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

这大致上是我们在Warp中选择的方法:我们用一个名为UnityId的唯一ID表示每个组件(我们称之为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>,
}
我们使用这个ID作为键来存储引用视图的任何状态。例如,我们存储每个视图到其父视图的映射(参见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中,这些数据被非规范化为系统拥有的一系列映射和列表。我们使用这个安全ID作为任何保存视图状态的结构的键。在下面的Warp截图中,我们有三个不同的视图,每个视图都有唯一的ID:

使用这些proxytyId允许我们在没有继承的情况下对组件树结构进行编码。上面屏幕截图的实际视图树看起来像这样:

无论如何,独立组件系统和Elm并不是解决这个问题的唯一两种方法。其他人已经研究了使用即时模式GUI,甚至使用DOM来渲染,同时在Rust中保留应用程序逻辑(参见Tauri)。

期待
在Rust中构建一个合适的UI框架很难,而且往往不直观。在使用框架时,它还没有为开发人员提供良好的体验:它不支持热重载,这可能导致重新编译代码以进行最小的UI更改的缓慢和笨拙的体验。Rust对可移植性和性能的坚定承诺,以及活跃的生态系统仍然使其成为UI编程的一个令人信服的选择:特别是对于高性能至关重要的情况。

在我们的案例中,我们的UI框架很好地帮助我们实现了性能目标,并且不是开发人员速度问题的主要来源。不过它也有缺点--有些是设计上的缺点,有些是Rust的缺点。例如,我们没有一种简单的方法来以任何方向的顺序遍历我们绘制到屏幕上的元素树-这使得事件处理更加困难。当从事件循环接收平台事件时,我们还使用RefCell来处理共享的可变性--尽管这种情况很少见,但由于对borrow_mut的并发调用,我们已经遇到了一些来自用户的崩溃。
用户评论