Jetpack Compose 重组🔄与甜甜圈洞跳过 🍩
原文:What is “donut-hole skipping” in Jetpack Compose?
非直接翻译,相对原文内容改动较大。
我最近在几次关于 Jetpack Compose 重组的讨论中偶然听到一个术语——"donut-hole skipping"(甜甜圈洞跳过),这个名字燃起了我的兴趣,让我忍不住想深入了解,这个甜甜圈 🍩 到底是指什么呢?和 Jetpack Compose 重组又有什么关系?在深入更有趣的内容之前,我们先来了解一些基本的概念,让大家都站在同一起跑线上。
🔄重组 Recomposition
重组是指当输入参数发生变化时再次调用可组合函数的过程。Compose 在基于新的输入参数进行重组时,它会调用可能已经发生变化的函数或 lambda,并跳过其余那些(没有变化的)。通过跳过执行那些参数没有变化的函数和 lambda,Compose 能更高效地完成重组 🚀
从整体上看,每当 Composable 函数的输入参数或状态发生变化时,重新调用该函数是非常有必要的,只有这样才能在界面上显示最新的数据。这种机制对 Jetpack Compose 的工作原理至关重要,也正是这种响应式特性使其变得强大。
重新调用 Composable 函数就是由重组所负责的,不过,与其他的响应式 UI 框架相比较,Compose 更智能💡,因为它会通过智能优化尽可能地避免冗余的工作,而且这一切都是自动进行的,完全不需要开发者干预。废话不多说,我们直接来看一些实际的重组例子,🔍 看看我们是否能从中找到前面提到的“智能优化” 。
由于我们后面的代码是在探索重组,为了直观地观察重组情况,所以我们先定义一个便捷函数,用于在函数调用处打印重组的次数:
class Ref(var value: Int)
@Composable
// 注意这是一个 inline 函数,这确保了日志是在函数调用处直接打印的,实际上没有到跳转到别的函数里面
inline fun LogCompositions(msg: String) {
val ref = remember { Ref(0) }
SideEffect { ref.value++ }
Log.d("Compositions", "$msg: ${ref.value}")
}
如果你不太清楚 SideEffect
,只需简单地理解:它会在当前可组合作用域每次组合完成后执行 lambda 里的代码。
❓ 为什么 MyComponent()
会被重组?
@Composable
fun MyComponent() {
LogCompositions("MyComponent Function Scope")
var counter by remember { mutableIntStateOf(0) }
CustomText(
text = "Counter: $counter",
modifier = Modifier.clickable { counter++ },
)
}
@Composable
fun CustomText(text: String, modifier: Modifier = Modifier) {
LogCompositions("CustomText function Scope")
Text(
text = text,
style = MaterialTheme.typography.headlineMedium,
fontFamily = FontFamily.Serif,
modifier = modifier.padding(12.dp),
)
}
首先我们创建一个简单的可组合项 MyComponent
,里面先用一个状态对象 counter
来保存计数值,并通过 CustomText
来展示,每次点击时将计数值加 1。另外,在两个可组合项发生重组时都会打印日志。很简单对吧?
让我们运行看看:
可以观察到,每当计数值发生变化时,MyComponent()
和 CustomText()
都会被重组。
-
CustomText()
被重组很好理解,因为它的参数text
发生了变化。 -
为什么要重组
MyComponent()
呢?唯一读取状态counter
的地方不是CustomText
吗?那仅仅重组CustomText()
不是已经足够了吗?虽然看起来唯一读取状态
counter
的地方是CustomText("Counter: $counter")
,但实际上,实参"Counter: $counter"
会先作为表达式被执行,然后才是CustomText()
函数被调用。当实参作为表达式被执行时,其所在的 Composable 作用域正是MyComponent()
的函数体,换句话说,MyComponent()
函数体内读取了状态counter
。既然MyComponent()
内部读取了状态counter
,当状态改变时,MyComponent()
显然应该被重新调用。
🎯 确定最小重组范围
@Composable
fun MyComponent() {
LogCompositions("MyComponent Function Scope")
var counter by remember { mutableIntStateOf(0) }
+ Button(onClick = { counter++ }) {
+ LogCompositions("Button Lambda Scope")
CustomText(
text = "Counter: $counter",
- modifier = Modifier.clickable { counter++ },
)
+ }
}
@Composable
fun CustomText(text: String, modifier: Modifier = Modifier) {
LogCompositions("CustomText Function Scope")
Text(text = text, ...)
}
我们稍微改造一下第 1 个例子:
- 使用
Button
可组合项来处理点击逻辑; - 将
CustomText
的调用移到Button
尾 lambda 里面。
这里需要注意的是,Button 重组 ≠ 尾 lambda(即参数 content
) 重组,二者是两个独立的可组合作用域。为了观察 Button 尾 lambda 是否发生重组,我们同样在里面添加了日志打印。
来看看运行结果:
事情开始有趣起来了,每次点击触发重组,Button 的尾 lambda 和 CustomText()
被重新执行,但是 MyComponent()
函数不再执行......🤔
这是因为,按照 UI 树的层级关系,虽然我们仍在 MyComponent()
可组合作用域的内部读取 counter
,但是现在我们能从中找到一个更小的可组合范围,即 Button 的尾 lambda,于是它将成为重组的起点:
通过确定需要重组的最小子树(最小重组范围),我们避免了重组 MyComponent()
与 Button()
,而只需重组 Button 的尾 lambda。
需要注意的是,当我们使用
Box
、Column
、Row
这类内联函数时,传递给它们的尾 lambda 不能作为最小重组范围。
🦘 跳过最小子树中的节点
在第二个例子的基础上,我们再把事情变得复杂一些:
@Composable
fun MyComponent() {
LogCompositions("MyComponent Function Scope")
var counter by remember { mutableIntStateOf(0) }
+ val readingCounter = counter
+ CustomButton(onClick = { counter++ }) {
- Button(onClick = { counter++ }) {
LogCompositions("CustomButton Lambda Scope")
CustomText(text = "Counter: $counter")
}
}
@Composable
private fun CustomText(text: String, modifier: Modifier = Modifier) {
LogCompositions("CustomText Function Scope")
Text(text = text, ...)
}
+ @Composable
+ private fun CustomButton(onClick: () -> Unit, content: @Composable () -> Unit) {
+ LogCompositions("CustomButton Function Scope")
+ Button(onClick = onClick, shape = RectangleShape) {
+ LogCompositions("Button Lambda Scope")
+ content()
+ }
+ }
这一次我们把 Button()
换成自己写的 CustomButton()
,另外,在 MyComponent()
的直接可组合作用域内添加了对状态 counter
的读取。
在查看运行结果之前,你可以先思考一下🤔,重组起点所在的可组合作用域在哪?(最小重组范围)
我们既在 MyComponent()
直接可组合作用域读取了 counter
,也在子可组合作用域(Custom Button Lambda)里读取了 counter
,所以需要重组的最小范围是 MyComponent() { ... }
。
这里的关键在于,重组时,没有重新调用最小子树内的所有节点,Compose 能跳过其中不需要重组的节点,这正是 Compose 的独特和智能之处 ✨,因为其他大多数的声明式 UI 框架都会识别需要重组的最小子树,并重新调用整个子树,而不会跳过该子树中的任何节点。
🍩甜甜圈洞跳过
还记得你打开这篇文章的原因吗?... 是的,🍩 甜甜圈呢?这一切和甜甜圈有什么关系?
甜甜圈这个词来自于 Web 开发,甜甜圈缓存(Donut Caching)是一种缓存策略,用于缓存页面的大部分内容,但允许在缓存的页面中包含一些动态、不缓存的内容。其原理类似于一个甜甜圈(Donut),其中大部分是缓存的内容(甜甜圈的主体),而中间的孔则是动态内容(实时更新的数据)。除了甜甜圈缓存,还有个甜甜圈洞缓存(Donut Hole Caching),这是一种更加细粒度的缓存策略,与 Donut Caching 类似,它也是缓存大部分页面内容,但它更侧重于“孔”的部分,即动态内容的处理。
我们把这个概念偷到 Jetpack Compose 里面,又该怎么去理解“甜甜圈洞跳过(Donut Hole Skipping)”呢?现在我要说一些可能让你大吃一惊的事情:可组合函数可以被认为是由甜甜圈组成的,而甜甜圈内部又由更小的甜甜圈组成。这是 Compose 团队用来描述和重组相关的一种比喻,像 Button
这种可组合函数,它的函数本身(函数体)可以表示为甜甜圈的主体,而中间的洞则表示拥有 Composable 上下文环境的函数类型参数 content
,这两部分可以独立地进行重组。
假设 Button
的输入参数没发生变化、函数体也没有读取改变的状态,而 Button Lambda 中读取了改变的状态,那么重组时,就只会执行甜甜圈中间的“洞”的部分,跳过甜甜圈的主体。
像 MyComponent
、Text
这类可组合函数,因为它没有中间“洞”的部分,所以看起来更像一个“饼”。
让我们以可视化的角度看一下示例 3 中的可组合项,看看它们是如何被重组的。显示为网格的部分代表发生了组合。
首次组合,所有节点都被执行。
随后点击触发的重组,MyComponent { ... }
被确定为最小重组范围,但是 Compose 智能地跳过了最小子树中不需要重组的节点(即 Custom Button),这种智能优化就被称作 donut-hole skipping(甜甜圈洞跳过)。
关联阅读: