Jetpack Compose布局优化实践

2023年 8月 25日 73.9k 0

01、前言

我们内部团队使用 Jetpack Compose 开发项目已近一年,经历了简单布局到复杂布局的应用,对 Compose 的使用越来越成熟,构造了很多易用的基础组合,提升了项目的开发效率,与此同时 Compose 布局的一些性能问题也慢慢凸显出来,因此专门对 Compose 布局优化进行了调研工作,旨在减少重组提高性能,规避负面效应,提高应用稳定性。结合具体场景来具体分析。

02、使用 remember 减少计算

我们构造一个客户列表,代码如下:

@Composable
fun ClientList(list: MutableList, modifier: Modifier) {
    LazyColumn(modifier = modifier) {
        items(list) {
            ClientItem(it)
        }
    }
}

接着增加一个需求,将客户列表按照年龄排序,我们改动一下代码:

@Composable
fun ClientList(list: MutableList, modifier: Modifier) {
    LazyColumn(modifier = modifier) {
        items(list.sortedBy { it.age }) {
            ClientItem(it)
        }
    }
}

上面代码能够正确运行,只不过会有一点问题,就是每次重组都会对 list 执行排序操作。众所周知在 Compose 中可组合项可能会非常频繁的重组,也就意味着排序操作可能会非常频繁的执行,这显然是不行的,因为排序可能会占用较多的资源,导致布局卡顿。最理想的状态应该是数据变动或者排序规则变动才会触发排序,达到这种状态我们可以使用 remember或者将排序操作放到 ViewModel 当中:

@Composable
fun ClientList(list: MutableList, modifier: Modifier) {
    // 通过remember方法,将list的排序结果缓存起来,当list发生变化时,才会重新排序
    val sortList = remember(key1 = list) {
        list.sortedBy { it.age }
    }
    LazyColumn(modifier = modifier) {
        items(sortList) {
            ClientItem(it)
        }
    }
}

在开发过程中应该谨记一条规则:重组可能会频繁的执行,因此尽量避免在组合内写一些会引起副作用的代码。

03、Lazy布局使用key

在项目开发中列表布局占多数,在 Compose 中实现列表使用延时布局它包含了 LazyColumn、LazyRow等布局,比如上一节使用 LazyColumn实现了一个客户列表。

接上继续以客户列表布局为例,如果对客户列表进行增加或者删除,列表布局是如何重组的呢?为了探究这个问题,稍微改下代码,增加一个添加客户的按钮:

Column {
    Row(modifier = Modifier.fillMaxWidth()) {
        Text(text = "添加新客户", modifier = Modifier.clickable {
            Log.d("compose demo", "添加新客户")
            //手动插入一条数据 
            list.add(5, ClientInfo("新添加客户", 5))
        })
    }
    ClientList(...)
}

然后在 LazyColumn作用域以及ClientItem中加上日志信息:

@Composable
fun ClientList(list: SnapshotStateList, modifier: Modifier) {
    LazyColumn(modifier = modifier) {
        Log.d("compose demo", "LazyColumn update")
        itemsIndexed(list) { _, item ->
            ClientItem(item)
        }
    }
}

@Composable
fun ClientItem(info: ClientInfo) {
    Log.d("compose demo", "item  name=${info.name} 重组")
    Text(text = "${info.name} ${info.age}", modifier = Modifier.height(44.dp))
}

接下来运行一次,并点击添加新客户按钮,控制台输出如下:

com.czx.demo       D  添加新客户
com.czx.demo       D  LazyColumn update
com.czx.demo       D  item  name = 添加新客户 重组
com.czx.demo       D  item  name = name ---- 5 重组
com.czx.demo       D  item  name = name ---- 6 重组
com.czx.demo       D  item  name = name ---- 7 重组
com.czx.demo       D  item  name = name ---- 8 重组
com.czx.demo       D  item  name = name ---- 9 重组
com.czx.demo       D  item  name = name ---- 10 重组
com.czx.demo       D  item  name = name ---- 11 重组
com.czx.demo       D  item  name = name ---- 12 重组
com.czx.demo       D  item  name = name ---- 13 重组
com.czx.demo       D  item  name = name ---- 14 重组

我们发现除了新添加的客户项之外,在此位置之后的所有可见的客户项都触发了不必要的重组。如果想让列表只重组新增项,那么这里就要使用 key参数来避免这些不必要的重组,key参数是一个任意类型的值,用于标识布局,并确保 Compose 框架在重新计算布局时正确地处理它们。改动代码加上key参数:

@Composable
fun ClientList(list: SnapshotStateList, modifier: Modifier) {
    LazyColumn(modifier = modifier) {
        Log.d("compose demo", "LazyColumn update")
        //key参数指定
        itemsIndexed(list, key = { _, item -> item.id }) { _, item ->
            ClientItem(item)
        }
    }
}

需要注意的是 key参数要保证唯一性这样才能确保 Compose 框架能够正确地计算和更新列表项,加上 key参数代码运行后台输出如下:

com.czx.demo       D  添加新客户
com.czx.demo       D  LazyColumn update
com.czx.demo       D  item  name = 添加新客户 重组

之前的不必要重组没有了,只重组了添加项,符合预期。

Tips: 这里一定要保证 key参数的唯一性,否则会出现不必要的重组,影响性能。

04、使用derivedStateOf限制重组

继续使用上面的客户列表,新增一个需求当第一个可见项大于0的时候,展示回到顶部的按钮,按照需求我们对代码做如下改动:

1.增加listState来监听列表状态:

