不少初学Compose的同学都会对Composable的Recomposition(官方文档中文译为"重组",而我更喜欢称为“重绘”)心生顾虑,担心大范围的Recomposition是否会影响性能。
Recomposition skips as much as possible
When portions of your UI are invalid, Compose does its best to recompose just the portions that need to be updated. – https://developer.android.com/jetpack/compose/mental-model#skips
如上,其实Compose的Compiler在编译期做了大量工作,保证了Recomposition的范围尽可能的小,从而避免无效开销。
那么Recomposition的范围究竟是怎样的呢?我们通过一个例子来了解一下
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Log.d(TAG, "Foo")
Button(onClick = {
text = "$text $text"
}.also { Log.d(TAG, "Button") }) {
Log.d(TAG, "Button content lambda")
Text(text).also { Log.d(TAG, "Text") }
}
}
如上,当点击button时,State的变化会触发Recomposition。
先不公布答案,大家先想想此时的日志输出是怎样的。
…
答案在文章最后,与你的判断是否一致?
Compose如何确定重绘范围?
- Compose在编译期分析出访问某state的代码,并记录其引用。当此state变化时,会根据引用找到这受影响的代码并标记为Invalid,在下一帧到来之前参与到Recomposition中。
- 可以被标记为Invalide的代码一般是一个 @Composable的非inline且无返回值的lambda或者function
- Invalid代码遵循影响范围最小化原则;
了解了Compose重绘范围的原则之后,我们试着解答一下下面的问题:
为什么必须是非inline且无返回值(返回Unit)?
- 对于inline函数,由于在编译期在调用方中展开,因此无法再下次重绘时找到合适的调用入口,只能共享调用方的重绘范围
- 对于有返回值的函数,新的返回值会影响调用方,因此无法单独重绘,必须连同调用方一同参与重绘。
为什么不只是Text
参与重绘?
了解了Compose的重绘的原则之后,再回看上面的例子,当点击button后,MutableState发生变化,代码中唯一访问这个state的地方是Text(...)
,为什么不是重绘范围不只是Text(...)
,而是button的整个lambda?
首先要理解出现在Text(...)
参数中的text
实际上是一个表达式
我们知道
println(“hello” + “world”)
与
val arg = “hello” + “world”
println(arg)
在执行顺序上是等价的:总是“hello” + “world”
作为表达式先执行,然后才调用println
方法。
在上面例子中,参数text
作为表达式执行的调用处是Button的lambda,之后才传入Text()。所以此时最小重回范围是Button的lambda而不只是Text
Foo
是否参加重绘
按照范围最小化原则, Foo中没有任何对state的访问,所以很容易理解Foo不应该参与重绘。
有一点需要注意的是,例子中Foo通过by
的代理方式声明text
,如果改为=
直接为text赋值呢?
@Composable fun Foo() {
val text: MutableState<String> = remember { mutableStateOf("") }
Button(onClick = {
text = "$text $text"
}) {
Text(text.value)
}
}
答案是一样的,仍然不会参与重绘。
第一,Compose关心的是代码块中是否有对state的read,而不是write。
第二,即使使用=
,也不代表text的对象会发生变化,text指向的MutableState实例是永远不会变的,变得只是内部的value
为什么Button
不参与重绘?
这个很好解释,Button的调用方Foo不参与重绘,Button自然也不会参与重绘,只对lambda重绘即可。
Button
的onClick
是否参与重绘?
重绘对象只能是@Composable的方法或lambda上,onClick是一个普通lambda,与重绘逻辑无关。
务必注意!重绘中的Inline陷阱!
前面讲了,只有非inline函数才有资格成为重绘的最小范围,理解这点特别重要!
我们将代码稍作改动,为Text()
包裹一个Box{...}
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text $text" }) {
Log.d(TAG, "Button content lambda")
Box {
Log.d(TAG, "Box")
Text(text).also { Log.d(TAG, "Text") }
}
}
}
上面的日志如下:
D/Compose: Button content lambda
D/Compose: Boxt
D/Compose: Text
为什么重绘范围不是从Box开始?
Column
、Row
、Box
甚至是Layout
这类容器类Composable都是inline
函数,因此它们只能共享调用方的Scope,也就是Button的lambda。
如果你希望缩小重绘范围,提高性能怎么办?
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text $text" }) {
Log.d(TAG, "Button content lambda")
Wrapper {
Text(text).also { Log.d(TAG, "Text") }
}
}
}
@Composable
fun Wrapper(content: @Composable () -> Unit) {
Log.d(TAG, "Wrapper recomposing")
Box {
Log.d(TAG, "Box")
content()
}
}
如上,自定义非inline函数,使之满足Compose重绘范围最小化条件。
总结
Just don’t rely on side effects from recomposition and compose will do the right thing
– Compose Team
关于Recomposition的具体规则官方文档中没有做过多说明。因为开发者只需要牢记Compose的编译期优化保证了Recomposition永远按照最合理的方式运行,以最自然的方式开发就好了,无需再付出额外的理解成本。
尽管如此,作为开发者仍要谨记一点:
不要直接在Composable中写包含副作用(SideEffect)的逻辑!
副作用是不能跟随Recomposition反复执行的,而纯洁的Composable,无论启动编译期优化与否,在功能的正确性上都不会有问题。
你不能预设某个function/lambda一定不参与重绘,因而在里面执行某些副作用。因为我们不确定这里是否存在“inline陷阱”,即使能确定也不能保证现在的优化规则在未来不会变化。
所以最安全的做法是,将副作用写到LaunchedEffect{}
、DisposableEffect{}
、SideEffect{}
中,并且使用remeber{}
、derivedStateOf{}
处理耗时的、不适合反复进行的运算。
FIN
开头例子的日志:
D/Compose: Button content lambda
D/Compose: Text