将跨平台 GUI 应用程序移植到 Rust

希望 Firefox 的崩溃报告器不是大多数用户经常遇到的东西。但是,它仍然是 Firefox 的一个非常重要的组件,因为它对于深入了解最明显的错误至关重要:导致主进程崩溃的错误。这些错误提供了最糟糕的用户体验(因为整个应用程序必须关闭),因此修复它们是重中之重。其他类型的崩溃,例如内容(选项卡)崩溃,可以由浏览器处理并优雅地报告,有时用户根本不会意识到发生了问题。但是,当主浏览器进程停止时,我们需要另一个单独的应用程序来收集有关崩溃的信息并与用户交互。

这篇文章详细介绍了我们采用的一种用 Rust 重写崩溃报告器的方法。我们讨论了重写背后的原因、是什么使崩溃报告器成为一个独特的应用程序、我们使用的架构以及一些实现细节。

为什么要重写?

尽管正确处理主进程崩溃非常重要,但崩溃报告器有一段时间没有得到重大开发(除了确保崩溃报告和遥测数据继续可靠地传递的开发之外)!它长期以来一直停留在“足够好”和“令人害怕维护”的局部最优状态:它具有 3 个独立的 GUI 实现(用于 Windows、Linux 的 GTK+ 和 macOS)、抽象了一些内容的粘合代码(主要在 C++ 中,以及 macOS 的 Objective-C)、由过时的 Apple 开发工具生成的二进制 Blob,以及没有测试套件。因此,存在尚未处理的功能和改进积压。

我们最近成功地进行了一些推动以降低崩溃率(包括重大飞跃和许多小的错误修复),并且崩溃报告器在此期间运行良好,足以满足我们的需求。但是,我们已经达到一个拐点,在该拐点上,改进崩溃报告器将提供有价值的见解,使我们能够进一步降低崩溃率。由于前面提到的原因,改进当前代码库既困难又容易出错,因此我们认为重写应用程序是合适的,这样我们就可以更容易地处理功能积压并改进崩溃报告。

与 Firefox 的许多组件一样,我们决定使用 Rust 进行此重写,以生成更可靠且更易于维护的程序。除了 Rust 中通常吹捧的内存安全性之外,其类型系统和标准库还使代码推理、错误处理和跨平台应用程序的开发更加健壮和全面。

崩溃报告是一个边缘案例

崩溃报告器有许多功能使其非常独特,特别是与已移植到 Rust 的其他组件相比。首先,它是一个独立的个体程序;基本上没有其他 Firefox 组件以这种方式使用。Firefox 本身启动了许多进程作为沙盒化和隔离崩溃的一种手段,但是这些进程彼此通信并可以访问相同的代码库。

崩溃报告器有一个非常独特的要求:它必须尽可能少地使用 Firefox 代码库,理想情况下根本不使用!我们不希望它依赖于可能存在错误并导致报告器本身崩溃的代码。使用完全独立的实现可以确保当发生主进程崩溃时,该崩溃的原因也不会影响报告器的功能。

崩溃报告器也必然有一个 GUI。这本身可能不会将它与其他 Firefox 组件区分开来,但我们无法利用 Firefox 提供的任何跨平台渲染优势!因此,我们还需要实现一个独立于 Firefox 的跨平台 GUI。您可能认为我们可以使用现有的跨平台 GUI crate,但是我们有一些理由不这样做。

  • 我们希望最大限度地减少对外部代码的使用:为了提高崩溃报告器的可靠性(这是最重要的),我们希望它尽可能简单和可审计。
  • Firefox 在树内提供所有依赖项,因此我们犹豫是否要引入大型依赖项(GUI 库可能相当庞大)。
  • 只有少数第三方 crate 提供原生操作系统外观(或实际使用原生 GUI API):希望崩溃报告器具有原生外观,以便用户熟悉并利用辅助功能。

因此,所有这一切都是说第三方跨平台 GUI 库不是一个理想的选择。

这些要求大大缩小了可以使用的方法范围。

构建 GUI 视图抽象

为了使崩溃报告器更易于维护(并使将来更容易添加新功能),我们希望尽可能减少和通用平台特定的代码。我们可以通过使用一个简单的 UI 模型来实现这一点,该模型可以转换为每个平台的原生 GUI 代码。每个 UI 实现都需要提供两种方法(在任意平台特定的&self数据上)

/// Run a UI loop, displaying all windows of the application until it terminates.
fn run_loop(&self, app: model::Application)

/// Invoke a function asynchronously on the UI loop thread.
fn invoke(&self, f: model::InvokeFn)

run_loop函数非常不言自明:UI 实现获取一个Application模型(我们将在稍后讨论)并运行应用程序,阻塞直到应用程序完成。方便的是,我们的目标平台通常对线程有类似的假设:UI 在单个线程中运行,并且通常运行一个事件循环,该循环在接收到指示应用程序结束的事件之前一直阻塞在新的事件上。

在某些情况下,我们需要异步地在 UI 线程上运行一个函数(例如显示窗口、更新文本字段等)。由于run_loop阻塞,我们需要invoke方法来定义如何做到这一点。这种线程模型将使使用平台 GUI 框架变得容易:在程序持续时间内,所有调用原生函数的操作都将在单个线程(实际上是主线程)上发生。

现在是时候更具体地说明每个 UI 实现的确切外观了。我们稍后将讨论每个实现的痛点。有 4 个 UI 实现

  • 使用 Win32 API 的 Windows 实现。
  • 使用 Cocoa(AppKit 和 Foundation 框架)的 macOS 实现。
  • 使用 GTK+ 3 的 Linux 实现(GTK 4 中已删除“+”,因此以后我将将其称为“GTK”)。Linux 没有提供自己的 GUI 原语,并且我们已经在 Linux 上与 Firefox 一起提供了 GTK 以制作现代感 GUI,因此我们也可以将其用于崩溃报告器。请注意,一些 Mozilla 不直接支持的平台(如 BSD)也使用 GTK 实现。
  • 一个测试实现,它将允许测试挂接到虚拟 UI 并戳东西(以模拟交互和读取状态)。

在我们深入研究之前,还有一个细节:崩溃报告器(至少目前)有一个非常简单的 GUI。因此,开发的一个明确的非目标是创建单独的 Rust GUI crate。我们希望创建足够的抽象来涵盖我们在崩溃报告器中需要的用例。如果我们将来需要更多控件,我们可以将它们添加到抽象中,但我们避免花费额外的周期来填充每个 GUI 用例。

同样,我们试图避免不必要的开发,同时允许对黑客和内置边缘情况有一定的容忍度。例如,我们的模型定义了一个Button作为包含任意元素的元素,但实际上使用 Win32 或 AppKit 支持它需要大量自定义代码,因此我们在包含ButtonLabel上进行特殊情况处理(这正是我们现在需要的,并且是我们可以使用的一种简单的原语)。我很高兴地说,实际上并没有很多这样的特殊情况,但我们对少数几个必要的情况感到满意。

UI 模型

我们的模型是主要存在于 GTK 中的概念的声明式结构。由于 GTK 是一个具有成熟的高级 UI 概念的成熟库,因此这使得它适合我们的抽象,并使 GTK 实现非常简单。例如,GTK 执行布局的最简单方法(使用容器 GUI 元素和每个元素的边距/对齐方式)对我们的 GUI 足够好,因此我们在模型中使用类似的定义。值得注意的是,这个“简单”的布局定义实际上是某种高级别的,并且使 macOS 和 Windows 实现稍微复杂了一些(但这种权衡值得轻松创建 UI 模型)。

UI 模型的顶级类型是Application。这非常简单:我们将Application定义为一组顶级Window(尽管我们的应用程序只有一个)以及当前区域设置是否为从右到左。我们检查 Firefox 资源以使用与 Firefox 相同的区域设置,因此我们不依赖于原生 GUI 的区域设置。

正如您可能预期的那样,每个Window包含单个根元素。模型的其余部分由少数典型的容器和基本 GUI 元素组成