val listState = rememberLazyListState()

2.通过listState获取当前可见项,判断是否展示回到顶部 button :

val showButton = listState.firstVisibleItemIndex > 0

3.回到顶部按钮显隐:

if (showButton){
    ScrollToTopButton()
 }

再将列表包裹一层布局整体代码如下:

Box {
    val listState = rememberLazyListState()

    ClientList(...)

    val showButton = listState.firstVisibleItemIndex > 0

     if (showButton){
       Log.d("compose demo", "button 重组")
       ScrollToTopButton()
    }
}

运行代码并上下滑动列表,控制台输出:

com.czx.demo       D  item  name = name ---- 17 重组
com.czx.demo       D  item  name = name ---- 18 重组
com.czx.demo       D  item  button 重组
com.czx.demo       D  item  button 重组

可以看到触发了多次重组,虽然 showButton只关心 firstVisibleItemIndex是否是从 0 变为非 0 ,但是这种写法当 firstVisibleItemIndex大于 0 时会一直被触发,从而引起了不必要的重组。要想规避这种情况可以使用 derivedStateOf()函数来处理频繁变更的数据:

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

控制台输出:

com.czx.demo       D  item  name = name ---- 17 重组
com.czx.demo       D  item  name = name ---- 18 重组
com.czx.demo       D  item  button 重组
com.czx.demo       D  item  name = name ---- 19 重组
com.czx.demo       D  item  name = name ---- 20 重组
com.czx.demo       D  item  name = name ---- 21 重组
com.czx.demo       D  item  name = name ---- 22 重组

连续滑动只会触发一次重组。

05、延迟读取 

Compose 有三个阶段 组合、布局和绘制 ,可以通过尽可能的跳过三个步骤中的一个或者多个来提高性能。

06、场景一

val color by animateColorBetween(Color.Red, Color.Blue)
Box(modifier = Modifier.fillMaxSize().background(color))

代码能够运行并且满足我们的要求,如果足够细心可以发现这里隐藏着一个优化点,上面提到 Compose 的三个阶段组合、布局和绘制,对于示例代码而言,仅仅是改变背景颜色,不需要重组和布局,那么我们对代码进行优化。

val color by animateColorBetween(Color.Red, Color.Blue)
Box(modifier = Modifier.fillMaxSize().drawBehind {  
    drawRect(color = color)
})

我们使用了 drawBehind()函数,该函数发生在绘制时期,由于仅改变背景颜色,所以这里改变方框的背景颜色使用 drawRect达到一样的效果,这样绘制就成了唯一重复执行的阶段,进而提高性能。

07、场景二

@Composable
fun SnackDetail() {
    //...
    Box(Modifier.fillMaxSize()) {  // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value) //1.状态读取
        // ...
    } //Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    //...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset) //2.状态使用
    ) {
        //...
    }
}

对 scroll.value的读取会使 Box()发生重组,但是 scroll的使用却不是在 Box()中,这种读取与使用位置不一致的情况,往往会有性能优化的空间。对于这种情况我们将让读取和使用位置一致:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value } 
        // ...
    } 
    // Recomposition Scope end
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset) // 状态读取+使用
    ) {
    // ...
    }
}

这样当 scroll.value()变化时不会触发重组,也就是在滑动中唯二执行的阶段只有布局和绘制。

08、避免向后写入

Compose中有个核心       假设:您永远不会向已被读取的状态写入数据。如果破坏了这个假设也就是向后写入,可能会造成一些不必要的重组。

举个例子:

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count") //1
    count++ // Backwards write, writing to state after it has been read
}

点击按钮后会 count++执行,注释1处读取了 count因此会触发重组,但是同时末尾处的 count++也会执行,最终导致之前状态过期,注释 1 继续读取,然后陷入循环,count++一直执行,每一帧都在重组。这会造成严重的性能问题,所以应该避免在组合中进行状态写入,尽量在响应事件中写入状态。

07、发布模式&R8优化

Compose并不是 Android 系统库,而是作为独立的库进行引入。这样做的好处就是可以兼容旧的安卓版本以及频繁的更新功能,但是也会产生性能上的开销,导致首次启动或者首次使用一个库功能时变得比较慢。

下图是冷启动耗时对比(单位:ms):

图片图片

可以看到发布模式 +R8+Profile 下的冷启动耗时是最短的。发布模式一般默认开启了 R8 优化,具体优化细节,这里不做展开。另外值得一提的是Profile,它是 Compose 官方定义的基准配置文件,专门用来提高性能。

基准配置文件中定义关键用户历程所需的类和方法,并与应用的 APK 一起分发。在应用安装期间,ART 会预先编译该关键代码,以确保在应用启动时可供使用。要定义一个良好的基准配置文件并不容易,因而此 Compose 随带了一个默认的基准配置文件。您无需执行任何操作即可直接使用该配置文件。但是,如果选择定义自己的配置文件,则可能会生成一个无法实际提升应用性能的配置文件。

10、总结

以上结合代码示例介绍了 Jetpack Compose中的布局优化手段,总结下来就是在应用开发中,应尽量减少不必要的重组来提高性能。因此我们需要合理的使用 remember、 Lazy布局的key, derivedStateOf等手段,来遵循最佳性能实践。

11、引用

Jetpack Compose 官方文档:https://developer.android.com/jetpack/compose

本文转载自微信公众号「 搜狐技术产品」,作者「 蔡志学」,可以通过以下二维码关注。

转载本文请联系「 搜狐技术产品」公众号。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论