Jetpack Compose 的范围重组

Original article # Scoped recomposition in Jetpack Compose --- what happens when state changes?

首先我们思考一个问题,在下面的代码中,那一个函数会在下一次 composition 的流程中被调用?

kotlin 复制代码
@Composable
fun Foo() {
	var text by remember { mutableStateOf("") }

	Button(onClick = { text = "$text\n$text"}) {
		Text(text)
	}
}
  1. Foo
  2. Button
  3. Button's content lambda
  4. Text

Show me the answer!

这个问题的答案是有歧义的,当用不同的方式解释这些选项的时候可能得出不同的结果。

最重要的,也是被 Compose 团队成员提到最多次的:

也就是说答案其实不重要,只要你遵守 Compose 的最佳实践(不要依赖重组产生的副作用!!),那你的代码就能生效,不用管 Compose 框架在背后做了一些啥优化。

这是一个很棒的特性,但是对于在了解新框架的过程,对于一些幕后工作窥探一二通常是一个更好的手段。

High-level analysis

当你写下这个代码并尝试进行梳理你的代码会做什么(例如Button内部时怎么运行的),那么你更可能会得出第三个答案,Button 的 content lambda。

  1. 当你点击这个按钮,文本的 MutableState 被修改了。
  2. 唯一会使用这个 state 对象的函数就是这个按钮的 content lambda,所以这个 content lambda 被 invalidated 了。
  3. 被 invalidated 的 content lambda 最终会使用新的 text 值重新执行,将这个值设置到 Text 组件上。因为 text 变了,所以 Text 组件也会再重新组合。

Some fundamentals

在深入这段代码之前,我们先过一些基本概念。

Recompose Scope

Recompose scopes 是 Compose 框架的一个重要部分。它们会进行一些状态保存工作,并帮助减少 Compose 在准备下一帧时的工作量。

  • 它们是重组过程中更新底层树的最小单元
  • 它们会跟踪基于 SnapShotState 对象的数据,当数据转换时自动标记为 invalidated

对于任意的非内联并且返回 Unit 的 composable 函数,Compose 编译器会生成包裹该函数体的 recompose scope。当一个 recompose scope 失效了,compose runtime 会保证这个函数体在下一帧之前执行重组(recompose)。函数是可充值行代码的天然分割符,他们已经有了明确定义的入口和出口。

Foo 的函数体、Button 的函数体,传给 Button 的 lambda,Text 的函数体都有他们自己所对应的 recompose scope。 那么对于那些行内函数又或者是返回一个值的表达式呢?

Expression and function calls

在探讨 Compose 本身的细节之前,我们需要回忆一些传统的函数调用的基本知识。在 Java/Kotlin 中,当你将一个表达式当作参数传递给一个函数,这个表达式会在调用方进行求值。

kotlin 复制代码
println("hello" + "world")

基本相当于

kotlin 复制代码
val arg = "hello" + "world"
println(arg)

一个函数调用的所有参数都需要在 JVM/ART 调用这个函数前求值,这样才能将这些表达式的记过传递给被调用的函数。你可以尝试调用 println(TODO()) 来验证这个行为------println 会被提示为不可达代码,因为 TODO() 不会返回,所以 println 函数永远不会走到。

在最早的代码片段中,这意味着编译器会将读取 text 状态的代码生成在传入的 lambda 中,然后将结果传给 Text 组件。Compose 编译器并不知道这个值只会在这里用到,起码到目前为止不行。这个可能可以通过数据流分析做到,但当前的 Compose 还没有那么的极致。它只能知道这个 content lambda 会读 text

Show your work

我们重新看一遍这段代码:

kotlin 复制代码
@Composable
fun Foo() {
	var text by remember { mutableStateOf("") }

	Button(onClick = { text = "$text\n$text"}) {
		Text(text)
	}
}

为什么不是只有 Text 进行重组?

