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{} 进行复杂计算。遵照最佳实践可以使你无需关心组件是在何时以何种顺序进行重组。

相关推荐
openinstall全渠道统计3 小时前
免填邀请码工具:赋能六大核心场景,重构App增长新模型
android·ios·harmonyos
双鱼大猫3 小时前
一句话说透Android里面的ServiceManager的注册服务
android
双鱼大猫4 小时前
一句话说透Android里面的查找服务
android
双鱼大猫4 小时前
一句话说透Android里面的SystemServer进程的作用
android
双鱼大猫4 小时前
一句话说透Android里面的View的绘制流程和实现原理
android
双鱼大猫4 小时前
一句话说透Android里面的Window的内部机制
android
双鱼大猫5 小时前
一句话说透Android里面的为什么要设计Window?
android
双鱼大猫5 小时前
一句话说透Android里面的主线程创建时机,frameworks层面分析
android
苏金标5 小时前
android 快速定位当前页面
android
雾里看山9 小时前
【MySQL】内置函数
android·数据库·mysql