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

相关推荐
无极程序员43 分钟前
PHP常量
android·ide·android studio
萌面小侠Plus2 小时前
Android笔记(三十三):封装设备性能级别判断工具——低端机还是高端机
android·性能优化·kotlin·工具类·低端机
慢慢成长的码农2 小时前
Android Profiler 内存分析
android
大风起兮云飞扬丶2 小时前
Android——多线程、线程通信、handler机制
android
L72562 小时前
Android的Handler
android
清风徐来辽2 小时前
Android HandlerThread 基础
android
HerayChen3 小时前
HbuildderX运行到手机或模拟器的Android App基座识别不到设备 mac
android·macos·智能手机
顾北川_野3 小时前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java
hairenjing11233 小时前
在 Android 手机上从SD 卡恢复数据的 6 个有效应用程序
android·人工智能·windows·macos·智能手机
小黄人软件4 小时前
android浏览器源码 可输入地址或关键词搜索 android studio 2024 可开发可改地址
android·ide·android studio