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

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

当然Compose在当时的版别下依然存在许多优化空间,可是实践上咱们的日常项目中并不会真的迫临Compose的理论功能上限,而是没有处理好一些状态的读取,导致了重组次数过多,在用户眼里那便是卡顿了,本文将为你供给一些优化思路,下降Compose页面的卡顿。

1.重组与重组效果域

注意:假如你已经了解重组和重组效果域的概念,能够越过本节

咱们看一下这个UI:

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

UI层级如下:

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

它对应的代码如下:

@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不是一个函数(实践上RowBox等组件也是内联函数),因而实践的层级会变成这样:

  • Example

    • ComposableContainerA

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

咱们能够总结出一个结论,组件会在2个条件下发生重组:

  1. 组件外部的传参发生了改变。
  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的值改变色彩,如下:

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

每次按下按钮之后,就会更新value,然后触发Example2的重组(为什么是Example2重组呢,由于上文说了,BoxColumn这些组件都是内联函数,因而他们不算独自的重组效果域),然后Box的布景改写。

相关的日志如下:

D 点击了+

D 重组了

D 点击了+

D 重组了

D 点击了+

D 重组了

能够看到,确实是每次点击按钮的时分发生了重组。

可是,咱们从头思考一下,真的需求每次数值改变的时分都重组吗?

答案是不需求的,在Example2中,事务的逻辑是判别value的正负值,而不是详细的数值,因而value从0.1变成0.2,亦或许是0.2变成0.3这种状况,方块的色彩是不变的,然而却进行了重组,浪费了功能。

因而咱们需求一个工具,让咱们监听value的数值改变演变成监听value的正负,这儿介绍本节的主角:派生状态(derivedStateOf)

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

@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 重组了

读者或许搞懂上述的事例了可是不明白实践项目的运用,笔者在这儿引用一下官方的事例:

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点处理的是单个组件内部的冗余重组的问题,还有一种场景运用派生状态是无法处理的,便是父组件向子组件传递高频改变的状态,例如下面这种场景:

@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如下:

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

底部一个翻滚的列表,顶部是监听可翻滚列表的已翻滚的像素,当列表滑动的时分,scrollState.value的值会高频改变,因而整个组件会高频重组。

简略滑动之后,输出了一大堆日志:

D 重组一次

D 重组一次

D 重组一次

实践上,真实运用滑动偏移量的是ScrollStateChecker(),而不是父组件,而原代码中,偏移量的读取却是发生在父组件。

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

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

  1. 父组件的没必要重组
  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往右移动,查看通过布局查看器看看重组次数:

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

滑动的进程中,ScrollStateChecke会不断重组,让布局不断进入重组-布局-制作的流程,这儿简略说说三个流程的差异:

  • 重组:有什么组件
  • 布局:组件的位置
  • 制作:怎么制作组件

对于上述使命来说,咱们只是希望做一个位置的偏移,是不需求从头进入重组流程的,由于没有组件呈现或许消失了,因而越过重组能够让UI的功能进一步提交,修正也十分简略:

@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {
    Box(
        Modifier
            .size(50.dp)
            .offset {
                IntOffset(
                    x = scrollValueProvider(),
                    y = 0
                )
            }
            .background(Color.Green)
    )
}

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

修正之后,恣意滑动列表,一次重组也没有呈现,功能进一步提升了。

在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,越过重组、布局阶段。

结尾:

许多刚下手Compose的运用者遇到卡顿的时分,或许是不恰当的访问了高频改变的State导致重组次数过高,希望这篇文章能够帮助到你优化页面功能,假如帮助到了你,能够点个赞支撑一下。