2023.04.26补充
别看这篇!别看这篇!建议直接去学习compose的重组原理和机制!
Jetpack Compose 如何确定重组范围
Compose的重组是智能的,遵循最小化重组原则。当State变化时,只有需要变化的UI会发生重组,而其他不必要的重组则被跳过,以提高性能。那么,这个所谓的最小范围到底是什么?当State变化时,到底哪些代码会重新执行?
文章仅仅是个人学习心得与体会的记录,不保证观点正确性。
参考
《JetPack Compose 从入门到实战》 王鹏、关振智、曾思淇著 机械工业出版社
概念
为了方便后面的说明,这里解释一些概念(这些概念只是为了方便后面说明,不是官方概念)。
代码块 后文如无特别说明,代码块都是带有@Composable注解的
重组 在State变化时,UI需要同步更新,Compose中这一过程就称为重组
重组的入口点 重组时肯定要对相关代码块再调用一次,那想要调用代码块就必须要有入口点
重组的范围 即哪个代码块会发生重组
确定重组范围的起点 直接受State变化影响的代码块(即直接含有State的代码块)
结论
最终发生重组的代码块由以下规则确定(事实不是这个流程,但是这个流程能够判断哪些代码块会发生重组):
1、确定最终重组范围的起点从受State变化影响的代码块开始。
2、实际发生重组的入口点一定是非inline且无返回值的Composable函数或lambda块。
3、实际发生重组的最终范围由起点开始,向外层寻找到可发生重组的最小入口点,然后在入口所在的代码块范围内进行重组
原理
经Compose编译器处理后的Composable代码在对State进行读取的同时,能够自动建立关联,在运行时,若State变化,则Compose会找到关联的代码块,标记为invalid,下一渲染帧到来之前,Compose会触发重组并执行invalid代码块。
这个invalid代码块就是结论中提到的非inline且无返回值的Composable函数或lambda块。
为什么必须是非inline且无返回值的代码块?
- 在编译期,inline代码块的内容就会展开在调用处,因而下次重组时无法找到合适的入口点。
- 意思就是,重组某个代码块相当于重新调用一次这个代码块,那么,调用肯定有一个入口,而一旦被标记为inline,实际上这个代码块运行时就不存在了,而是已经转换为了一段在调用入口处的代码片段。
- 如果函数有返回值,那么每次重组的返回值一旦不一样,一定会对调用方产生影响(副作用),所以不能单独作为
invalid代码块参与重组。
接下来分析两个案例。
案例1
@Composable
fun Test() {//block1
Log.d(TAG, "Test: 1")
var count by remember {
mutableStateOf(0)
}
Column {//block2
Log.d(TAG, "Test: 2")
Button(onClick = {//block-onclick
Log.d(TAG, "Test: onClick")
count++
}) {//block3
Log.d(TAG, "Test: 3")
Text(text = "+")
}
Text(text = "$count")
}
}
上述代码在初始化时会输出
2022-09-29 16:30:46.991 6873-6873/ptq.mpga.pinance D/MainActivity: Test: 1
2022-09-29 16:30:46.997 6873-6873/ptq.mpga.pinance D/MainActivity: Test: 2
2022-09-29 16:30:47.079 6873-6873/ptq.mpga.pinance D/MainActivity: Test: 3
而在点击Button以后会输出
2022-09-29 16:32:08.334 6873-6873/ptq.mpga.pinance D/MainActivity: Test: onClick
2022-09-29 16:32:08.355 6873-6873/ptq.mpga.pinance D/MainActivity: Test: 1
2022-09-29 16:32:08.356 6873-6873/ptq.mpga.pinance D/MainActivity: Test: 2
分析
在初始化时,block1、2、3都是@Composable的,第一遍显示时,三个block都会执行,log符合预期。
上述代码哪些部分会发生重组呢?
当我们点击按钮后,按照前述,我们首先要寻找起点,即State出现的地方。其次再要寻找非inline且无返回值的Composable函数或lambda块作为入口点,那么看一下代码。
首先block2肯定是会被重组到的,因为在block2中直接出现了State,即count变量。那么按照最小重组原则,其他地方没有出现State,则重组只发生在block2中,奇怪,为什么log表示block1也参与了重组?
让我们来看看属于Column参数的block2的定义。
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
)
好吧,block2是inline的,那么,它并不符合重组入口点的条件,那么,没办法了,只能向上寻找,让block1也发生重组了。
看看block1吧,它是符合条件的,它就是我们自己写的代码块,是Composable且不是inline的,因此它可以作为重组的入口点。
注意: Text(text = "$count")
这行代码并非Text读取count,而是block2读取count,count作为参数传入Text
案例2
@Composable
fun Test() {//block1
Log.d(TAG, "Test: 1")
var count by remember {
mutableStateOf(0)
}
Column {//block2
Log.d(TAG, "Test: 2")
Button(onClick = {//block-onclick
Log.d(TAG, "Test: onClick")
count++
}) {//block3
Log.d(TAG, "Test: 3")
Text(text = "+")
}
Text(text = "$count")
Box {//block7
Log.d(TAG, "Test: 7")
}
}
Box {//block4
Log.d(TAG, "Test: 4")
Column {//block6
Log.d(TAG, "Test: 6")
Row {//block8
Log.d(TAG, "Test: 8")
Card {//block9
Log.d(TAG, "Test: 9")
Box {//block10
Log.d(TAG, "Test: 10")
Text(text = "$count")
}
}
}
}
}
Log.d(TAG, "Test: 5")
}
结果是,当点击Button时,除了log3不会打印出来,其他都会。
而当把block10的Text组件给注释了,则log9和log10都不会打印。
分析
按照之前的结论,重组的起点有两个,一个是block2,一个是block10,因为他们是直接包含count这个State的最小代码块。
重组的入口点则是block1和block9,因为block2对应的函数Column是inline的,block10对应的函数Box也是inline的,因此分别向外层寻找最小入口点,即block1和block9。
那么现在,点击Button后,onClick触发,然后从入口点开始,block1范围内会发生重组,log1打印,而block2和block4都是inline的,因此它们和block1共享重组范围,因而它们也会发生重组,log2和log4打印。
先看block2,Button并不是inline的,因此block3不发生重组。而Box是inline的,因此block7发生重组,log7打印。
再看block4,Column、Row都是inline的,因此它们都共享重组范围,因此block6、block8发生重组,log6、log8打印。
但是,Card并非inline,因此block9不与block1、block4、block6、block8共享重组范围。但是,据刚才的分析,block9本身就是一个重组的入口点,因此,block9内部也会发生重组,log9打印,而Box也是inline的,因此block9和block10共享重组范围,block10也发生重组,log10打印。
而当把block10内的Text组件注释以后,Card的block9不再作为入口点,而block9因为非inline又不与外层共享重组范围,因而block9内部都不会发生重组。
案例3
@Composable
fun Test() {//block1
Log.d(TAG, "Test: 1")
var count by remember {
mutableStateOf(0)
}
Column {//block2
Log.d(TAG, "Test: 2")
Button(onClick = run {
Log.d(TAG, "Test: onClick1")
return@run {
Log.d(TAG, "Test: onClick2")
count++ }
}) {//block3
Log.d(TAG, "Test: 3")
Text(text = "+")
}
Text(text = "$count")
}
}
上述代码在初始化时会输出
2022-09-29 16:34:05.106 7799-7799/ptq.mpga.pinance D/MainActivity: Test: 1
2022-09-29 16:34:05.112 7799-7799/ptq.mpga.pinance D/MainActivity: Test: 2
2022-09-29 16:34:05.112 7799-7799/ptq.mpga.pinance D/MainActivity: Test: onClick1
2022-09-29 16:34:05.187 7799-7799/ptq.mpga.pinance D/MainActivity: Test: 3
而在点击Button以后会输出
2022-09-29 16:34:46.876 7799-7799/ptq.mpga.pinance D/MainActivity: Test: onClick2
2022-09-29 16:34:46.924 7799-7799/ptq.mpga.pinance D/MainActivity: Test: 1
2022-09-29 16:34:46.924 7799-7799/ptq.mpga.pinance D/MainActivity: Test: 1
2022-09-29 16:34:46.926 7799-7799/ptq.mpga.pinance D/MainActivity: Test: 2
2022-09-29 16:34:46.926 7799-7799/ptq.mpga.pinance D/MainActivity: Test: onClick1
分析
案例3与案例1区别在于Button的onClick事件,是以作用域函数的方式指定的。通过Android Studio的类型提示我们能很快搞清输出结果。
可以看到,调用作用域函数run的this对象是外层的ColumnScope,而Button的onClick属性接收一个()->Unit类型的参数,这个参数的值就是作用域函数run的返回值,而run的返回值就是run的lambda块的返回值,lambda块返回了一个代码块,即
{
Log.d(TAG, "Test: onClick2")
count++
}
那么Button的onClick参数的值就是这个代码块了,与案例1其实是一样的。
那么完整的流程就应该是,点击Button后,触发onClick,因此onClick2打印。而count变化触发重组,按照案例1,重组的入口是block1,同时block2与block1共享重组范围,因此block2也发生重组,log1和log2打印。
然后block2内,Button参与重组,因此onClick参数再次被赋值,run块再次执行,因此onClick1打印。而Button函数非inline,block3不与block2共享重组范围,因此block3不参与重组,log3不打印。
至于为什么log1被打印了两次,这与重组的其他特性有关。
结语
这篇学习笔记仅仅是对重组的范围作了现象上的分析,至于实际的情况,需要结合官方文档和源码进一步探索,重组是Compose的核心,并非三言两语能描述清楚,随着日后学习,再慢慢继续揭开Compose的神秘面纱吧。
最后,文章如有严重误导性错误请直接在评论区爆锤我,不用客气。