A class diagram showing the inheritance structure. An Application contains one or more Windows. A Window contains one Element. An Element is subclassed to Checkbox, Label, Progress, TextBox, Button, Scroll, HBox, and VBox types.

崩溃报告器只需要 8 种类型的 GUI 元素!实际上,Progress目前用作微调器,而不是指示任何实际进度,因此它并非严格必要(但显示出来很好)。

Rust 没有明确支持面向对象的继承概念,因此您可能想知道每个 GUI 元素如何“扩展”Element。图片中表示的关系有点抽象;已实现的Element看起来像

pub struct Element {
    pub style: ElementStyle,
    pub element_type: ElementType
}

其中ElementStyle包含元素的所有公共属性(对齐方式、大小、边距、可见性和启用状态),以及ElementTypeenum,其中包含每个特定 GUI 元素作为变体。

构建模型

模型元素都旨在供 UI 实现使用;因此,几乎所有字段都具有公共可见性。但是,作为拥有用于构建元素的单独接口的一种方式,我们定义了一个ElementBuilder<T>类型。此类型具有维护断言和提供便利设置器的方法。例如,许多方法接受作为impl Into<MemberType>的参数,一些方法如margin()设置多个值(但您可以使用margin_top()等更具体),等等。

有一个通用的impl<T> ElementBuilder<T>提供各种ElementStyle属性的设置器,然后每个特定元素类型还可以提供自己的impl ElementBuilder<SpecificElement>以及元素类型特有的其他属性。

我们结合ElementBuilder<T>与拼图的最后一块:一个ui!宏。此宏允许我们以声明方式编写 UI。例如,它允许我们编写

let details_window = ui! {
    Window title("Crash Details") visible(show_details) modal(true) hsize(600) vsize(400)
         halign(Alignment::Fill) valign(Alignment::Fill)
    {
         VBox margin(10) spacing(10) halign(Alignment::Fill) valign(Alignment::Fill) {
            	Scroll halign(Alignment::Fill) valign(Alignment::Fill) {
                	TextBox content(details) halign(Alignment::Fill) valign(Alignment::Fill)
            	},
            	Button halign(Alignment::End) on_click(move || *show_details.borrow_mut() = false)
             {
                 Label text("Ok")
             }
         }
     }
};

的实现ui!非常简单。第一个标识符提供元素类型和ElementBuilder<T>被创建。之后,其余方法调用式语法形式在构建器(它是可变的)上调用。

可选地,一组最终的花括号表示元素具有子元素。在这种情况下,宏将被递归调用以创建它们,并且add_child在构建器上使用结果调用(因此我们只需要确保构建器具有add_child方法)。最终,语法转换非常简单,但我认为此宏不仅仅是语法糖:它使读取和编辑 UI 明确得多,因为元素的层次结构在语法中表示。不幸的是,一个缺点是没有办法支持此类宏 DSL 的自动格式化,因此开发人员需要维护合理的格式。

因此,现在我们已经定义了一个模型并以声明方式构建它。但是我们还没有讨论此处任何动态运行时行为。在上面的示例中,我们看到一个on_click处理程序被设置为Button。我们还看到了诸如Windowvisible属性被设置为show_details值,该值在on_click被按下。我们利用这个声明式 UI,使用一组简单的**数据绑定**原语在运行时更改或响应事件,UI 实现可以通过这些原语进行交互。

许多如今的 GUI 框架(无论是 Rust 还是其他语言)都是基于“差异化元素树”架构构建的(例如 React),其中您的代码(至少大部分)是函数式且无副作用的,并根据当前状态生成 GUI 视图。这种方法有其权衡:例如,它使布局的复杂、有状态的更改非常易于编写、理解和维护,并鼓励模型和视图的清晰分离!但是,由于我们没有编写框架,并且我们的应用程序现在和将来都将保持相当简单,因此这种架构的优势并不值得额外的开发负担。我们的实现更类似于 MVVM 架构

  • 模型是,嗯,此处讨论的模型;
  • 视图是各种 UI 实现;以及
  • 视图模型(松散地,如果你眯着眼睛看)是数据绑定的集合。

