Jetpack Compose 重组🔄与甜甜圈洞跳过 🍩

1,097 阅读6分钟

Jetpack Compose 重组🔄与甜甜圈洞跳过 🍩

原文:What is “donut-hole skipping” in Jetpack Compose?

非直接翻译,相对原文内容改动较大。

donut.png

我最近在几次关于 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。另外,在两个可组合项发生重组时都会打印日志。很简单对吧?

让我们运行看看:

Example1.gif

可以观察到,每当计数值发生变化时,MyComponent()CustomText() 都会被重组。

  • CustomText() 被重组很好理解,因为它的参数 text 发生了变化。

  • 为什么要重组 MyComponent() 呢?唯一读取状态 counter 的地方不是 CustomText 吗?那仅仅重组 CustomText() 不是已经足够了吗?

    唯一读取状态counter的地方.png

    虽然看起来唯一读取状态 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 是否发生重组,我们同样在里面添加了日志打印。

独立的可组合作用域.png

来看看运行结果:

Example2.gif

事情开始有趣起来了,每次点击触发重组,Button 的尾 lambda 和 CustomText() 被重新执行,但是 MyComponent() 函数不再执行......🤔

这是因为,按照 UI 树的层级关系,虽然我们仍在 MyComponent() 可组合作用域的内部读取 counter,但是现在我们能从中找到一个更小的可组合范围,即 Button 的尾 lambda,于是它将成为重组的起点:

更小的可组合范围.png

最小重组范围.png

通过确定需要重组的最小子树(最小重组范围),我们避免了重组 MyComponent()Button(),而只需重组 Button 的尾 lambda。

需要注意的是,当我们使用 BoxColumnRow 这类内联函数时,传递给它们的尾 lambda 不能作为最小重组范围。

内联Composable函数.png

🦘 跳过最小子树中的节点

在第二个例子的基础上,我们再把事情变得复杂一些:

@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 的读取。

在查看运行结果之前,你可以先思考一下🤔,重组起点所在的可组合作用域在哪?(最小重组范围)

Example3.gif

我们既在 MyComponent() 直接可组合作用域读取了 counter,也在子可组合作用域(Custom Button Lambda)里读取了 counter,所以需要重组的最小范围是 MyComponent() { ... }

跳过最小子树中的节点.png

这里的关键在于,重组时,没有重新调用最小子树内的所有节点,Compose 能跳过其中不需要重组的节点,这正是 Compose 的独特和智能之处 ✨,因为其他大多数的声明式 UI 框架都会识别需要重组的最小子树,并重新调用整个子树,而不会跳过该子树中的任何节点。

🍩甜甜圈洞跳过

还记得你打开这篇文章的原因吗?... 是的,🍩 甜甜圈呢?这一切和甜甜圈有什么关系?

甜甜圈这个词来自于 Web 开发,甜甜圈缓存(Donut Caching)是一种缓存策略,用于缓存页面的大部分内容,但允许在缓存的页面中包含一些动态、不缓存的内容。其原理类似于一个甜甜圈(Donut),其中大部分是缓存的内容(甜甜圈的主体),而中间的孔则是动态内容(实时更新的数据)。除了甜甜圈缓存,还有个甜甜圈洞缓存(Donut Hole Caching),这是一种更加细粒度的缓存策略,与 Donut Caching 类似,它也是缓存大部分页面内容,但它更侧重于“孔”的部分,即动态内容的处理。

我们把这个概念偷到 Jetpack Compose 里面,又该怎么去理解“甜甜圈洞跳过(Donut Hole Skipping)”呢?现在我要说一些可能让你大吃一惊的事情:可组合函数可以被认为是由甜甜圈组成的,而甜甜圈内部又由更小的甜甜圈组成。这是 Compose 团队用来描述和重组相关的一种比喻,像 Button 这种可组合函数,它的函数本身(函数体)可以表示为甜甜圈的主体,而中间的洞则表示拥有 Composable 上下文环境的函数类型参数 content,这两部分可以独立地进行重组。

甜甜圈.png

假设 Button 的输入参数没发生变化、函数体也没有读取改变的状态,而 Button Lambda 中读取了改变的状态,那么重组时,就只会执行甜甜圈中间的“洞”的部分,跳过甜甜圈的主体。

MyComponentText 这类可组合函数,因为它没有中间“洞”的部分,所以看起来更像一个“饼”。

饼.png

让我们以可视化的角度看一下示例 3 中的可组合项,看看它们是如何被重组的。显示为网格的部分代表发生了组合。

组合前.png

counter=0.png

首次组合,所有节点都被执行。

counter=1.png

随后点击触发的重组,MyComponent { ... } 被确定为最小重组范围,但是 Compose 智能地跳过了最小子树中不需要重组的节点(即 Custom Button),这种智能优化就被称作 donut-hole skipping(甜甜圈洞跳过)。


关联阅读:

[StackOverflow] Jetpack Compose Scoped/Smart Recomposition

[Github] Scoped Recomposition