走向原则性反应式UI

2020-09-27 00:09:54

这是大约一年前我的帖子的后续文章,走向反应式UI的统一理论。它是对这个问题的更深层次的探索:“在Rust中表达反应式UI的最佳方式是什么?”

关于反应式UI系统的“文献”有惊人的多样性。我在这里把“文献”放在引号里,因为除了某些例外,最好的主要来源是开放源码项目的代码。一些差异来自于目标的不同,但也有一些是偶然的。在许多情况下,原因很简单,因为设计者当时没有更好的解决方案的洞察力。我去年的帖子试图通过发现共同的模式来整理这种多样性。

我相信“在Rust中表达反应式UI的最佳方式”的答案很可能是在现有的文献中找到的,至少是通过结合主要主题来找到的,如果不是在单个现有的系统中复制的话。我们似乎不太可能不得不发明一些全新的东西。但整理它并不容易。这篇文章的目的不是提供一个全面的文献回顾(尽管我认为这样的事情会很有趣),它是为了引导人们探索最有希望的途径。我想做采矿,不是集邮。哪里是最富的矿脉?

为了集中调查的焦点,我将从列出一些目标开始。虽然总的来说,这些目标看起来都是好事,但重要的是要把它们理解为权衡。确定不同目标的优先顺序会把你带到不同的地方,而且不是每个人都有相同的需求。以整体系统复杂性为例。如果你是一家价值万亿美元的公司,那么一个复杂的系统仅仅是一个资源分配的问题,甚至可能在战略上作为阻碍竞争的护城河有用。但是,如果您是一名试图集成基本UI的独立游戏开发人员,您就会有一个非常不同的观点。

每个目标将主要作为一种方式介绍现有反应性系统所做的设计决策,并过滤那些看起来最有希望的来源和灵感。

然后,我将更深入地探讨三个原则,我认为这三个原则在任何反应式UI框架中都是至关重要的:是否使用“可观察对象”、如何表示呈现对象树(或一般的树)的突变,以及该树中节点的稳定标识的概念。

最后,我将介绍Crochet,这是一个为探索这些想法而构建的研究原型。

反应式UI架构的要点是使应用程序能够清晰而简洁地表达其逻辑,并且结果能够以合理的方式驱动UI堆栈的其余部分。

反应式UI的一个中心功能是让应用程序声明性地表示视图树的当前状态。在传统的面向对象的UI中,更常见的是指定初始状态(通常是静态文档,甚至不是代码),外加用于状态更改的附加逻辑。我认为辩论现在基本上结束了,被动的方法正在获胜。

SwiftUI因其出色的人体工程学在这方面获得了相当大的关注。但其他方法也值得研究。特别是,即时模式GUI(Imgui)几乎是声明性的,它只是以一种非常不同的方式来实现它(下面将详细介绍)。而Reaction和它的许多衍生品也“足够好”。Svelte是来自JS世界的另一个值得称赞的例子,尽管要适应Rust要困难得多,因为它依赖于成熟的编译器。

它在Rust GUI领域非常流行,以适应榆树模式;我们在RELM、ICED、VGTK和其他方面看到了明显的影响。但我认为榆树的简洁和友好很大程度上来自于语言本身,特别是它在更高层次的写作上的便利。在适应更实用的语言(如Rust)时,我认为视图构建和向组件分发消息的每个子任务都是半透镜,需要写出两个逻辑来集成一个组件。因此,我发现改编自ELM的Rust UI代码不够清晰和简洁。

比较不同工具包简洁性的一个很好的资源是7GUI。我们还没有把这些移植到克罗切特,除了柜台,但计划这样做。作为参考,下面是用于该操作的run方法:

FN Run(&;mut Self,CX:&;MUT CX){Label::New(Format!(";Current Count:{}";,Self.count)).build(CX);If Button::New(";Increment";).Build(CX){sel.count+=1;}}。

虽然imgui可以简明扼要地表达UI,但它不是递增的,这在某种程度上有些欺骗。通常,它可以非常快速地重新绘制世界(使用GPU加速)来弥补这一点,但它也有缺点,包括功耗。在一个无论如何都在积极使用GPU的游戏环境中,这是很好的,但这是在该环境之外不选择imgui的一个很好的理由。

Conrod的文档很好地表达了一个目标:“Conrod的目标是通过在隐藏的、保留的小部件状态图上提供即时模式API,从而兼收并蓄。”一旦您做到了这一点,在保留的小部件图中进行高效的增量更新就是一个解决的问题,尽管细节可能很复杂。不幸的是,我不相信Conrod会兑现这一承诺,因为应用程序逻辑会进行大量的显式图形构造和节点id的变戏法,而这两者在实际的即时模式API中都找不到。

另一方面,ICED虽然具有许多所需的属性,但不能满足“实际增量”的目标:虽然渲染器中有缓存,但它每16ms构建一棵完整的视图树(当有传入事件时,实际上是两次)。当视图树相对较小时,这很好,但在规模上这是一个严重的问题。

“虚拟DOM”方法的流行需要讨论差异,我认为这是一种半增量的形式(低级直接树突变的模转义孵化)。其想法是,生成完整的视图树应该很便宜,然后协调引擎计算该树与旧树之间的最小差异,然后通过DOM突变或一些其他方法应用该差异。因为DOM很慢,所以它当然比笨手笨脚的直接DOM突变(很难做到真正最小化)要快,但随着视图树的增长,仍然会带来性能问题。Reaction程序员应该非常熟悉这个问题。

当提供了逃生通道时,它们是实现树突变的较低级别访问的合理原则性方法,只是将一些状态跟踪转移到组件(通常是列表视图或类似的集合),还是绕过限制性能的基本体系结构决策的肮脏技巧?这两个都在文学作品中得到了体现。

这似乎是一个相对简单的功能,但正确实现选项卡聚焦需要相当深入的体系结构支持(或者,在基于Web的UI中,它被转送到浏览器)。基本上,它需要工具箱维护哪个小部件被聚焦的状态,并查询足够的整个视图树以确定哪个是选项卡焦点顺序中的下一个。Imgui的支持者提出了一个非常老套的部分解决方案(参见imgui上的johno),但我发现这样的解决方案并不令人满意。

同样,ICED是缺少此功能的现有Rust GUI工具包的一个示例,我认为需要进行大量的体系结构工作来解决,至少以系统的方式来满足类似的未来需求。归根结底,这些需求包括可访问性,这对墨西哥风味的设计来说尤其是一个严重的阿喀琉斯之踵。

我认为解决这个问题的正确方法包括小部件的稳定标识,下面将详细介绍这一点。

现在我们进入了更具争议性的目标。我个人发现越来越重要的一点是使用简单类型来表达应用程序逻辑和UI工具包之间的接口。

Rust特别吸引了复杂类型的使用,这主要是因为它有一个丰富的类型系统,能够将许多概念表示为类型。相比之下,在面向对象的UI中,传统的做法是使用非常松散耦合的动态类型;传递的很多值的类型都是“any”的某种变体。

复杂类型的一个很好的例子是SwiftUI,在SwiftUI中,组件返回实现View协议的静态已知类型。因此,组件的具体返回类型通常对其整个视图层次结构进行编码,包括特殊的条件和循环组合符;这在SwiftUI博客中的静态类型中有很好的解释。

这样的计划有好处,但也有严重的缺点。来自编译器的错误消息获取…。有意思的。而且它对编译时间也有严重影响,因为(至少在Rust中)编译器甚至在开始生成代码之前就必须将类型单元化。

对我来说,复杂类型方法最严重的缺点之一是脚本语言不能轻松发挥作用,因为类型必须在编译时知道。

同样,imgui是通过直接绘制UI而不是构造视图对象的中间树来避免复杂类型的体系结构的示例。但imgui并不是唯一这样的;另一个值得学习的令人信服的例子是Jetpack Compose。

使用复杂的控制流模式非常诱人:将重要逻辑放在回调中,使用高阶组合技术,或使用编译器显著转换代码。然而,这样的技术也有不利的一面。

第一个原因很简单,就是这种复杂性会泄露到应用程序中。在当前的德鲁伊中,我们使用一些高阶合成技术,如镜头。虽然按照Haskell的标准来说相当简单,而且我们有Haskell背景的用户倾向于喜欢它们,但很多来到Druid的人对它们感到困惑。

组合UI元素的最简单机制是函数组合。这一观点在Jetpack Compose中得到了很好的论证,而Reaction钩子与基于类的组件的经验则进一步证明了这一点。

更喜欢简单控制流的另一个原因是性能。并不是说它总是更快,而是因为它更容易推理和衡量。Crochet原型更具争议性的一个方面可能是它依赖于显式的应用程序逻辑来决定何时跳过子树。这是一种负担,但也是应用程序应用其自己的上下文(例如来自有状态数据库连接的状态)的机会。此外,简单明了的分析和跟踪应该能迅速发现更积极的跳过机会。

这一点更加主观,但我认为仍然很重要。UI工具包实际上是一项雄心勃勃的任务。执行与Adapton或Incremental类似范围的全面增量计算引擎是一个严重的额外负担。(增量的粉丝们也应该知道它的继任者盆景)。据我所知,SwiftUI在引擎盖下有一个类似的引擎(在堆栈跟踪中显示为“viewgraph”或“AttributeGraph”),这是对它与面向公众的组合的集成之外的。

因此,虽然我有时会听到人们建议将SwiftUI改造成Rust,从表面上看,这是有意义的,因为它具有出色的人体工程学和其他优势,但我从根本上认为它不会奏效,至少在不花费大量资源的情况下是这样的。

再一次,imgui是一个令人印象深刻的例子,说明在没有这种复杂性的情况下,有多少事情是可能的。它确实走捷径,但我认为可以探索如何实现功能更全的保留小部件树,以支持真正捕捉到imgui的简单性的API。在这个方向上有希望的一步是Makepad,它也是我许多想法的灵感来源。

我对ICED的评论也发现它的整体简单性很吸引人,尽管我担心这是否能经受住多窗口、全功能选项卡聚焦(更不用说可访问性)、扩展到大型列表视图的能力等所需的架构改造。

虽然上述目标阐明了现有反应式UI系统之间的重要区别,但我也相信基本上所有此类系统都有共同的原则,尽管每个系统都可能为其如何实现这些原则增添自己的特点。我的“走向统一理论”的博客文章提出了其中的一些原则,特别是树转换管道的模型。在这一节中,我将重点讨论上一篇文章中仅简要涉及的另外三个问题:可观察对象与类似未来的轮询、树和树突变是如何表示的,以及视图树中稳定的节点标识这一棘手问题。

UI需要表达依赖关系。您点击这里的一个按钮,内部状态中的某些内容会发生变化,然后在那边的另一个小工具中可以看到。

这些依赖项的标准面向对象方法是“可观察对象”,它有许多许多实现。虽然有很多变体,但通常它涉及到对象跟踪一定数量的订阅,每个订阅都归结为一个在发生事情时调用的回调。可能最熟悉的示例是JavaScript/DOM世界中的onclick侦听器等。

可观察的模式是如此普遍,它通常被认为是UI的必需构建块。但我认为我们应该寻找替代方案,特别是在Rust中,面向对象的基础不能很好地转换。

在使用getter/setter表示法的语言中,除了更新对象的字段外,可观察对象的setter方法还调用当前订阅的侦听器的回调。事实上,这可能是语言使用这种语法的主要动机之一。值得注意的是,虽然SWIFT确实有getter/setter,但不管是好是坏,Rust都没有。

可观察到的问题归结为这样一个事实,即回调需要大量上下文才能知道响应事件应该发生什么变化。通过这种方式,我认为它从根本上与反应式UI的目标相冲突,尽管一些系统已经设法调和了这两者,通常是在编译器的帮助下将相当直接的声明性逻辑转换为基于可观察的实现:SwiftUI和Svelte出现在脑海中。

我不确定是否有一个好的名字或文献可以替代可观察到的模式,但一般原则是通知携带的上下文要少得多。而不是“这个特定的事情改变了,更新你的状态作为回应,”通知说,“这一领域的某些东西改变了,你将需要重新计算。”

