Compose:从重组谈谈页面性能优化思路,狠狠优化一笔

前言:随着越来越多的人使用Compose开发项目的组件或者页面,关于使用Compose构建的组件卡顿的反馈也愈发增多,特别是LazyColumn这些重组频率较高的组件,因此很多人质疑Compose的性能过差,这真的是Compose的性能问题吗。

当然Compose在当前的版本下依然存在许多优化空间,但是实际上我们的日常项目中并不会真的逼近Compose的理论性能上限,而是没有处理好一些状态的读取,导致了重组次数过多,在用户眼里那就是卡顿了,本文将为你提供一些优化思路,降低Compose页面的卡顿。

1.重组与重组作用域

注意:如果你已经了解重组和重组作用域的概念,可以跳过本节

我们看一下这个UI:

UI层级如下:

  • Example
    • Column
      • ComposableContainerA
        • ComposableBoxA
        • ComposableContainerB
      • Row
        • Button
        • Button

它对应的代码如下:

kotlin 复制代码
@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代码,我们分析一下:

kotlin 复制代码
@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的源码:

kotlin 复制代码
@Composable
inline fun Column(
  //...
){
  //...
}

原来Column是一个内联函数,因此编译后Column不是一个函数(实际上RowBox等组件也是内联函数),因此实际的层级会变成这样:

  • Example

    • ComposableContainerA

那么一切就说的通了,valueA变化后,由于Example内部读取valueA的值,并将新值传递给了ComposableContainerA并导致了它重组,而ComposableContainerA内部的子容器没有发生参数变化,ComposableContainerB的参数也没有发生变化,因此他们没有发生重组。

我们可以总结出一个结论,组件会在2个条件下发生重组:

  1. 组件外部的传参发生了变化。
  2. 组件内部的State发生了变化,而且组件读取了这个状态。

注意第2点,只有读取State,组件才会因为State变化而进入了重组,如果只是声明了State而没有直接读取State的值,State变化后是不会导致当前组件重组的。

改造成这样之后,只有声明没有读取,则变成如下:

kotlin 复制代码
@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.使用派生状态来降低重组次数

假设这样一个场景,有一个变化频率非常高的数值,但是我们只关心他的正负,数值为负的时候,组件的颜色是红色的,数值为正的时候,组件的颜色是绿色的。

kotlin 复制代码
@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重组呢,因为上文说了,BoxColumn这些组件都是内联函数,因此他们不算单独的重组作用域),然后Box的背景刷新。

相关的日志如下:

D 点击了+ D 重组了 D 点击了+ D 重组了 D 点击了+ D 重组了 D 点击了+ D 重组了 D 点击了- D 重组了 D 点击了- D 重组了

可以看到,确实是每次点击按钮的时候发生了重组。

但是,我们重新思考一下,真的需要每次数值变化的时候都重组吗?

答案是不需要的,在Example2中,业务的逻辑是判断value的正负值,而不是具体的数值,因此value从0.1变成0.2,亦或者是0.2变成0.3这种情况,方块的颜色是不变的,然而却进行了重组,浪费了性能。

因此我们需要一个工具,让我们监听value的数值变化演变成监听value的正负,这里介绍本节的主角:派生状态(derivedStateOf)

把上述的代码改造成如下:

kotlin 复制代码
@Composable
@Preview
private fun Example2() {

    var value by remember {
        mutableStateOf(0f)
    }

    val isPositive by remember {
        //                 👇🏻仅在derivedStateOf内部读取value的值
        derivedStateOf { value >= 0 }
    }

    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)
                //                👇🏻读取的是isPositive而不是value
                .background(if (isPositive) Color.Green else Color.Red)
        )
    }
}

我们使用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 重组了

读者可能搞懂上述的案例了但是不懂实际项目的使用,笔者在这里引用一下官方的案例:

kotlin 复制代码
val listState = rememberLazyListState()
    
LazyColumn(state = listState) {
  // ...
}

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

listStatefirstVisibleItemIndex是一个高频变化的属性,但是业务上只关注它是否大于0的情况,这种情况就非常适合可以使用派生状态。

本节总结:监听一个高频变化的State时,如果我们只关心State的部分变化,则可以使用派生属性来降低重组次数

3.使用lambda间接传值/跳过阶段

第2点解决的是单个组件内部的冗余重组的问题,还有一种场景使用派生状态是无法解决的,就是父组件向子组件传递高频变化的状态,例如下面这种场景:

kotlin 复制代码
@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(),而不是父组件,而原代码中,偏移量的读取却是发生在父组件。

kotlin 复制代码
@Composable
@Preview
fun Example3() {
    val scrollState = rememberScrollState()
    //...
    Column {
        //                                     👇🏻父组件直接读取该值
        ScrollStateChecker(scrollValue = scrollState.value)
        //...
    }
}

这样的做法导致了2个后果:

  1. 父组件的没必要重组
  2. 子组件强制重组

这里说说第2点,为什么子组件强制重组是不好的呢,因为有时候组件并不一定需要重组,如果这个组件仅仅是希望拿到滑动偏移量之后做一些偏移量的操作,是不需要重组的,只需要重新执行布局阶段即可,这个后面会展开说。

先解决第1点的问题,父组件并不需要使用偏移量的值,因此父组件不要直接读取该值,那么如何间接传该值给子控件呢?

答案是lambda,修改代码如下:

kotlin 复制代码
@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的代码改成如下:

kotlin 复制代码
@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的性能进一步提交,修改也非常简单:

kotlin 复制代码
@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,推荐使用,案例如下:

kotlin 复制代码
 @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来完成背景设置,完全可以跳过组合和布局阶段,仅仅需要绘制

kotlin 复制代码
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

本节总结:子组件需要读取父组件上面的高频变化的State时,考虑使用lambda传值;实现偏移、缩放等操作时,考虑使用lambda版本的api,跳过重组、布局阶段。

结尾:

许多刚入手Compose的使用者遇到卡顿的时候,可能是不恰当的访问了高频变化的State导致重组次数过高,希望这篇文章可以帮助到你优化页面性能,如果帮助到了你,可以点个赞支持一下。

相关推荐
fatiaozhang95272 小时前
中兴云电脑W102D_晶晨S905X2_2+16G_mt7661无线_安卓9.0_线刷固件包
android·adb·电视盒子·魔百盒刷机·魔百盒固件
CYRUS_STUDIO3 小时前
Android APP 热修复原理
android·app·hotfix
鸿蒙布道师3 小时前
鸿蒙NEXT开发通知工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
鸿蒙布道师3 小时前
鸿蒙NEXT开发网络相关工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
大耳猫3 小时前
【解决】Android Gradle Sync 报错 Could not read workspace metadata
android·gradle·android studio
ta叫我小白4 小时前
实现 Android 图片信息获取和 EXIF 坐标解析
android·exif·经纬度
dpxiaolong5 小时前
RK3588平台用v4l工具调试USB摄像头实践(亮度,饱和度,对比度,色相等)
android·windows
tangweiguo030519876 小时前
Android 混合开发实战:统一 View 与 Compose 的浅色/深色主题方案
android
老狼孩111226 小时前
2025新版懒人精灵零基础及各板块核心系统视频教程-全分辨率免ROOT自动化开发
android·机器人·自动化·lua·脚本开发·懒人精灵·免root开发
打死不学Java代码7 小时前
PaginationInnerInterceptor使用(Mybatis-plus分页)
android·java·mybatis