写点什么

对 Jetpack Compose 设计实现的解读与思考

用户头像
Android架构
关注
发布于: 14 小时前

Compose 从整体技术风格上来说是这样一个产物:在语法上激进模仿 SwiftUI,编译/运行过程充满 Svelte 风格,同时也综合了各方包括 Android 开发组自身对 UI 框架的思考结果。


使用 Compose 时,最值得关注的就是 Compose 的编译器插件。可以这么说,Compose 的 runtime、api 都是依附于编译器插件的,那个巨大而无所不包的编译器插件才是 Compose 的本体。


Compose 插件强势的入侵了原版 Kotlin 的语法,导致包含了 Compose 的 Kotlin 基本上可以算作新语言(算成个新虚拟机都不过分)。初次了解的时候确实让我很困惑,因为这与 ReactFlutter 推崇的趋势简直是背道而驰。但是了解了 SvelteSwiftUI 之后,Compose 显得没有那么突兀了。


高性能 UI 与通用编程语言的冲突




很久以前代码与 UI 都是分开用不同语言写的,React 改变了 UI,"Code in one language"的呼声越来越高,再到后来,他们又改了回去。


Svelte,?SwiftUI,?Vue3.0 的趋势揭示出通用编程语言并不能很好的满足高性能 UI 的需求。这些语言都不约而同地选择在编译期优化上下功夫,这些优化需要大量关于代码的元信息来实现,当通用编程语言默认提供的元信息不足之后,只剩下开发者手动标注和发明新的编译流程两个选项。


(代码本身执行会产生返回值和副作用,大部分时候人们只关心返回值和副作用,但是代码本身包含的信息远多于返回值和副作用。代码是怎么写的、什么逻辑,都是编译期优化感兴趣的内容。有些语言默认携带了更多的元信息,比如如果某个函数式语言语法自带了依赖收集,那么就相当于这个语言的每个变量自己就携带了这个元信息,那么写 React 的useMemo时就能省略手动标注依赖项的操作。但是“通用”编程语言一般默认携带的元信息少得可怜,一般每个变量可挖掘的信息只有值、编译期类型和运行期类型。有些语言甚至后两个都是残废甚至不存在。)

Svelte

Svelte 试图解决的问题是:如何用声明式的代码书写风格,一对一直接翻译成纯粹命令式的 DOM 操作,从而达到无额外开销+极小 runtime 的效果。最终 Svelte 选择发明一套自制的模板语法来翻译到 Javascript 上。这个好理解,因为大家很熟悉,显然 javascript 以及其工具链没有任何实现这个目标的可能。

Vue3.0

Vue3.0 使用的模板则是为了另一个目的:在编译期收集 UI 布局的静态信息。模板的编译器可以在编译器自动识别模板里出现的那些值和节点是不变的,哪些值和节点是开发者传入的可变的变量,从而在编译结果中跳过对于不变值的 Diff 过程。Vue3.0 的模板优化远不止这些,但从根本上来说,这些优化都是基于收集代码的元信息而实现的(或者说是在编译期实现的),基于纯 javascript 并不足以很好的实现这些需求,所以才产生了对于模板语法的需求。


另一方面,Vue3.0 的 Reactivity API 倒是成功的通过 hack 的方式实现了类似依赖收集的特性,基本不需要手动标注,Javascript 各种奇怪的特性总能带来惊喜。

SwiftUI

SwiftUI 解决方案则更加夸张。由于设计目标导致 SwiftUI 必须由 Swift 单语言完成而不能搞自制语法,SwiftUI 采用了两个举措来获取代码元信息:第一个是对编译器开洞,搞了黑箱式的 FunctionBuilder 注解,第二个是利用FunctionBuilder提供的操作空间,将感兴趣的元信息编码进编译期类型中,通过对编译期类型的解读实现类似于 vue3.0 的编译期优化。


具体来说,SwiftUI 的FunctionBuilder能把对于表面上一个闭包调用了两个构造函数


{


Text("Hello")


Text("World")


}


转化为一个TupleView<(Text,Text)>的编译期类型,从而告知 runtime:“子节点数量写死的只有两个”+“类型也是写死的”。 也能够把表面上 if 控制的一个构造函数


{


if something {


Text("Hello")


}


}


转化为一个Text?,告知 runtime:“这个子节点是有条件出现的” 甚至连扩展方法


{


Text("Hello")


.background(Color.red)


}


返回的都是ModifiedContent<Text, _BackgroundModifier<Color>>,告知 runtime 代码中究竟是怎么修改的 Text,修改了什么属性。


总之 Swift 选择用复杂的编译期类型嵌套来描述 UI 的绝大部分细节,相当于把代码 AST 中感兴趣的部分,在黑箱内转译成一个符合语言规范的表达方式(编译期类型),再传递给框架的其他部分。这个方法相对较通用,而且侵入性较低(否则直接拿着 AST 在框架里传来传去就相当于是在魔改编译器了)。

React

React 选择**all in javascript",jsx 都是直接展开成React.createElement,所以没有编译期优化。同样的 useMemo 后面得手动声明一堆依赖项。

Flutter

Flutter 同为 Google 的项目,很适合与 Compose 进行对比。Flutter 很显然对于编译期优化缺乏兴趣(同样也对很多其他高层次优化缺乏兴趣),Flutter 的目的只是提供一个贴近乃至暴露底层渲染流程的跨平台 app 自绘引擎,提供的上层封装很浅。Flutter 关心的只有运行期的各种机制,对编译期细节基本是毫无兴趣,也符合其偏向底层的风格。所以 Dart 这种缺乏特性的语言对 Flutter 来说并无大碍。(多嘴一句,Dart 的趋势估计是要逐渐的成为带 GC 的增甜 C++,更适合开发 Flutter 这种引擎)

Compose

Compose 在框架设计方面的野心明显超越 Flutter。Compose 团队多次表示 Compose 就是对 Android SDK 的重写。Compose 对自身的定位估计类似于 SwiftUI 在苹果系生态中的定位,那就是高层次、生态内通用、外加依靠自身定位尽可能挖掘以及定制工具链以实现先进的开发模式。Compose 使用了语法上和 Swift 神似的 Kotlin,也面临相似的问题,于是 Compose(出于一些原因)选择了简单粗暴的魔改 Kotlin 编译器,而不是模仿 SwiftUI 玩类型系统杂耍。


Compose 团队解释过 Compose 的出发点:构建一个通用的、描述树状结构渲染过程的框架,不管是是手机 UI 组件树或者是浏览器 HTML Element。


Compose 一不做二不休,直接把 Kotlin 编译器魔改到底。最后利用编译器魔改实现了几大功能。


Svelte 风格的指令式翻译




Compose 对于 @Composable 函数的翻译很有 Svelte 的风格,基本上做到了将声明式的函数语句一对一的翻译为针对 composer 的指令。这个翻译过程目前官方放出的资料很少,而且演示性质居多,一般只是针对某个特定的翻译模式来撰写简单的例子,而没有准确的、成体系的说明,真正的翻译产物远复杂于官方示例。


最简单的 Counter 示例


@Composable


fun Counter() {


var count by remember { mutableStateOf(0) }


Button(


text="Count: $count.",


onPress={ count += 1 }


)


}


翻译为


fun Counter($composer: Composer) {


$composer.start(123)


var count = remember($composer) { mutableStateOf(0) }


Button(


$composer,


text="Count: ${count.value}",


onPress={ count.value += 1 },


)


$composer.end()?.updateScope { nextComposer ->


Counter(nextComposer)


} // 为重渲染注册钩子


}


Button函数也是一个@Composable函数,内部也会被编译器处理。可以看到这段最简单的示例被翻译成了 composer 上 start 了一个 group,执行了remember和 Button 的操作(Button 也将被进行类似的展开,直到展开为最基础的画布操作),在 end 的时候注册了一个为了重渲染准备的钩子。接下来的优化,也都是基于这种指令式翻译的风格展开的。


实质上各种 vdom,widget,HTML Element,Swift View 结构的存在,无非都是用面向对象的方式储存基础画布操作,方便声明式编程而已。这也是 Svelte 风格的指令式翻译的突破点:取消掉中间层,直接编译阶段翻译为基础操作。


Positional Memoization 进行状态持久化与运行期优化




Compose 团队口中的“描述通用的树状结构渲染过程”很大程度上指的就是 Positional Memoization。声明式编程常常遇到的问题是如何在重渲染过程中保存部分状态,从而 1.实现状态管理 2.方便进行 Diff 从而避免不必要开销。类 React 的方案是在组件层背后再增加一层 v-dom 层,这样 v-dom 层自然保持了状态,同时也能进行 Diff,Flutter 的 Element 层同理。但是 Compose 团队表示连这一层的开销他们都想省......


跳脱出面向对象,换成指令式的思路之后,这事就变得可行了。反正计算机到头来都是纸带加上读写头,纸带就是状态,读写头移到哪就在哪里 Diff 不就行了。Compose 团队最后实现了这个暴力美学的方案,Compose 的 runtime 还真的就是一个 composer(读写头)工作在一个 slot table(纸带)上。


代码的执行流程本质上就是深度遍历一棵树的过程,于是在 Compose 的思想里,@Composable 函数代码里所有感兴趣的细节可以视为一棵 AST 树(不仅 @Composable 函数的嵌套关系


《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
浏览器打开:qq.cn.hn/FTe 免费领取
复制代码


被记录下来了,开发者传的每一个参数、调用的某些函数也被视为节点),然后 composer 执行时就相当于按照深度遍历的顺序把这棵树事无巨细的记在 slot table 里。如果这棵树的结构不发生变化(UI 结构不发生变化),那么无论怎么重渲染,节点在纸带上的位置一定不会变化,所以 composer 读到相应的位置,就相当于找到了相应节点在上一轮执行时留下的状态,就叫做 Positional Memoization


以下示例来自于 Google 演示文档


@Composable


fun Counter() {


var count by remember { mutableStateOf(0) }


Button(


text="Count: $count",


onPress={ count += 1 }


)


}


对应在 slot table 上的执行结果为



positional memoization


可以看到remember函数,state,Button 函数传的参数,全部都被以深度优先遍历的顺序记录在了纸带上


Positional Memoization 自然可以用作状态管理。同时,因为 Compose 记录了每个函数传递的参数,因此 Diff 操作就变成了 composer 在纸带上对比上一轮参数与本轮参数,从而决定是否跳过某个组件。


@Composable


fun Google(number: Int) {


Address(number=number)


}


会被编译为


fun Google(


$composer: Composer,


number: Int


) {


if (number == $composer.next()) {


$composer.skip()


} else {


Address(


$composer,


number=number


)


}


}


在没有引入额外层的情况下,Compose 实现了状态的持久化和 Diff 操作,可以算是 Compose 团队创新的思路了。但是由于 Compose 收集以及处理的信息如此之多,这样的直接结果就是导致 Compose 几乎可以被称为是一个新虚拟机了

用户头像

Android架构

关注

还未添加个人签名 2021.10.31 加入

还未添加个人简介

评论

发布
暂无评论
对Jetpack Compose设计实现的解读与思考