一个特别极端的例子是imgui,它几乎不跟踪更改通知(如果有的话),而是假设世界将以每秒60帧的速度重新绘制。但是,虽然取消通知跟踪的整个机制是一个巨大的简化,但它将增量婴儿与洗澡水一起扔掉。

现有的德鲁伊体系结构是另一个数据点,它证明了在没有观察到的情况下存在有效的、符合人体工程学的变化通知。它依赖于树的差异,使用指针相等跳过根本没有改变的部分,并使用类似Haskell的镜头将这种跳过逻辑应用于子树。然而,我们发现对差异的严重依赖会产生其自身的问题,这取决于应用程序状态与不变(因此很容易差异)的树数据结构范例的契合度。

我认为一个有前途的灵感来自Rust的异步基础设施。期货最终解决了与基于回调的可观测对象类似的问题,但使用的是非常不同的机制。基本上,通知被提供给“唤醒程序”,这是一种回调,但是携带的关于实际更改内容的上下文非常有限。通常,它引用将来阻塞的任务,以及标识阻塞对象的不透明令牌(例如提供某些数据的网络连接)。然后,Rust异步体系结构从其根调用该任务,该任务基于其自己的内部状态机和通过唤醒程序提供的令牌,快速分派到正在等待的特定未来。

钩针原型有这个想法的具体实现,但可能还有其他可行的变种。总的来说,我认为这是反应式UI框架要做出的最重要的架构决策之一。

与Rust的异步生态系统集成是UI工具包的一个主要特性,也是现有的Druid架构难以解决的问题。基于对Crochet原型的早期实验(尽管还有很多工作要做),似乎任务唤醒方法将非常好地集成在一起。细节超出了这篇文章的范围,但涉及到应用程序逻辑从根开始概念性地遍历视图树,而实际上有机会有效地跳过除包含唤醒令牌的子树之外的子树,在这一点上它正好拥有推进其状态所需的上下文。实现这一点是我对这种架构方法感到兴奋的主要原因之一。

正如“统一理论”一文中所论述的那样,反应式UI的逻辑被很好地表达为一系列树转换。典型的管道包括从应用程序状态到视图树的转换(此阶段基本上是“应用程序逻辑”的视图部分,另一部分是对UI操作的响应),从视图树到呈现对象(小部件)树,以及从小部件树到绘制某种或其他图形基元(理想情况下是GPU友好的显示列表)。

除了自定义小部件之外,视图树后面的大部分管道都由工具包负责。在现有的Druid架构中,应用程序状态本身也应该符合树形结构,尽管我认为放松这一点对于“简洁表达”的目标很重要。

这给我们留下了一个相当重要的部分:表示视图树,因为我们希望计算是增量的,特别是表示视图树的突变。

在JavaScript世界中,DOM(文档对象模型)是表示这种树的标准方式。总而言之,树中的每个节点实际上都是一个图形节点,它有对其子节点和父节点(以及直接兄弟节点)的引用,最上面实现了一个可观察的协议,在该协议中,更改既通过C++接口传播到浏览器引擎,也通过C++接口传播到JavaScript语言的侦听器。各个节点的所有权受到垃圾收集的影响,然后还有用于CSS处理的附加逻辑。简而言之,每个节点都非常昂贵,也是基于Web技术的UI性能问题的主要来源。

DOM的突变是通过指定良好且符合人体工程学的接口(如果效率不高)来表示的:appendChild、removeChild、setAttribute等方法的集合。虽然多年来肯定有很多JS手动完成这些突变,但基本上可以肯定的是,反应式UI框架的主要角色(如果不是主要角色)是将应用程序逻辑声明性表示的更改转换为这些树突变方法调用。

另一个极端是树的HTML序列化。这足够高效,因此它是通过网络发送树的首选机制,解析器可以很容易地将其扩展到DOM中,但是它的缺点是只对静态树有真正的好处。从理论上讲,发送差异并应用相应的补丁是可能的,但实际上这是非常脆弱的,生成差异的唯一可靠方法是将整个旧树与整个新树进行比较;要了解如何以编程方式生成差异并非易事。

我坚信我们应该寻找DOM的替代品,特别是在Rust原生GUI工具包中,因为它既很棒。

.