共计字数:3.4k 预计阅读用时:11 分钟

Rust UI 中的 ECS 架构(翻译)

原文:Entity-Component-System architecture for UI in Rust
仅在 小萧的个人博客 发布,严禁搬运抄袭!
个人翻译,水平不佳,如有错误还请评论区提醒更正,非常感谢!

最近,我一直在维护 xi-win 这个有趣的工程。这是一个用 Rust 编写,用于 xi-editor 的实验窗口前端。我还在简单的优化它的性能,所以做了一些不寻常的决定。除去其他的库,UI 库由我自己编写,而不是用现在已有的。

本文的代码:xi-win/xi-win-ui。如果你用的是 Windows 的话,可以试试看里面的 calc 范例!

我已经听说过有关实体-组件-系统(Entity-Component-System)(ECS)架构的优势了,所以很想试一下。我也有很多编写经验,所以想在这里介绍一下。对于那些想要从零开始制作自己的 GUI 框架的人,我都十分推荐使用 ECS 架构。而且我认为,这个架构非常方便,可以有效避免借用检查冲突和 RefCell 的滥用。作为依据,我们可以看看由此编写的代码与其他 GUI 框架实现相比有多么简洁明了。

2018-05-10 更新:这篇文章在 Reddit 上得到了很多不错的回复。而且还在 Rust 开发论坛帖子里也被提到了。官方论坛有很多非常好的资源,包括这个不错的文章 Rust UI Difficulties,更详细的解释了我的想法。

Reddit 帖子的总结是,我做的其实不是纯正的 ECS。我觉得把它描述成受到 ECS 启发的混合体更加准确,而且我也会不断补充这个解释。

非常感谢评论区的大佬!

(更新结束)

为什么(在 Rust 的)GUI 开发很难?

用任何编程语言编写一套 GUI 是很有难度的,在 Rust 更是如此,为什么呢?

一个 GUI 拥有非常多的状态(State),非常多的交互(Interations),而且还是动态的(Dynamic)。传统的 GUI 框架都是设计成将接口拆分成各种各样的小部件对象,并相互调用函数方法来交互。每个部件都各自存储着全局状态的一部分,但是其他部件也可以访问到其余的所有状态。而且,由于各个部件的子级依赖关系,所有的部件也可以访问其他全部组件。

这样的架构在 Rust 无法工作。首先,在 Rust 里访问可变的状态非常受限:同一时间内只允许一个结构访问一个可变引用。

其中一个方法是使用内部可变性。通常用 Rc<RefCell<T>> 来封装一个对象,使其能够被共享。如果在代码里需要借用可变状态,就从这里借用一个可变引用,修改其中的数据,然后借用就被自动释放。这个方式让可变引用检查转移到了运行时通过恐慌(Panic)来处理。(使用线程安全的方法的话,Arc<Mutex<T>> 会出现死锁而不是恐慌)

虽然用这种技巧写 GUI 是完全可以的,但这并不好写。第一,代码会充斥着 .borrow_mut() 的借用调用,为了缩短生命周期的花括号约束以及时释放借用等。

另一个 GUI 编程的难题是,为了提高性能,理想情况下它应该是可以增量更新的。例如响应输入,大多数 GUI 状态和外观可能保持不变,或者只有少量改变。优化性能的关键是尽可能减少计算工作量,也就是说只更新变化了的那个状态,然后只重绘屏幕上改变了的部分。在过去,因为性能约束,必须采用增量更新的方式,因为重绘整个窗口会非常缓慢。现在,完全重绘已经十分常见,尤其是立即模式 GUI 框架。我需要说明一下我目前的代码并不会增量更新,但我正在计划去制作,而且我也希望架构可以支持这样。

实体-组件-系统架构

在 实体-组件-系统(Entity-Component-System) 架构中,系统拥有所有组件(追加更新 2018-05-10:这不是很准确,在真实的 ECS 中组件都被存储在一个数据库内,我的 ECS UI 状态是作为数据库和系统的组合存在的)。用它们的术语来讲,一个实体是一个小型的轻量级对象,用于引用组件,在 Rust 中,它只是一个系统用来存储组件的 Vec<> 中的 usize 索引。

在 ECS 中(或者按我的想法来说),组件负责它的特定行为,管理它的特定状态(例如标签组件的文字),但是系统会管理全部组件都通用的状态(例如几何布局)。系统也同时管理组件间的依赖关系(这样,部件就会构成关系树了)并管理它们之间的交互。

在 xi-win-ui 中,大多数交互都使用 Widget trait。实际上,里面的组件都是 Box<Widget> 的实现。

使用整数来索引各个节点

UI 的核心是部件树,长起来更像一个图表,父部件/子部件可以相互联系并双向遍历。有很多种方法来实现这种部件树。我个人喜欢将节点存储在一个 Vec 向量里面,然后用 usize 作为标识符,前面也有说到,我也向读者介绍一下 Rust 里常用的树形结构

这里没有太多需要补充的,只是另一个可以完美工作的形式而已。

状态分离

另一个让 GUI 开发变得困难的原因是可变性的形式非常多样。例如,当进行布局时,你希望对几何进行可变引用,但又要不可变引用图像(译注:此处并不是很明白,请评论区的大佬稍微补充)。在不同的时间里添加组件到树中时,树需要变为可变的,但又不需要引用到几何。

施工(人工翻译)进度到此,后文为机器翻译

一种方法是将所有这些操作推迟到运行时-使用内部可变性,这样您传递的所有引用都是非可变的,然后仅在需要的时候进行可变的借用。