数据绑定

我们使用一些类型来声明动态(运行时可更改)的值。在我们的 UI 中,我们需要支持一些不同的行为

  • 触发事件,即按钮被点击时会发生什么,
  • 同步值,它会镜像所有克隆并通知更改,以及
  • 按需值,可以查询其当前值。

按需值用于获取文本框内容,而不是使用同步值,以避免在每个 UI 中实现去抖动。这样做可能并不困难,但支持按需实现也不难。

为了方便起见,我们创建了一个属性类型,它也包含面向值的字段。一个Property<T>可以设置为静态值(T)、同步值(Synchronized<T>)或按需值(OnDemand<T>)。它支持一个impl From对于每个,以便构建器方法看起来像fn my_method(&mut self, value: impl Into<Property<T>>)允许在 UI 声明中传递任何支持的值。

我们不会深入讨论实现细节 (它就是你所期望的),但值得注意的是,这些都是Clone以轻松共享数据绑定:它们使用Rc(我们不需要线程安全)和RefCell根据需要访问回调。

在上一节的示例中,show_detailsSynchronized<bool>值。当它发生变化时,UI 实现会更改关联的窗口可见性。该Button on_click回调将同步值设置为 false,隐藏窗口(请注意,在此示例中使用的详细信息窗口永远不会关闭,它只是显示和隐藏)。

在以前的版本中,数据绑定类型具有一个生命周期参数,该参数指定事件回调有效的生命周期。虽然我们能够使它工作,但它极大地复杂化了代码,特别是因为没有办法将生命周期的正确协变性传达给编译器,因此存在额外的unsafe代码转换生命周期(尽管它作为实现细节包含在内)。这些生命周期也是具有传染性的,需要将一些关于其安全性的复杂语义传播到存储属性字段的模型类型中。

其中大部分是为了避免将值克隆到回调中,但将这些类型全部更改为Clone并存储静态生命周期回调值得使代码更易于维护。

线程和线程安全

细心的读者可能会记得我们讨论过我们的线程模型如何在主线程上与 UI 实现进行交互。这包括更新数据绑定,因为 UI 实现可能已在其上注册了回调!虽然我们可以在主线程中运行所有内容,但通常在 UI 线程之外执行尽可能多的操作是一种更好的体验,即使我们没有执行很多阻塞操作(尽管在发送崩溃报告时我们将进行阻塞)。我们希望我们的业务逻辑默认在 UI 线程之外运行,以便 UI 永远不会冻结。我们可以通过一些谨慎的设计来保证这一点。

保证此行为的最简单方法是将所有业务逻辑放在一个(非-Clone,非-Sync)类型中(我们称之为Logic)并在另一个类型中构建 UI 和 UI 状态(如属性值)(我们称之为UI)。然后我们可以移动Logic值到一个单独的线程中,以确保UI无法直接与Logic交互,反之亦然。当然,我们有时确实需要通信!但我们希望确保此通信始终委托给拥有这些值的线程(而不是值之间直接交互)。

