作者:晴天小庭
前言:
随着越来越多的人使用Compose开发项目的组件或者页面,关于使用Compose构建的组件卡顿的反馈也愈发增多,特别是LazyColumn这些重组频率较高的组件,因此很多人质疑Compose的性能过差,这真的是Compose的性能问题吗。
当然Compose在当前的版本下依然存在许多优化空间,但是实际上我们的日常项目中并不会真的逼近Compose的理论性能上限,而是没有处理好一些状态的读取,导致了重组次数过多,在用户眼里那就是卡顿了,本文将为你提供一些优化思路,降低Compose页面的卡顿。
1.重组与重组作用域
注意:如果你已经了解重组和重组作用域的概念,可以跳过本节
我们看一下这个UI:
UI层级如下:
- Example
- Column
- ComposableContainerA
- ComposableBoxA
- ComposableContainerB
- Row
- Button
- Button
- ComposableContainerA
- Column
它对应的代码如下:
@Composable
@Preview
fun Example() {
var valueA by remember { mutableStateOf(0) }
var valueB by remember { mutableStateOf(0) }
SideEffect {
Log.d("重组观察","最外层容器进行了重组")
}
Column {
ComposableContainerA(text = "$valueA")
ComposableContainerB(text = "$valueB")
Row {
Button(onClick = { valueA++ }) {
Text("A值加1")
}
Button(onClick = { valueB++ }) {
Text("B值加1")
}
}
}
}
@Composable
private fun ComposableContainerA(
text: String,
) {
SideEffect {
Log.d("重组观察", "重组作用域A进行了重组")
}
Column(
Modifier
.background(Color.Black)
.padding(10.dp)
) {
Text(
text = "我是重组作用域A,当前值${text}",
color = Color.White
)
ComposableBoxA()
}
}
@Composable
private fun ComposableBoxA() {
SideEffect {
Log.d("重组观察", "重组作用域A内部的容器进行了重组")
}
Text("我是A容器的内部组件", color = Color.White, modifier = Modifier.background(Color.Gray))
}
@Composable
private fun ComposableContainerB(
text: String,
) {
SideEffect {
Log.d("重组观察", "重组作用域B进行了重组")
}
Box(
Modifier
.background(Color.Red)
.padding(10.dp)
) {
Text(
text = "我是重组作用域B,当前值${text}",
color = Color.White
)
}
}
- 使用
SideEffect
来观察每个组件的重组。
启动程序后,得到的日志如下:
D 最外层容器进行了重组 D 重组作用域A进行了重组 D 重组作用域A内部的容器进行了重组 D 重组作用域B进行了重组
不难理解,因为刚启动程序,所有UI都未初始化,于是所有UI层级的组件都进行了重组。
然后我们点击一下第一个按钮,让A值+1,得到的日志如下:
D 最外层容器进行了重组
D 重组作用域A进行了重组
我们发现了,虽然是容器A的传参发生了变化,为什么会导致最外层的容器也重组了呢,为什么容器A的子容器没有重组,容器B没有重组呢?
这里引入一个概念——重组作用域
Compose编译器做了大量的工作让重组的范围尽可能的小,它会在编译期间找出所有使用了State
的代码块,如果State
发生了变化,那么对应的代码块就会重组,这个受State
影响的代码块就是所谓的重组作用域
。
回到Example代码,我们分析一下:
@Composable
@Preview
fun Example() {
var valueA by remember { mutableStateOf(0) }
//省略...
SideEffect {
Log.d("重组观察","最外层容器进行了重组")
}
Column {
ComposableContainerA(text = "$valueA")
//省略...
Row {
Button(onClick = { valueA++ }) {
Text("A值加1")
}
//省略...
}
}
UI层级(部分):
-
Example
-
Column
-
ComposableContainerA
-
仔细看有个问题:valueA
不是在Column
层级被使用吗,为什么valueA
的变化,会让Example
层级也发生了重组呢?
我们看看Column
的源码:
@Composable
inline fun Column(
//...
){
//...
}
原来Column
是一个内联函数,因此编译后Column
不是一个函数(实际上Row
,Box
等组件也是内联函数),因此实际的层级会变成这样:
-
Example
- ComposableContainerA
那么一切就说的通了,valueA
变化后,由于Example
内部读取了valueA
的值,并将新值传递给了ComposableContainerA
并导致了它重组,而ComposableContainerA
内部的子容器没有发生参数变化,ComposableContainerB
的参数也没有发生变化,因此他们没有发生重组。
我们可以总结出一个结论,组件会在2个条件下发生重组:
- 组件外部的传参发生了变化。
- 组件内部的
State
发生了变化,而且组件读取了这个状态。
注意第2点,只有读取了State
,组件才会因为State
变化而进入了重组,如果只是声明了State
而没有直接读取State
的值,State
变化后是不会导致当前组件重组的。
改造成这样之后,只有声明没有读取,则变成如下:
@Composable
@Preview
fun Example() {
var valueA by remember { mutableStateOf(0) }
SideEffect {
Log.d("重组观察","最外层容器进行了重组")
}
Column {
Row {
Button(onClick = { valueA++ }) {
Text("A值加1")
}
}
}
}
无论我们点多少次按钮,让valueA增加,日志都只有如下一条:
D 最外层容器进行了重组
本节总结:只有受到State
影响的代码块(即读取了State)会进入重组,而且重组的范围会尽可能小。
2.使用派生状态来降低重组次数
假设这样一个场景,有一个变化频率非常高的数值,但是我们只关心他的正负,数值为负的时候,组件的颜色是红色的,数值为正的时候,组件的颜色是绿色的。
@Composable
@Preview
private fun Example2() {
var value by remember {
mutableStateOf(0f)
}
SideEffect {
Log.d("日志", "重组了")
}
Column {
Row {
Button(onClick = {
Log.d("日志", "点击了+")
value += 0.1f
}) {
Text("点我+0.1")
}
Button(onClick = {
Log.d("日志", "点击了-")
value -= 0.1f
}) {
Text("点我-0.1")
}
}
Box(
Modifier
.size(50.dp)
.background(if (value >= 0) Color.Green else Color.Red)
)
}
}
这里我们创建了2个按钮,一个加一个减,然后Box
根据value
的值变化颜色,如下:
每次按下按钮之后,就会更新value
,然后触发Example2
的重组(为什么是Example2
重组呢,因为上文说了,Box
,Column
这些组件都是内联函数,因此他们不算单独的重组作用域),然后Box
的背景刷新。
相关的日志如下:
D 点击了+
D 重组了
D 点击了+
D 重组了
D 点击了+
D 重组了
可以看到,确实是每次点击按钮的时候发生了重组。
但是,我们重新思考一下,真的需要每次数值变化的时候都重组吗?
答案是不需要的,在Example2
中,业务的逻辑是判断value
的正负值,而不是具体的数值,因此value
从0.1变成0.2,亦或者是0.2变成0.3这种情况,方块的颜色是不变的,然而却进行了重组,浪费了性能。
因此我们需要一个工具,让我们监听value
的数值变化演变成监听value
的正负,这里介绍本节的主角:派生状态(derivedStateOf)
把上述的代码改造成如下:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
我们使用derivedStateOf
来构建出一个是否是正数的属性isPositive
,Box的颜色变化是根据isPositive
来变化的,而不是之前的value
。
简单说说derivedStateOf
,它的参数是一个lambda,该lambda可以监听State
的变化,lambda内部任意一个State
变化时,就会重新执行lambda并返回新值,是的,这个和重组作用域的概念非常接近。
于是当value
进入到derivedStateOf
的lambda内部的时候,外部的重组作用域就没有直接读取value
了,从而导致value
的变化不会直接影响组件的重组,相应的是,一旦value
的值从正数变成负数,或者从负数变成正数时,isPositive
就会变化,从而导致了重组。
我们把重组的时刻从「每次value
的变化」变成了「value
的正负值发生了变化」,排除掉了value
从正数变成正数,从负数变成负数的情况,让重组次数极大的降低。
日志如下,只有发生了正负值的跃变的时刻才会触发重组:
D 点击了+
D 点击了+
D 点击了+
D 点击了-
D 点击了-
D 点击了-
D 点击了-
D 重组了
读者可能搞懂上述的案例了但是不懂实际项目的使用,笔者在这里引用一下官方的案例:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
listState
的firstVisibleItemIndex
是一个高频变化的属性,但是业务上只关注它是否大于0的情况,这种情况就非常适合可以使用派生状态。
本节总结:监听一个高频变化的State时,如果我们只关心State的部分变化,则可以使用派生属性来降低重组次数
3.使用lambda间接传值/跳过阶段
第2点解决的是单个组件内部的冗余重组的问题,还有一种场景使用派生状态是无法解决的,就是父组件向子组件传递高频变化的状态,例如下面这种场景:
@Composable
@Preview
fun Example3() {
val scrollState = rememberScrollState()
SideEffect {
Log.d("重组监听","重组一次")
}
Column {
ScrollStateChecker(scrollValue = scrollState.value)
Column(
Modifier
.fillMaxSize()
.weight(1f, false)
.verticalScroll(scrollState)
) {
list.forEach {
Text(
"我是第${it}个", modifier = Modifier
.fillMaxWidth()
.background(Color.Red.copy(0.3f))
.padding(vertical = 5.dp)
)
}
}
}
}
@Composable
private fun ScrollStateChecker(scrollValue: Int) {
Text("scrollValue:$scrollValue")
}
对应的UI如下:
底部一个滚动的列表,顶部是监听可滚动列表的已滚动的像素,当列表滑动的时候,scrollState.value
的值会高频变化,因此整个组件会高频重组。
简单滑动之后,输出了一大堆日志:
D 重组一次
D 重组一次
D 重组一次
…
实际上,真正使用滑动偏移量的是ScrollStateChecker()
,而不是父组件,而原代码中,偏移量的读取却是发生在父组件。
@Composable
@Preview
fun Example3() {
val scrollState = rememberScrollState()
//...
Column {
// 👇🏻父组件直接读取该值
ScrollStateChecker(scrollValue = scrollState.value)
//...
}
}
这样的做法导致了2个后果:
- 父组件的没必要重组
- 子组件强制重组
这里说说第2点,为什么子组件强制重组是不好的呢,因为有时候组件并不一定需要重组,如果这个组件仅仅是希望拿到滑动偏移量之后做一些偏移量的操作,是不需要重组的,只需要重新执行布局阶段即可,这个后面会展开说。
先解决第1点的问题,父组件并不需要使用偏移量的值,因此父组件不要直接读取该值,那么如何间接传该值给子控件呢?
答案是lambda,修改代码如下:
@Composable
@Preview
fun Example3() {
//...
Column {
// 👇🏻使用lambda让子控件读取
ScrollStateChecker(scrollValueProvider = { scrollState.value })
Column(
//...
) {
//...
}
}
}
@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {
// 👇🏻使用lambda读取
Text("scrollValue:${(scrollValueProvider())}")
}
将ScrollStateChecker
的参数改造为lambda,这样父组件就不用直接读取滚动偏移了,重新查看日志:
D 重组一次
除了初始化的一次重组,父组件不再参与scrollState.value
导致的重组了。
子组件还能减少重组次数吗,可惜不行了,因为子组件是要输出滑动的偏移量的文案,因此我们在最大可能上做了优化。
但是,上文说了,大多数情况的业务并不是要把偏移量作为文案输出到屏幕上,而是根据偏移量做一些偏移操作(例如滑动布局顶部的吸顶Title),我们把ScrollStateChecker
的代码改成如下:
@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {
val scrollXDp = with(LocalDensity.current) {
scrollValueProvider().toDp()
}
Box(
Modifier
.size(50.dp)
.offset(x = scrollXDp)
.background(Color.Green)
)
}
当列表滑动的时候,会导致ScrollStateChecker
往右移动,查看通过布局查看器看看重组次数:
滑动的过程中,ScrollStateChecke
会不断重组,让布局不断进入重组-布局-绘制的流程,这里简单说说三个流程的差异:
- 重组:有什么组件
- 布局:组件的位置
- 绘制:如何绘制组件
对于上述任务来说,我们只是希望做一个位置的偏移,是不需要重新进入重组流程的,因为没有组件出现或者消失了,因此跳过重组可以让UI的性能进一步提交,修改也非常简单:
@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {
Box(
Modifier
.size(50.dp)
.offset {
IntOffset(
x = scrollValueProvider(),
y = 0
)
}
.background(Color.Green)
)
}
修改之后,任意滑动列表,一次重组也没有出现,性能进一步提升了。
在Compose自带的关于偏移、可见度、大小变化的api中,都有一个lambda版本的,这个lambda的效率会比非lambda版本更高,因为可以跳过重组的过程。
graphicsLayout
是一个不错的关于修改偏移、可见度、缩放的lambda版本Api,推荐使用,案例如下:
@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {
Box(
Modifier
.size(50.dp)
.graphicsLayer {
scaleY = scrollValueProvider() / 1000f
scaleX = scrollValueProvider() / 1000f
translationX = scrollValueProvider().toFloat()
}
.background(Color.Green)
)
}
另外一个关于背景颜色的场景,如果你的背景颜色高频变化,可以使用drawBehind来完成背景设置,完全可以跳过组合和布局阶段,仅仅需要绘制
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawRect(color)
}
)
本节总结:子组件需要读取父组件上面的高频变化的State时,考虑使用lambda传值;实现偏移、缩放等操作时,考虑使用lambda版本的api,跳过重组、布局阶段。
Android 学习笔录
Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap