2021-06-03 | translate

翻译:Compose From First Principles

Compose 首要原则

原文地址:http://intelligiblebabble.com/compose-from-first-principles/
译者注:原文写于 2019 年,有点内容过时了,但是全文还是很值得一看的!翻译夹杂私货,仅作学习备忘,建议读原版。
推荐关注作者的 Twitter

本月初,来自世界各地的数千名开发者参加了谷歌2019年的I/O大会。我对此次 I/O 尤为兴奋,Google 第一次公开介绍了 Jetpack Compose 项目,我从 2018 年二月开始受雇于这个项目。

Compose 是一个雄心勃勃的跨团队项目,在安卓平台初始 UI 工具推出的 10 多年后,致力于重新构想 Android UI 工具集。

有一个关于声明式 UI 的视频,解释了这个项目背后的动机和目标,本篇文章不再赘述。如果你想在阅读本文之前,了解背后的动机,可以先去看这个视频,本文只讨论实现细节。

自从我们开源了 Compose 后,很多人对它的工作原理很感兴趣,提出了许多问题。我考虑了会儿先讲哪一点比较合适。

本文主要想给大家建立一个扎实的思维模式,关于 Compose 做了什么,把看起来像黑魔法似的操作背后原理讲明白。我想,最好的方式莫过于把 Compose 工作原理简化,先构建一个原始框架,再一点点的修饰,逐渐变成一个像样的东西。换句话说,让我们从“基本原理”开始,写一个 Compose。

本文只解释 Compose 做了什么,不负责解释为什么这样做。读者需要熟悉 Kotlin,尤其是扩展函数

最后声明下,文中代码和 Compose 实际应用完全没有关系

UI 是树状数据结构

Compose 的核心设计目的是为了高效构建 并维护 树状数据结构。更具体的说,他提供了一种可以描述树如何随时间变化的编程模型 。

这种变成模型不是全新的。我们从许多框架中得到启发,例如 ReactLithoVueFlutter 等等,他们都以各自的方式达成了这个目的。

从上面列出的框架中,我们大概可以猜到,这各系统主要是用来构建 UI 的。UI 是典型的会随时间变化的树状数据结构。而且,现代 UI 正在变得越来越动态,复杂,需要一种编程模型来缓解复杂度。

Compose 的运行时并不聚焦于某一种类型的树,已经被在很多不同类型的树上了,例如 Android Views, ComponentNodes, Vectors, TextSpan,后续还会有更多的应用场景。

本文没有使用上面提到的,而是自己定义一个基础树状结构,更易于理解。

我们可以想象一个非常基础的 UI 库,定义了以下几种类型:

1
2
3
4
5
6
7
8
9
abstract class Node {
val children = mutableListOf<Node>()
}

enum class Orientation { Vertical, Horizontal }

class Stack(var orientation: Orientation) : Node()

class Text(var text: String) : Node()

这里只有两个原始类型:StackText。真实场景肯定比这多,还有一堆属性和方法,不过没影响,我们就图省事。这俩相当于 Android 里的那堆 View,或者 Web 里的 Element

接着,我们需要一个方法,把 Node 树渲染成像素到屏幕上。这个方法的实现细节不重要,我们假设有这么一个东西就好:

1
fun renderNodeToScreen(node: Node) { /* ... */ }

用上面这套东西写出来的 “Hello World” 长下面这个样子:

1
2
3
fun main() {
renderNodeToScreen(Text("Hello World!"))
}

下面我们开始搞个复杂点的,写一个 “To Do List” 程序。

从 UI 到“转换函数”

构建应用的一个指导原则就是要把 “model” 的概念从 “UI” 中分离。

这里我们的 “model” 是个 TodoItem 的 list,一种实现方式是用一个函数,把 item list 转换成 Node 树:

1
2
3
4
5
6
7
8
9
10
fun TodoApp(items: List<TodoItem>): Node {
return Stack(Orientation.Vertical).apply {
for (item in items) {
children.add(Stack(Orientation.Horizontal).apply {
children.add(Text(if (item.completed) "x" else " "))
children.add(Text(item.title))
})
}
}
}

这就可以响应程序的数据变化,并渲染到屏幕上。像下面这样:

1
2
3
4
5
fun main() {
todoItemRepository.observe { items ->
renderNodeToScreen(TodoApp(items))
}
}

从上面 TodoApp 的例子里可以看出,直接向 parent 的 children 属性添加节点很麻烦。我们需要保证这个树里所有 Node 都可以访问其 parent 的 children 属性,调用 children.add(...)。在这个例子里可能看起来还行,不算麻烦,但是当函数逻辑变得再复杂点,就不够用了,很难维护。

我们可以新建一个 “holder” 对象,用它来持有当前的 “parent” 的 Node。然后用一个 “emit” 函数把 node 添加到 parent,还提供一个 “content” lambda 参数,在这个 lambda 的作用域里,emit 参数传入的 Node 是 parent。这个 “emit” 函数可以把一个 Node 添加到当前这棵树的当前位置(译者注:dsl 声明的感觉?),而不需要知道当前位置的 parent 是谁。

从语义上说,这个对象可以帮我们构建 “compose” 树,就叫他 Composer 吧。定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface Composer {
// 添加 node 到当前 Node
// content,可以用来往参数传递的 node 里添加节点
fun emit(node: Node, content: () -> Unit = {})
}
// 一个简单的实现。可以略过不看。
class ComposerImpl(root: Node): Composer {
private var current: Node = root

override fun emit(node: Node, content: () -> Unit = {}) {
// 储存当前节点,用于后续恢复
val parent = current
parent.children.add(node)
current = node
// 在这个作用域中,执行传进来的 lambda 函数,lambda 里面的 emit 都会添加到参数 node
content()
// 恢复 current
current = parent
}
}

使用上面的抽象,可以把 TodoApp 函数以 Composer扩展函数方式重写:

1
2
3
4
5
6
7
8
9
10
fun Composer.TodoApp(items: List<TodoItem>) {
emit(Stack(Orientation.Vertical)) {
for (item in items) {
emit(Stack(Orientation.Horizontal)) {
emit(Text(if (item.completed) "x" else " "))
emit(Text(item.title))
}
}
}
}

接着我们提供一个顶级函数 compose,它 new 了一个 Composer,并在这个 Composer 对象上执行一个 lambda,并把根节点返回:

1
2
3
4
5
fun compose(content: Composer.() -> Unit): Node {
return Stack(Orientation.Vertical).also {
ComposerImpl(it).apply(content)
}
}

当我们想要构建 UI 时,就可以像下面这样使用:

1
2
// render UI
renderNodeToScreen(compose { TodoApp(items) })

把我们的 ToDoApp 重构一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun Composer.TodoItem(item: TodoItem) {
emit(Stack(Orientation.Horizontal)) {
emit(Text(if (item.completed) "x" else " "))
emit(Text(item.title))
}
}

fun Composer.TodoApp(items: List<TodoItem>) {
emit(Stack(Orientation.Vertical)) {
for (item in items) {
TodoItem(item)
}
}
}

这种简单的结构,或者说把常见的 UI 逻辑分解成函数至关重要的特性。我们可以把这些函数称为 “Components”(组件)。

位置化记忆

一些注重性能的家伙可能注意到了,每次执行 compose 都会重新构建整棵树。对于大型应用这种性能浪费是不能容忍的。从正确性的角度来说,如果一个节点有私有状态,每次重建都会导致这些状态丢失。

对应这种问题有很多解决方案, Compose 使用了一种我们称为 “位置化记忆”(Positional Memoization)的技术。Compose 的很多架构都是基于这个概念,接下来让我们更加深入地理解它背后的思维模式。

上节讲了,我们引入 Composer 对象,它持有我们当前在树中的位置和使用 emit 构建视图树节点的上下文。我们的目标是继续使用我们的编程模型,并尝试在视图树重建时复用之前构建 UI 时创建的 Node。基本上,我们想就是想给每个 Node 加上缓存。

大部分缓存需要 key 从缓存中取回对象。从 TodoApp 的例子我们可以发现,每次执行 TodoApp 函数,都以同样的顺序创建了相同数量的 Node。我们假设缓存所有 Node,每次执行 TodoApp 函数时,以相同的顺序访问缓存(如果我们引入条件分支,这个缓存逻辑就失效了,后面会讨论这个情况)。

所以,我们使用 执行顺序 做缓存的 key,可以完全避免用于查找的成本;仅用一个 list 或者数组就可以缓存 node,读取缓存的操作也十分轻量。执行转换函数时维护一个 “当前索引”,每次读取缓存就 +1。

一个简单的实现,给 Composer 新增一个 memo 方法:

1
2
3
4
5
6
interface Composer {
/* emit(...) excluded for brevity */

// 对比 input 和此前在当前位置的元素。如果发生变化,返回 factory 的结果,否则从返回之前的。
fun <T> memo(vararg inputs: Any?, factory: () -> T): T
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// naive implementation. feel free to skip
class ComposerImpl: Composer {
private var cache = mutableListOf<Any?>()
private var index = 0
private val inserting get() = index == cache.size
private fun get(): Any? = cache[index++]
private fun set(value: Any?) {
if (inserting) { index++; cache.add(value); }
else cache[index++] = value
}

private fun <T> changed(value: T): Boolean {
// 插入节点时,不需要比较,直接缓存并返回
return if (inserting) {
set(value)
false
} else {
// 获取当前位置的 item,index++。直接缓存新值,
// 只有在 item 和 value 不同时返回 true
val index = index++
val item = cache[index]
cache[index] = value
item != value
}
}

private fun <T> cache(update: Boolean, factory: () -> T): T {
// 需要更新时,或者第一次执行时,需要执行 factory 并缓存结果
return if (inserting || update) factory().also { set(it) }
// otherwise, just return the value in the cache
else get() as T
}

override fun <T> Composer.memo(vararg inputs: Any?, factory: () -> T): T {
var valid = true
// 需要对所有 input 执行检查,不能短路(译者注:changed() 里面修改了 index,短路就错了)。
for (input in inputs) {
// 任意一个 input 发生了变化,缓存就会失效。
valid = !changed(input) && valid
}
return cache(!valid) { factory() }
}
}

Demo 里只用了 MutableList,实际上 Compose 使用了一维数组的 Gap Buffer,让查找,插入,删除操作更轻量。

注意,如果调用 memo 函数时传入 n 个 input,会增加缓存索引 n+1 次。这需要在给定的位置上,每次调用时都要有相同数量的 input,否则缓存会随时间错位。

现在我们可以用 memo 函数重写 TodoApp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun Composer.TodoItem(item: TodoItem) {
emit(memo { Stack(Orientation.Horizontal) }) {
emit(
memo(item.completed) {
Text(if (item.completed) "x" else " ")
}
)
emit(
memo(item.title) {
Text(item.title)
}
)
}
}

fun Composer.TodoApp(items: List<TodoItem>) {
emit(memo { Stack(Orientation.Vertical) }) {
for (item in items) {
TodoItem(item)
}
}
}

现在,每次执行 compose 都会复用此前创建的 Node,如果没变的话。因为我们是用执行顺序作为缓存依据,我们使用的内存数量没变,编程模型也不变。

在当前示例中,记忆的最小节点是整个 Node,实际上我们可以单独记忆 Node 的属性,支持可变属性。

例如,假设 textText 的可变属性:

1
class Text(var text: String) : Node()

因此,我们可以复用 Text 节点,发生变化时,只更新它的 text 属性。实现这个需要另一个 emit 函数,稍微修改下函数签名:

1
2
3
4
5
6
7
8
9
interface Composer {
/* emit(..) and memo(...) excluded for brevity */

fun <T: Node> emit(
factory: () -> T,
update: (T) -> Unit = {},
block: () -> Unit = {}
)
}
1
2
3
4
5
6
7
8
9
10
11
12
// naive implementation. feel free to skip.
class ComposerImpl(val root: Node) : Composer {
override fun <T: Node> emit(
factory: () -> T,
update: (T) -> Unit = {},
block: () -> Unit = {}
) {
val node = memo(factory)
update(node)
emit(node, block)
}
}

这个版本的 emit 中,emit 使用 factory 创建 Node,并使用 memo 缓存节点。接着在刚刚创建的 Node 实例上执行 update 函数。update lambda 里可以使用 memo 函数设置 Node 的属性,从而实现缓存 Node 的属性。

例如,TodoItem 可以这样重写:

1
2
3
4
5
6
7
8
9
10
11
12
fun Composer.TodoItem(item: TodoItem) {
emit({ Stack(Orientation.Horizontal) }) {
emit(
{ Text() }
{ memo(item.completed) { it.text = if (item.completed) "x" else " " } }
)
emit(
{ Text() }
{ memo(item.title) { it.text = item.title } }
)
}
}

所以,为了让复用性能最佳,我们可以在每次调用 compose 时复用 Node 实例,Node 的属性变化时单独更新属性。

你可能已经注意到了,基于执行顺序记忆存在一个问题。如果在转换函数里引入了条件语句,就坏了。例如:

1
2
3
4
5
6
7
8
9
10
11
12
fun Composer.TodoApp(items: List<TodoItem>) {
emit({ Stack(Orientation.Vertical) }) {
for (item in items) {
TodoItem(item)
}
}
val text = "Total: ${items.size} items"
emit(
{ Text() },
{ memo(text) { it.text = text }}
)
}

这里,假设第一次有两个 item,第二次有三个 item,会出现什么问题?

前两个 Node 没问题。和第一次执行时一样,我们的缓存可以读取到正确的值。

当我们拿第三个 TodoItem 时,由于首次执行时只有两个 item,我们拿到的缓存是带有 "Total: ${items.size} items" 属性的 Text 节点。由于这种错位,后续的缓存都失效了,在 获取 Text 节点的缓存时,会发现不存在,重新创建一个新的实例。

总之,每次控制流导致缓存数量对不上时,或者其它的执行顺序不一致了,我们的缓存就会产生错位,出现未定义行为。

修复这个需要给“位置化记忆”引入另一个至关重要的概念: 组(Group)。

1
2
3
4
5
6
7
interface Composer {
/* emit(...) and memo(...) excluded for brevity */

// start a group, execute block inside that group, end the group
// 新起一个 group,在 group 内执行代码块,结束 group
fun group(key: Any?, block: () -> Unit)
}

实现太复杂了,可惜这里写不下。

这个概念导致 composer 的记忆化缓存实现非常复杂,但它对保证位置化记忆正常工作非常重要。而且,group 使得线性缓存变为树状结构,我们可以由此区分节点是被移动了,删除了还是添加了。

group 需要传入一个 key。这个 key 就和 memo 的 input 参数一样,会被存到缓存数组。但是当 key 和上次执行时不匹配的话,运行时会在缓存里搜索,判断这个 group 是被移动了,删除了还是添加了。

group 的 key 只要在 parent group 的范围内保持唯一就行,不需要全局唯一。好了,让我们使用 group 修改下 TodoApp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
fun Composer.TodoItem(item: TodoItem) {
group(3) {
emit({ Stack(Orientation.Horizontal) }) {
group(4) {
emit(
{ Text() }
{ memo(item.completed) { it.text = if (item.completed) "x" else " " } }
)
}
group(5) {
emit(
{ Text() }
{ memo(item.title) { it.text = item.title } }
)
}
}
}
}

fun Composer.TodoApp(items: List<TodoItem>) {
group(0) {
emit({ Stack(Orientation.Vertical) }) {
for (item in items) {
group(1) {
TodoItem(item)
}
}
}
}

val text = "Total: ${items.size} items"
group(2) {
emit(
{ Text() },
{ memo(text) { it.text = text } }
)
}
}

这里给每个 group 都设置了不同的整数作为 key,TodoItem 也包了一层 group,确保每个 TodoItem 单独记忆。

现在当 items 从 2 变成 3 时,我们可以知道要往缓存里 “加一个” item,而不是去缓存里直接拿下一个,因为超出我们所在的 group 了。当 items 从缓存中移除时同理。

“移动” item 也类似,就是算法有点复杂。就不展开讲了,只说一点,我们是通过子 group 的 key 来识别 group 内的“移动”。如果打乱例子中的 items list,每个 TodoItem 的外层都是 group(1),Composer 没法知道 item 的顺序改变了。问题倒也不算大,也就是缓存的性能不是最优,之前关联的状态被设置到其他 item 上。不过,我们可以使用 item 自己做 key:

1
2
3
4
5
for (item in items) {
group(item) {
TodoItem(item)
}
}

现在,每个 group 和它包含的一组缓存会随 item 而动,TodoItem 可以从之前创建的缓存 group 拿到数据了,虽然移动缓存的开销变大了,但是增加了移动 item 时的复用缓存的可能性。

还有一种 key 可以声明 @Pivotal 属性,我会在以后的文章里讲解。

State

目前为止, 示例仅仅是对数据进行一些简单的转换和映射到 UI。实际场景中,大部分 UI 都由多个状态组合而成,这些状态作为数据模型的一部分没有任何意义,重要的是将状态具现到 UI(比如: “view state”)。例如,文本选择、滚动位置、焦点、对话框可见性等状态,你也得放在数据模型里。这种状态只有 UI 关心,别的都不管。

Compose 需要一种状态模型处理这种“本地状态”。使用位置化记忆就可以很好地处理。

关于状态,让我们用计数器的例子,一个增加按钮,一个展示当前数值的Text,再加一个重置按钮。我们先用一个全局变量 count 开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var count = 0

fun Composer.App(recompose: () -> Unit) {
emit({ Text() }, { memo(count) { it.text = "$count" } })
emit({ Button() }, { it.text = "Increment"; it.onClick = { count++; recompose(); } })
emit({ Button() }, { it.text = "Reset"; it.onClick = { count = 0; recompose(); } })
}

fun main() {
var recompose: () -> Unit = {}
recompose = {
renderNodeToScreen(compose { App(recompose) })
}
recompose()
}

因为是全局变量,如果这个组件在多处复用,都会展示相同的数值。这肯定不是我们想要的效果,需要想个办法把 count 变成本地变量。

该怎么做?

首先尝试将 count 移入 App 函数体:

1
2
3
4
5
6
7
8
// NOTE: 不会正常工作
fun Composer.App(recompose: () -> Unit) {
var count = 0

emit({ Text() }, { memo(count) { it.text = "$count" } })
emit({ Button() }, { it.text = "Increment"; it.onClick = { count++; recompose(); } })
emit({ Button() }, { it.text = "Reset"; it.onClick = { count = 0; recompose(); } })
}

每次函数调用,count 都会被重新初始化。

是不是和 Node 服用时很像,使用我们的位置化记忆工具搞定。事实证明,本地状态也可以用这种办法解决。

1
2
3
4
5
6
7
8
9
class State<T>(var value: T)

fun Composer.App(recompose: () -> Unit) {
val count = memo { State(0) }

emit({ Text() }, { memo(count.value) { it.text = "${count.value}" } })
emit({ Button() }, { it.text = "Increment"; it.onClick = { count.value++; recompose(); } })
emit({ Button() }, { it.text = "Reset"; it.onClick = { count.value = 0; recompose(); } })
}

我们使用 memo 在每次函数调用(在 UI 树的同一位置上)时获取 State 的同一实例。修改状态,并触发视图树重组,渲染更新 State 的值。

关于 @Composable 注解

我们已经在使用这些 `Composer’ 扩展函数 上取得相当大的进展了。在效率和稳定性上都有所建树。

回看示例,有很多模板代码可以系统的添加。我们可以应用一些规则和范式,不用了解应用具体细节,自动添加这些模板代码。

让编译器来处理很合理!为此,Compose 引入 @Composable 注解。它有以下含义:

  1. 函数内,所有调用 Node 子类构造函数的地方外部都包了一层 emit 调用, Node 的属性修改则包了一层 memo 调用。
  2. 所有标记了 @Composable 注解的函数,被调用时外部都包了一层 group 调用。group 的 key 由编译器分配一个调用栈内唯一的整数。
  3. 所有 emit 调用外部都包了一层 group,key 也是调用栈内唯一的整数。
  4. 函数额外接收一个隐式 Composer 参数,而不用写成 Composer 的扩展函数。因为 (1) 和 (2),所有对 Composer 的需求都是隐式的。
  5. 所以,@Composable 标记的函数只能在 @Composable 函数内调用。 因为 (3),需要传递 Composer 这个隐式参数。

综上所述,最终 App 函数如下:

1
2
3
4
5
6
7
8
9
class State<T>(var value: T)

@Composable fun App(recompose: () -> Unit) {
val count = memo { State(0) }

Text("${count.value}")
Button(text = "Increment", onClick = { count.value++; recompose(); })
Button(text = "Reset", onClick = { count.value = 0; recompose(); })
}

TodoApp 也可以重写下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable fun TodoItem(item: TodoItem) {
Stack(Orientation.Horizontal) {
Text(if (item.completed) "x" else " ")
Text(item.title)
}
}

@Composable fun TodoApp(items: List<TodoItem>) {
Stack(Orientation.Vertical) {
for (item in items) {
TodoItem(item)
}
}
Text("Total: ${items.size} items")
}

这可省老事了!虽然 @Composable 注解隐藏了大量内部调用机制,但并没有完全改变我们在调用一个函数式的预期。和 kotlin 协程提供的 suspend 函数机制很像。当然可以用 Future 实现和协程相同的工作,但是围绕 suspend 建立一个新的思维模式,可以少写很多模板代码。

现在,你应该对 @Composable 的实际作用和 Compose 背后的设计有所了解了。

当然还有许多内容没能在本文展现:

  • @Model工作原理。(译者注:本文写于2019年,现在 @Model 已被废弃)。
  • Composable 函数的延迟和并行化。
  • 缓存可用时,跳过 composable 函数执行
  • 指定子树的 Invalidating/recomposing
  • Having @Composable functions that target different types of trees with compile-time safety
  • Optimizing away comparisons of expressions we can determine will never change

All potential future topics!

Let me know if this blog post helped you better understand Compose or not. If it didn’t, let me know what was confusing!

Have followup questions? 本文作者的 Twitter,推荐关注!!

Interested in Compose and want to chat with others about it? Stop by the #compose channel on the Kotlin Slack (get an invite)