我们可以通过为每种类型创建一个入队函数并将其存储在相反的类型中来实现此目的。此类函数将传递要在线程上运行的 boxed 函数,这些函数获取对拥有类型的引用(例如,Box<dyn FnOnce(&T) + Send + 'static>)。这很容易创建:对于UI线程,它只是UI实现的invoke方法,我们之前简要讨论过。该Logic线程除了运行一个循环来获取这些函数并在拥有值上运行它们之外,什么也不做(我们只是使用 mpsc::channel 将它们入队并传递)。现在每种类型都可以异步地调用另一种类型上的方法,并保证它们将在正确的线程上运行。

在以前的版本中,使用线程局部存储和一个负责创建线程和委托函数的中心类型,使用了更复杂的方案。但是,对于像两个线程相互委托这样基本的使用案例,我们能够将其提炼为所需的必要方面,从而极大地简化了代码。

本地化

此重写的一个好处是,我们可以使崩溃报告程序的本地化与我们的现代工具保持一致。在 Firefox 的几乎所有其他部分,我们都使用 fluent 来处理本地化。使用fluent在崩溃报告程序中使本地化人员的体验更加统一和可预测;他们不需要理解超过一个本地化系统(崩溃报告程序是旧系统中最后的堡垒之一)。在新代码中使用它非常容易,只需添加一些额外的代码即可在运行崩溃报告程序时从 Firefox 安装中提取本地化文件。在最坏的情况下,如果我们无法找到或访问这些文件,我们将en-US定义直接捆绑在崩溃报告程序二进制文件中。

UI 实现

我们不会详细介绍这些实现,但值得稍微谈谈每个实现。

Linux (GTK)

GTK 实现可能是最直接和简洁的。我们使用 bindgen 生成我们需要的 GTK 函数的 Rust 绑定(避免引入任何外部板条箱)。然后我们只需调用所有相应的 GTK 函数来设置模型中描述的 GTK 窗口小部件(请记住,模型是为了镜像一些 GTK 概念而创建的)。

由于 GTK 比较现代,并且旨在由人类(而不是像其他一些平台那样的自动化工具)编写,因此实际上没有任何需要解决的痛点或异常行为。

我们有一些不错的功能来提高内存安全性和正确性。一组特征 使将拥有数据附加到 GObjects 变得容易(确保数据保持有效并在 GObject 被销毁时正确删除),并且 一些宏 设置了 GTK 信号和我们的数据绑定类型之间的粘合代码。

Windows (Win32)

Windows 实现可能是最难编写的,因为如今很少编写 Win32 GUI,并且 API 显示了它的时代。我们使用 windows-sys 板条箱来访问 API 的绑定(该绑定已在代码库中为许多其他 Windows API 使用而引入)。此板条箱是直接从 Windows 函数元数据(由 Microsoft 生成)生成的,但除此之外,其绑定与bindgen生成的绑定并没有太大区别(尽管它们可能更准确一些)。

有许多障碍需要克服。一方面,Win32 API 没有提供任何布局原语,因此我们使用的较高层次的布局概念(允许优雅地调整大小/重新定位)必须 手动实现。仅仅为了获得一个看起来有点像样的 GUI(正确的窗口颜色、字体平滑、高 DPI 处理等),还需要进行相当多的额外 API 调用。即使是默认字体最终也变成了一个看起来很糟糕的位图字体,而不是更现代的系统默认字体;我们需要手动检索系统默认字体并将其设置为要使用的字体,这有点令人惊讶!

我们有 一组特征 来促进创建自定义窗口类和管理类实例的关联窗口数据。我们还 包装器 类型 来正确管理句柄的生命周期并执行类型转换(主要是String到以 null 结尾的宽字符串,反之亦然)作为 API 周围的安全额外层。

macOS (Cocoa/AppKit)

macOS 实现也有一些棘手的地方,因为 macOS GUI 绝大多数都是用 XCode 编写的,并且有很多自动化和生成的部分(例如 nibs)。我们再次使用 bindgen 生成 Rust 绑定,这次是用于 macOS 框架头文件中的 Objective-C API。

与 Windows 和 GTK 不同,如果您创建 GUI 时不使用例如 XCode(它会在新项目模板的一部分中为您生成),则不会免费获得 Cmd-C、Cmd-Q 等键盘快捷键。为了拥有用户期望的这些典型快捷方式,我们需要手动实现应用程序主菜单(它控制键盘快捷键)。我们还必须处理运行时设置,例如创建 Objective-C 自动释放池、将窗口和应用程序(它们是单独的概念)带到前台等。甚至实现invoke在主线程上调用函数也有一些细微差别,因为模式窗口使用嵌套事件循环,在默认情况下不会调用排队函数NSRunLoop模式。

我们编写了一些 简单的辅助类型和宏 以便于从 Rust 代码中实现、注册和创建 Objective-C 类。我们使用它来创建委托类以及为实现子类化一些控件(例如 NSButton);它使安全管理类下层的 Rust 值的内存以及正确注册类方法选择器变得很容易。

测试 UI

我们将在下一节讨论测试。我们的测试 UI 非常简单。它不会创建 GUI,但允许我们直接与模型交互。该ui!宏在启用测试时支持额外的语法,以便为每个元素可选地设置字符串标识符。我们在单元测试中使用这些字符串来访问和交互 UI。数据绑定类型还在测试中支持一些其他方法,以便轻松操作值。此 UI 允许我们模拟按钮按下、字段输入等,以确保其他 UI 状态按预期更改,以及模拟系统副作用。

模拟和测试

我们重写的一个重要目标是向崩溃报告程序添加测试;我们的旧代码非常缺乏测试(部分原因是单元测试 GUI 非常困难)。

模拟一切

在新代码中,我们可以模拟崩溃报告程序,无论我们是否正在运行测试(尽管在测试中始终模拟它)。这很重要,因为模拟允许我们(手动)在各种状态下运行 GUI,以检查 GUI 实现是否正确并渲染良好。我们的模拟不仅模拟了崩溃报告程序的输入(环境变量、命令行参数等),还模拟了所有有副作用的 std 函数。

我们通过在板条箱中拥有一个std模块并在代码的其余部分使用crate::std来实现这一点。当禁用模拟时,crate::std::std相同。但当启用它时,会改为使用我们编写的一堆函数。这些函数模拟了文件系统、环境、启动外部命令和其他副作用。重要的是,仅实现了模拟现有函数的最低限度,以便如果例如来自std::fs, std::net等的新函数被使用,则板条箱在启用模拟时将无法编译(这样我们就不会错过任何副作用)。这听起来可能需要很多工作,但您可能会惊讶于std真正需要模拟的部分很少,并且大多数实现都很简单。

现在我们已经让代码使用了不同的模拟函数,我们需要有一种方法来注入所需模拟数据(在测试中和我们的正常模拟操作中)。例如,我们有能力在读取File时返回一些数据,但我们需要能够为测试设置不同的数据。无需详细介绍,我们使用模拟数据的线程局部存储来实现此目的。这样,我们就不需要更改任何代码来适应模拟数据;我们只需要更改设置和检索它的位置。语言爱好者可能会将此识别为 动态作用域 的一种形式。实现 允许我们的模拟数据使用类似的代码设置

mock::builder()
    .set(
        crate::std::env::MockCurrentExe,
        "work_dir/crashreporter".into(),
    )
    .run(|| crash_reporter_main())

在测试中,以及

pub fn current_exe() -> std::io::Result {
    Ok(MockCurrentExe.get(|r| r.clone()))
}

在我们的crate::std::env实现中。

测试

通过我们的模拟设置和测试 UI,我们能够广泛地测试崩溃报告程序的行为。“最后一英里”的测试是我们无法轻松自动化的,即每个 UI 实现是否忠实地表示 UI 模型。我们使用每个平台的模拟 GUI 手动测试这一点。

除此之外,我们能够自动测试任意 UI 交互如何导致崩溃报告程序影响其自身的 UI 状态和环境(检查调用了哪些程序以及建立了哪些网络连接,如果失败、成功或超时会发生什么等)。我们还设置了一个模拟文件系统,并在崩溃报告程序完成后,针对精确的结果文件系统状态在各种场景下添加断言。这极大地增强了我们对当前行为的信心,并确保未来的更改不会改变它们,这对我们崩溃报告流程中如此重要的组件至关重要。

最终产品

当然,如果我们不展示崩溃报告程序的图片,就无法完成所有这些工作!这是它在 Linux 上使用 GTK 时的样子。其他 GUI 实现看起来相同,但风格采用了原生外观和感觉。

The crash reporter dialog on Linux.

请注意,目前,我们希望保持其外观与之前完全相同。因此,如果您不幸看到它,它看起来不应该有任何变化!

有了新的、清理后的崩溃报告程序,我们终于可以解除一系列功能请求和错误报告的阻塞,例如

我们很高兴能够进一步迭代和改进崩溃报告程序的功能。但最终,如果您从未见过或使用过它,那将是极好的,我们一直在努力实现这一目标!

关于 Alex Franchuk

更多 Alex Franchuk 的文章…