我们内部团队使用 Jetpack Compose 开发项目已近一年,经历了简单布局到复杂布局的应用,对 Compose 的使用越来越成熟,构造了很多易用的基础组合,提升了项目的开发效率,与此同时 Compose 布局的一些性能问题也慢慢凸显出来,因此专门对 Compose 布局优化进行了调研工作,旨在减少重组提高性能,规避负面效应,提高应用稳定性。结合具体场景来具体分析。
我们构造一个客户列表,代码如下:
@Composablefun ClientList(list: MutableList<ClientInfo>,modifier: Modifier){ LazyColumn(modifier=modifier){ items(list){ ClientItem(it)} } }
接着增加一个需求,将客户列表按照年龄排序,我们改动一下代码:
@Composablefun ClientList(list: MutableList<ClientInfo>,modifier: Modifier){ LazyColumn(modifier=modifier){ items(list.sortedBy { it.age }){ ClientItem(it)} } }
上面代码能够正确运行,只不过会有一点问题,就是每次重组都会对 list 执行排序操作。众所周知在 Compose 中可组合项可能会非常频繁的重组,也就意味着排序操作可能会非常频繁的执行,这显然是不行的,因为排序可能会占用较多的资源,导致布局卡顿。最理想的状态应该是数据变动或者排序规则变动才会触发排序,达到这种状态我们可以使用 remember或者将排序操作放到 ViewModel 当中:
@Composablefun ClientList(list: MutableList<ClientInfo>,modifier: Modifier){// 通过remember方法,将list的排序结果缓存起来,当list发生变化时,才会重新排序val sortList=remember(key1=list){ list.sortedBy { it.age } } LazyColumn(modifier=modifier){ items(sortList){ ClientItem(it)} } }
在开发过程中应该谨记一条规则:重组可能会频繁的执行,因此尽量避免在组合内写一些会引起副作用的代码。
在项目开发中列表布局占多数,在 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中加上日志信息:
@Composablefun ClientList(list: SnapshotStateList<ClientInfo>,modifier: Modifier){ LazyColumn(modifier=modifier){ Log.d("compose demo","LazyColumn update")itemsIndexed(list){ _,item->ClientItem(item)} } }@Composablefun 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 LazyColumnupdatecom.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参数:
@Composablefun ClientList(list: SnapshotStateList<ClientInfo>,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 LazyColumnupdatecom.czx.demo D item name=添加新客户 重组
之前的不必要重组没有了,只重组了添加项,符合预期。
Tips: 这里一定要保证 key参数的唯一性,否则会出现不必要的重组,影响性能。
继续使用上面的客户列表,新增一个需求当第一个可见项大于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>0if(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 showButtonbyremember { 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 重组
连续滑动只会触发一次重组。
Compose 有三个阶段 组合、布局和绘制 ,可以通过尽可能的跳过三个步骤中的一个或者多个来提高性能。
val colorbyanimateColorBetween(Color.Red,Color.Blue)Box(modifier=Modifier.fillMaxSize().background(color))
代码能够运行并且满足我们的要求,如果足够细心可以发现这里隐藏着一个优化点,上面提到 Compose 的三个阶段组合、布局和绘制,对于示例代码而言,仅仅是改变背景颜色,不需要重组和布局,那么我们对代码进行优化。
val colorbyanimateColorBetween(Color.Red,Color.Blue)Box(modifier=Modifier.fillMaxSize().drawBehind { drawRect(color=color)})
我们使用了 drawBehind()函数,该函数发生在绘制时期,由于仅改变背景颜色,所以这里改变方框的背景颜色使用 drawRect达到一样的效果,这样绘制就成了唯一重复执行的阶段,进而提高性能。
@Composablefun SnackDetail(){//...Box(Modifier.fillMaxSize()){// Recomposition Scope Startval scroll=rememberScrollState(0)// ...Title(snack,scroll.value)//1.状态读取// ...}//Recomposition Scope End}@Composableprivate fun Title(snack: Snack,scroll:Int){//...valoffset=with(LocalDensity.current){ scroll.toDp()}Column(modifier=Modifier.offset(y=offset)//2.状态使用){//...} }
对 scroll.value的读取会使 Box()发生重组,但是 scroll的使用却不是在 Box()中,这种读取与使用位置不一致的情况,往往会有性能优化的空间。对于这种情况我们将让读取和使用位置一致:
@Composablefun SnackDetail(){// ...Box(Modifier.fillMaxSize()){// Recomposition Scope Startval scroll=rememberScrollState(0)// ...Title(snack){ scroll.value}// ...}// Recomposition Scope end}@Composableprivate fun Title(snack: Snack,scrollProvider:()->Int){// ...valoffset=with(LocalDensity.current){ scrollProvider().toDp()}Column(modifier=Modifier.offset(y=offset)// 状态读取+使用){// ...} }
这样当 scroll.value()变化时不会触发重组,也就是在滑动中唯二执行的阶段只有布局和绘制。
Compose中有个核心 假设:您永远不会向已被读取的状态写入数据。如果破坏了这个假设也就是向后写入,可能会造成一些不必要的重组。
举个例子:
@Composablefun BadComposable(){ var countbyremember { mutableStateOf(0)}// Causes recomposition on clickButton(onClick={ count++},Modifier.wrapContentSize()){Text("Recompose")}Text("$count")//1count++// Backwards write, writing to state after it has been read}
点击按钮后会 count++执行,注释1处读取了 count因此会触发重组,但是同时末尾处的 count++也会执行,最终导致之前状态过期,注释 1 继续读取,然后陷入循环,count++一直执行,每一帧都在重组。这会造成严重的性能问题,所以应该避免在组合中进行状态写入,尽量在响应事件中写入状态。
Compose并不是 Android 系统库,而是作为独立的库进行引入。这样做的好处就是可以兼容旧的安卓版本以及频繁的更新功能,但是也会产生性能上的开销,导致首次启动或者首次使用一个库功能时变得比较慢。
下图是冷启动耗时对比(单位:ms):
图片
可以看到发布模式 +R8+Profile 下的冷启动耗时是最短的。发布模式一般默认开启了 R8 优化,具体优化细节,这里不做展开。另外值得一提的是Profile,它是 Compose 官方定义的基准配置文件,专门用来提高性能。
基准配置文件中定义关键用户历程所需的类和方法,并与应用的 APK 一起分发。在应用安装期间,ART 会预先编译该关键代码,以确保在应用启动时可供使用。要定义一个良好的基准配置文件并不容易,因而此 Compose 随带了一个默认的基准配置文件。您无需执行任何操作即可直接使用该配置文件。但是,如果选择定义自己的配置文件,则可能会生成一个无法实际提升应用性能的配置文件。
以上结合代码示例介绍了 Jetpack Compose中的布局优化手段,总结下来就是在应用开发中,应尽量减少不必要的重组来提高性能。因此我们需要合理的使用 remember、 Lazy布局的key, derivedStateOf等手段,来遵循最佳性能实践。
Jetpack Compose 官方文档:https://developer.android.com/jetpack/compose
本文转载自微信公众号「 搜狐技术产品」,作者「 蔡志学」,可以通过以下二维码关注。
转载本文请联系「 搜狐技术产品」公众号。