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)
}
}
Foo
Button
Button
's content lambdaText
Show me the answer!
这个问题的答案是有歧义的,当用不同的方式解释这些选项的时候可能得出不同的结果。
最重要的,也是被 Compose 团队成员提到最多次的:
也就是说答案其实不重要,只要你遵守 Compose 的最佳实践(不要依赖重组产生的副作用!!),那你的代码就能生效,不用管 Compose 框架在背后做了一些啥优化。
这是一个很棒的特性,但是对于在了解新框架的过程,对于一些幕后工作窥探一二通常是一个更好的手段。
High-level analysis
当你写下这个代码并尝试进行梳理你的代码会做什么(例如Button
内部时怎么运行的),那么你更可能会得出第三个答案,Button
的 content lambda。
- 当你点击这个按钮,文本的
MutableState
被修改了。 - 唯一会使用这个 state 对象的函数就是这个按钮的 content lambda,所以这个 content lambda 被 invalidated 了。
- 被 invalidated 的 content lambda 最终会使用新的 text 值重新执行,将这个值设置到
Text
组件上。因为 text 变了,所以Text
组件也会再重新组合。
Some fundamentals
在深入这段代码之前,我们先过一些基本概念。
Recompose Scope
Recompose scopes 是 Compose 框架的一个重要部分。它们会进行一些状态保存工作,并帮助减少 Compose 在准备下一帧时的工作量。
- 它们是重组过程中更新底层树的最小单元
- 它们会跟踪基于 SnapShot 的
State
对象的数据,当数据转换时自动标记为 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
的实例。当我们说到变量读取的时候,我们实际是在说对这个 State
的 value
属性的读取,就像是 text.value
。
为什么 Button 没有被重组
因为 Foo
没有被重组,Button
在重组流程中没有人会调用 Button
。
然而,Button
函数的实现中,可能会有部份被重组,但这些部份不会影响我们的代码。例如,Button
会维护一些变量表示它被按下,或是触发 ripple。但实际上可能它们不依赖重组,只需要进行重新绘制即可。
onClick lambda ?
重组的 scope 只会围绕 composable 函数。Button
的 onClick
这种事件的 handler 并不是一个 composable 函数。所以当点击事件触发的时候,这个 lambda 会像是普通的函数调用一样在 recompose 流程之外处理。
!!Inline composable functions
Column
、Row
、Box
这种基本的排版函数都是内联函数。内联函数都会被直接拷贝到调用的位置,所以被内联函数包裹的函数体不会创建单独的 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, LaunchedEffect
、DisposableEffect
和 SideEffect
。使用 remember{ }
或者 derivedStateOf{}
进行复杂计算。遵照最佳实践可以使你无需关心组件是在何时以何种顺序进行重组。