我们可以这样想,对 text的读取操作就在对 Text 组件调用的前一行,那么最近的包含它的 recompose scope 就是这个按钮的 content lambda。那这个就是当 text state 更新时回去 invalidate 的 recompose scope。

所以当 text 被修改的时候,这个 lambda 的 recompose scope 失效了,整个函数会在 recompose 流程中重新执行。

为什么不是整个Foo 重新组合?

我们之前讨论了recompose scope 是怎么分割和怎么失效的,我们可以看到 Foo 的 recompose scope 内没有部分直接读取相关的 State, 所以 Foo 实际上永远不会进行重新组合。

如果我们将 text 的委派语法去掉,这个行为可能更显而易见,但这不会改变任何的行为:

kotlin 复制代码
@Composable
fun Foo() {
	val text: MutableState<String> = remember { mutableStateOf("") }

	Button(onClick = { text.value = "${text.value}\n${text.value}"}) {
		Text(text.value)
	}
}

现在我们可以更清楚的看到,Foo 里面对 text 的操作只是创建了一个 MutableState 的持有变量。这个被 text 所引用的对象是没有被改变的,它一直都指向这个 MutableState 的实例。当我们说到变量读取的时候,我们实际是在说对这个 Statevalue 属性的读取,就像是 text.value

为什么 Button 没有被重组

因为 Foo 没有被重组,Button 在重组流程中没有人会调用 Button

然而,Button 函数的实现中,可能会有部份被重组,但这些部份不会影响我们的代码。例如,Button 会维护一些变量表示它被按下,或是触发 ripple。但实际上可能它们不依赖重组,只需要进行重新绘制即可。

onClick lambda ?

重组的 scope 只会围绕 composable 函数。ButtononClick 这种事件的 handler 并不是一个 composable 函数。所以当点击事件触发的时候,这个 lambda 会像是普通的函数调用一样在 recompose 流程之外处理。

!!Inline composable functions

ColumnRowBox 这种基本的排版函数都是内联函数。内联函数都会被直接拷贝到调用的位置,所以被内联函数包裹的函数体不会创建单独的 recompose scope。在下面的 case 中,当 Wrapper 的 content lambda 需要进行重组的时候,因为 recompose scope 会选择最近的外层的 recompose scope。因为 Wrapper 是内联的,所以整个 App 都会执行重组。所以任何情况下,这段代码的输出永远会是 "App recomposing", "Wrapper recomposing", "Lambda recomposing" 同时出现。

kotlin 复制代码
@Composable fun App() {
	println("App recomposing")
	Wrapper {
		println("Lambda recomposing")
		// read some state causing recomposition...
	}
}

@Composable inline fun Wrapper(content: @Composable () -> Unit) {
	println("Wrapoper recomposing")
	content()
}

所以,同样的,如果我们一开始那段代码中的 Text 被包裹在一个 Column 中,那么进行重组的范围依然还是整个 Button 的 content lambda,而不是 Column 的 lambda。

Final thoughts

这个意味着在一些代码中,我们可能写出比预期中调用更多重组的代码。而且通常只通过看代码很带确定哪些部份会被重组,因为有些函数是内联的。

而且这些行为规则可能会在未来被修改。Compose 团队可能会去修改这些规则,当然他们会保证"正确性" (correctness)。所有的被跳过的函数只是为了优化,而不应该影响程序的预期行为。所以这个也是为什么不要直接在 composable 函数中直接产生副作用的原因。优先使用 compose 提供的 API, LaunchedEffectDisposableEffectSideEffect。使用 remember{ } 或者 derivedStateOf{} 进行复杂计算。遵照最佳实践可以使你无需关心组件是在何时以何种顺序进行重组。

相关推荐
雨白13 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk13 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING14 小时前
RN容器启动优化实践
android·react native
恋猫de小郭16 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker1 天前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴1 天前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭1 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab1 天前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe2 天前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农2 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos