Native GUI research in 2021

整理一些native GUI架构方面的想法。

Motivation & Wishlist

有点迷恋于客户端技术。(这里的客户端是指的广义的客户端,泛指用户能接触到的最终产品)。在RRF的开发中,缺少用户界面来帮助我验证调试某些功能,展示作出的一些东西,是件遗憾的事情。我并不想引入外部的框架来做这件事情,因为本身UI的开发也是对RRF整个graphics stack的不错的检验机会。

与此同时,我认为研究GUI如何impl from ground,可以让我对2d渲染,客户端架构有一些深刻的了解和学习的机会,所以一个新的宏伟的巨轮又出现在了项目计划之中

我理想中的native的gui架构需要满足以下特新,这些特性不可缺一,其重要性主要按照以下顺序排列:declarative(expressive),high performance(and efficiency),composable(modularity)

声明式编程

所谓declarative,就是诸多现代流行的框架核心提供的能力,也是众所周知的概念:用户通过声明式的代码,直接指出数据与界面之间的映射关系,由框架负责更新工作,由此避免大量繁琐的数据和视图手工同步实现,巨幅提升界面编写效率和维护性。相关的概念可以说是深入人心的,也是vue、react等框架成功之处。甚至可以确定的说,现在没有人会再去使用没有双向绑定支持的UI框架了。双向绑定的支持对于用户是否能高效的编写业务是至关重要的。

关于性能

这里我不仅提到了performance,还有个重要的观点是efficiency。随着硬件的发展,事实上常见用户设备的图形能力能够吞吐非常大规模的UI图元显示,这意味着在整个UI管线的后端图形部分很可能并不需要复杂的增量/脏绘制实现,甚至某些情况实现不佳的增量绘制的cpu检查成本高过了暴力绘制的gpu实际成本。但是我的观点是我们不仅要performance,还要efficiency。

依赖GPU的全量绘制能力,使得具体的客户端性能表现依赖于实际的图形硬件配置。缺乏增量绘制更新的能力,也意味着你无法为你的GUI提供一个软件实现的图形后端,意味着无法在低端机器,受限的环境,嵌入式系统等情形下正常工作。

另一方面,efficiency意味着移动设备的续航能力。时间尺度下,假设没有必要的计算在GPU加速的情况下事实上不会有性能影响,但从空间角度/消耗的角度来看,依然是需要被优化的。做到完美的高性能增量更新是相当难做的,这对技术上提出了新的考验。

模块化,可组合可扩展能力

举例而言:用户可以自行实现UI组件,比如某个定制化的panel。用户可以自行实现全局主题。用户可以通过底层api实现高度定制化的样式。用户自定义组件的更新优化逻辑。用户可以扩展底层api,添加扩展性功能。

Prior art & Complexity

实现丰富复杂native的桌面程序非常困难,因为做的完善的话,事实上就是在实现浏览器。但这是不可能的。但浏览器是一个好的比喻,因为我们要解决的一些问题其实是差不多的。

简单而言,典型结构分为三层:

底层部分:需要搞定windowing:窗体管理,作为呈现的容器。基于这个容器做 输入:来自窗体或者设备的事件。和输出:UI渲染的结果。这一层需要完成windowing,events,presentation的跨平台封装(这些工作主要是调用操作系统的API),可以理解是一个类似提供shell的能力。具体实现细节可以简单也可以非常复杂,比如支持多窗体?子窗体?全屏?不同系统任务栏?系统内置的menu?drag and drop?多显示器?,dpi?文件选择(不会让上层实现自己画一个file explorer吧),奇怪的设备(游戏手柄)?系统提供的合成器(compositor)?硬件视频加速(是的这个和渲染没关系,是操作系统的api)?web支持?

视图层部分:提供一个界面的描述方式的视图数据结构view,接受底层的事件,转化成界面上逻辑的事件(比如这个按钮被点击了) 触发对应的业务逻辑回调。以及将view渲染到窗体上。这部分实现内部也会分为多层实现,最上层是设计一套描述view的API,一般来说是树结构,比如DOM tree,widget tree。这套tree描述了样式和结构方面的原始信息,第二层会根据这些信息计算layout,生成displaylist显示列表,其中可能会有大量的优化实现,大量的中间数据结构和缓存(比如额外的layout tree、render tree),第三层是渲染实现,会翻译displaylist成不同图形后端的GPU渲染命令并提交,同时实现纹理,字体,几何缓存,渲染调度,使用系统合成器等。这里每一层可以说内部由分了好几块,每一块都有海量的细节。

应用部分:构建和维护view:监听事件,触发业务逻辑,修改业务数据,再反向更新view。一般响应式框架也会构建于这一层完成自动的双向绑定工作。

在游戏开发领域,更多的会推崇另一种Immediate mode的架构方案,与之相对的上面这种则称之为retain mode。游戏中UI只是配角,因为优化UI的性能一般没有收益,而界面风格可能高度定制化,一个高度优化非常复杂,扩展能力差的view层就完全没有存在的价值,更重要的是不像web开发领域,view和应用部分没有成熟好用的双向绑定机制,所以不如没有view。immediate mode就是这种1 不在乎性能(每一帧几乎不缓存什么计算,暴力全刷,像画游戏一样画UI),2 full declarative(直接traverse 应用state生成渲染命令,由于是每一帧都绘制,事件处理的代码直接被封装进渲染里了)。非常适合并且容易集成在游戏引擎中。但是缺点也显而易见,因为stateless,比如复杂的animation,layout,这些功能难以实现(某些具有内部state的UI实现,其state还是需要交给业务,等于说上层还是要完成view的工作),因为stateless,所以优化很难搞(所谓优化本质是维护cache,增量计算,本质是由大量 state cache 来实现的)。

我个人并不认可纯粹的immediate mode这种方式, 结合我对重要性的考量,正如上述提到的缺点一样:对于高静态的应用,performance和efficiency方面的优化缺乏可控性,需要付出更多的实现成本,以至于引入partial的retain mode。同样的,immediate mode其实本质上和3d渲染引擎并无太大区别,对于高动态内容,也不失于是一种不错的view输出模式。所以,对于retain还是immediate之争,我真正推崇的是hybrid的方式,有机的吸收两种做法在不同场景下的可取之处,而不局限于教条。

GUI的底层支持是个复杂的事情,我不认为我们需要一个大而全的解决方案,一步到位像浏览器一样做一个巨大的runtime,而是可以将这个逐个的功能支持点独立成一个个可组合的crate,各取所需。在开发框架时也要充分考虑到这些部件的替换扩展能力。根据上述的分层架构,事实上结合rust现有的生态,搭建一个minimal,但是扩展性不错的框架是可以做到的。

CaseStudy & Experiment

druid 这个UI框架在rust的GUI社区生态方面还是有一定影响力,druid的作者就是xieditor的作者,在native ui,graphics方面研究非常深入。这个库我在去年年初还集中研究过一段时间,但当时看的不是很明白,但现在得益于技术的长进,可以说大致上是了解清楚了,我觉得他的视图层部分很有可取之处。

druid的核心在于widget这个trait,整个UI的view,就是一个实现了这个trait的数据结构,然后任意的widget都可通过combinator的模式组合起来形成新的widget。整个widget tree就是这么靠combinator套娃套起来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub trait Widget<T> {
// 事件处理,可以自己触发自定义事件,修改应用数据
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env);

// 相当于react的一堆生命周期hook
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env);

// 接受应用数据(T),更新自己的view
fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env);

// 实现layout
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size;

// 实现渲染
fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env);

}

举例来说,flex布局事实上就是实现了widget的数据结构Vec<dyn widget>,差不多主要是实现layout。然后其他的event/paint直接转发子节点的实现。某个事件处理器也是一个widget,只不过实现了event,其他的都是套娃转发的。

我认为这真是绝妙的设计!我愿意称之为combinator套娃式的架构。

这套思想的核心就是定义面向数据流的最核心的抽象(trait)然后针对某个单独功能特性做实现,然后做一堆组合器的实现,最后通过组合器和原子化的功能,将你最后的实际需求组装起来。 比如Rx的事件流,promise,future,全都是这样的。这个做法和GUI放在一起同样绝佳的解决了组合性方面的问题。

除了解决了架构上功能模块化和扩展的的问题。对于rust这门底层语言,用户有能力控制内存,组合器链接子节点的过程可以自由的选择dyn trait动态连接,或者是通过泛型静态连接。得益于rust如此的零开销抽象机制,用户可以通过控制这个来显式的优化每个组件实际最后的内存布局,以避免过度零散的组合导致额外的间接调用和缓存不友好情况。比如你组合出了一个slider,可能下面有十几个小的widget,但是我可以保证这个slider在内存上是连续的,几十个函数调用最后编译出来只有几个,仿佛你手写了一个超级复杂的slider widget。