我认为,更惯用的方法是准确地确定需要什么可变性,并将其编码为各种方法的类型,以便在编译时强制执行。这样做的好处是,不可能多次借贷出现恐慌,借贷模式基本上被证明是正确的。

实现此模式的关键是状态拆分。在入口点,您可以对整个状态进行可变引用。采取该状态并将其拆分为对各个字段的引用。根据您的操作,某些参考是可变的,而其他参考则不是。您可以通过进行递归调用来遍历树,只需遍历引用即可。

示例包括layout,它具有对分量矢量和几何的可变引用,但对图形具有不可更改的引用。该涂料的方法是类似的,但具有不可变的几何形状和一个可变的渲染目标的实际绘制控件的外观。调用输入事件的处理程序(鼠标键盘)时,会传入一个上下文,该上下文具有足够的可变状态,以使小部件可以将其标记为需要重绘,还可以将其他事件发送给侦听器。以下有关监听器的更多信息。

数据流,而不是控制流

我想采用Flutter布局模型,因为它既高效又灵活。Flutter布局是树的一遍遍遍,其中约束向下流动,尺寸向上流动。我很喜欢亚当·巴特(Adam Barth)的演讲,即Flutter的“渲染管道”(Ruttering Pipeline)作为清晰的描述,但是了解Flutter的布局(框)约束可以使您理解所有基础知识。(我应该指出以避免混淆,Flutter的RenderObjectWidgetxi-win-ui中最接近; RenderObject层次结构是一个相当传统的窗口小部件系统,而Flutter的Widget类可以看作是管理渲染的React样式层对象-Ian Hickson的讲话是一个很好的解释。)

在Flutter中,容器RenderObject的布局方法递归调用其子级。但是,在Rust中,这将是一个问题。借位检查员会抱怨。要调用父窗口小部件的布局方法,我们需要一个可变借项,它是从保存Box<Widget>对象的容器中的可变借项派生而来的。因此,要调用该子项,我们需要从该容器中再次借用以获取对该子项小部件的可调用引用。哎呀。

xi-win-ui中使用的解决方案是从借来的上下文中“走私”足够的状态以取得进展。就像延续一样,其样式类似于在Rust中编写迭代器。对于布局而言,当某个微件要申请一个孩子的布局,它返回一个RequestChild与子节点的ID结果。然后,系统计算该布局(根据需要遍历为子级),然后layout再次使用结果调用该方法。

侦听器使用类似的方法。在传统的面向对象的UI中,按钮小部件的鼠标处理程序可能会调用附加按钮的侦听器。反过来,侦听器可能会做很多不同的事情,包括向树中添加更多小部件(例如,如果按钮的操作是打开一个新选项卡)。但是,当按钮具有可变借位时,将不允许任何这些操作。

解决方案类似于布局。小部件不是立即调用侦听器,而是将其输出事件添加到队列中(传递给处理程序的上下文包括对该队列的可变引用)。然后,当处理程序返回时,此队列中的事件将分派给侦听器。提供给侦听器的上下文允许对几乎所有状态的可变访问,因此它可以“戳”小部件,更改小部件图等。

事件队列还说明了RustAny特质的良好使用。不同的窗口小部件将发送不同的具体类型,因为在此类事件中有许多有用的类型,但是连接到窗口小部件的特定侦听器将知道预期的类型。

应用逻辑状态

在撰写本文时,在master分支中,计算器使用Rc<RefCell<CalcState>>来存储计算器状态,并且此参考在所有侦听器之间共享,用于单个计算器按钮。这表明可以将内部可变性模式与ECS架构混合使用。

另一种实现是使用自定义窗口小部件来存储应用程序状态。各个按钮侦听器发送事件,这些事件冒泡到此小部件。该管道不需要明确的ID;它是作为捕获事件的最接近的祖先而隐含的。该窗口小部件无法直接更改其他窗口小部件的状态(例如,更新读数),因此它将事件发送给侦听器。

我仍在寻找最简洁,最惯用的方法来连接此体系结构中的UI。本小节的重点是说明存在多种合理的方法。

参考文献

Rust中GUI的另一种方法是conrod。它使用了某些相同的技术(包括Rust-idiomatic图),但做出的设计决策与xi-win中我想要的有所不同。Rust中GUI的潜在用户和实现者一定应该注意这一点。

与xi-win-ui相比,relm项目处理的堆栈级别更高,后者是传统微件系统之上的功能响应层。功能反应式风格以UI的简洁表达而闻名。在xi-win-ui上集成这样的层可能很有趣,但是对于xi-win,我计划直接构建窗口小部件树。

结论

过度使用RefCell是单一Rust代码的标志。使用正确的体系结构,可以完全避免这种情况。通用技术适用于许多有状态组件之间存在动态交互的情况。这些技术是:使用整数作为图形中节点的引用,在跳入时将状态分为可变和不可变的部分,在借用得过多时显式导出“继续”状态,而不是直接传递控制流。

事实证明,ECS体系结构本身在游戏界非常有价值,尤其是在C ++中。它非常适合Rust,并且同样适用于UI框架中的小部件和游戏中的玩家。

尽管仍处于实验阶段,但xi-win-ui的方法似乎适合于xi-win相对简单的UI需求。该代码非常简单,基本上没有“魔术”或宏来隐藏潜在的复杂性,只是惯用的Rust概念的普通用法。强烈建议未来的Rust GUI工具包的设计者学习此代码。

鸣谢

感谢Connie Hilarides对ECS架构进行了有益的讨论,并感谢Rob Tsuk对Rust中GUI的讨论和原型。