这种做法还提供了另一块额外的性能优化能力。假设你的view结构的逻辑也是被组合的数据结构,比如,某个子view是否要渲染,是通过特制实现if组件来控制,列表视图通过for组件实现,本质上你就是定义了一个类似vue的模版,只不过是个编译期高度优化过的形式。同样的,正如同vue的模版设计对于vue的性能优化起到了很大的作用,对于我们来说就是logic,branching,looping,等控制流结构被encode到combinator的数据结构里了,而针对每一种数据结构,我们的combinator可以定制的实现优化行为,比如memo,caching,differing,这些combinator本质上就是用户数据state,或者input event的transformer,transform整个view的change到下一层的combinator,整个UI更新的增量架构实现完全就是做在这些transformer/combinator上的,第二点就是对于某些子view的片段是constant的,即便他们有更新的逻辑,但其实是不需要运行的,得益于编译期优化强大,这些代码大概率可以在编译期就被优化掉。

我认为最好的设计不应该限制用户编写代码的方式,或者说逼迫用户采用某种范式进行开发。模版式的做法虽然有相当可取之处,但像react一样通过自由的逻辑来生成视图也是可以被允许的,react使用了统一的view描述结构virtual dom和不可变数据结构(optional)来做增量更新,而我们认为view的数据结构不应该由框架控制,那么如何做增量式更新呢。druid给出的答案是必须依赖不可变数据结构,我觉得这个想法还是可以接受的。做增量的事情,我看是逃不了做diff,要减少diff的成本,要么对view的实现做规定/侵入式实现(RP,virtual dom),要么对用户state做侵入式实现(immutable, reactive)。而甚至两边看场景都需要,因为如果小的state生成巨量的view,那肯定得diff state,如果大的state生成小的view,应该diff view。

除了diff,做自由式数据界面映射的增量实现还有个非常关键之处,如何解决move语义?比如你diff出change了,但是这个change本质是个move,如果不做任何处理,还是执行销毁重建流程,那么不仅浪费性能,还会触发不必要的生命周期。react和vue都通过key来确定子组件identity来执行move,我很确定我们的for/list组件也能实现。但如果是自由式数据界面映射呢?毕竟我们没有vdom。另外假设你有两个相同类型的子组件,条件渲染第一个并出现,如何自动move第二个组件而不全量更新,当然我很确定模版或者if的做法也不会有这个问题,因为条件渲染被体现在数据里了(第一个坑总归留着的)但如果是自由式数据界面映射呢?

这个问题的本质是通过调用函数来组合生成view的过程,缺乏函数调用点对view实例的映射信息,以至于无法在运行时通过缓存的view实例来判断是否是当前相同的view function调用产生的。解决这个问题一般需要编译器介入使得函数内能访问函数调用的元信息,而rust正好有个#[track_caller]的宏可以在某个函数内获得当前调用对调用点的unique信息,于是问题就被魔法般的解决了,另一个rust框架 moixe主要就是利用这个来做incremental的。

我真的非常喜欢druid作者对incremental ui的论述,甚至从big picture来看,所谓incremental就是优化的本质。UI framework从数据到界面的映射,中间历经结构化,样式,布局,动画,渲染,每一层都有每一层的数据结构,整个framework incremental就是实现一条无比复杂的增量数据流pipeline,让每一层都最小化更新成本。又比如实现编译器的增量编译,language server,也是input一个小的change,然后一路pipeline下去。所以我想应该会有一套增量计算的框架来共同抽象出这类优化问题,可能是提供类似一套immutable的数据和更新元语的东西,也不排除这些特性升级到语言的层面。我能看到一些研究,但是依然还不得要领。

对比druid的做法和prior art,druid view,application并没有空间上的强隔离,你可以看到用户数据是直接通过Widget<T> 直接注入进来的。我认为这个是正确的做法,减少一层indirect,提高了性能。直接反映了应用数据生成视图的思想,体现了应用数据在视图转化中的过程,非常干脆利落。而避免了传统retain mode UI 强行 MV分离 大量callback,引用到处指,各种indirect调用同步数据,编译器运行时根本无法作出良好的优化。而事实上传统的这种大量callback的做法在rust这种没有GC的语言里是非常痛苦的,非常不ergonomic。虽然空间上是一体的,但是从实现上,职责区分是清楚干净的。

目前实践

我的整体架构还是会使用druid style的reactive programing。但是允许无缝的接入一些freestyle的做法(比如即时模式)。目前已经可以绘制textbox和button等简单组件,但距离理想情况还有很大差距。

我也会尝试作出一些新的尝试,作出更加模块化的设计。比如incremental方面,layout,animation protocol,,这里边每一点都可以展开细说,如果有兴致我可能后续连载于此。

##