01、前言
我们内部团队使用 Jetpack Compose 开发项目已近一年,经历了简单布局到复杂布局的应用,对 Compose 的使用越来越成熟,构造了很多易用的基础组合,提升了项目的开发效率,与此同时 Compose 布局的一些性能问题也慢慢凸显出来,因此专门对 Compose 布局优化进行了调研工作,旨在减少重组提高性能,规避负面效应,提高应用稳定性。结合具体场景来具体分析。
02、使用 remember 减少计算
我们构造一个客户列表,代码如下:
@Composable
fun ClientList(list: MutableList<ClientInfo>, modifier: Modifier) {
LazyColumn(modifier = modifier) {
items(list) {
ClientItem(it)
}
}
}
接着增加一个需求,将客户列表按照年龄排序,我们改动一下代码:
@Composable
fun ClientList(list: MutableList<ClientInfo>, modifier: Modifier) {
LazyColumn(modifier = modifier) {
items(list.sortedBy { it.age }) {
ClientItem(it)
}
}
}
上面代码能够正确运行,只不过会有一点问题,就是每次重组都会对 list 执行排序操作。众所周知在 Compose 中可组合项可能会非常频繁的重组,也就意味着排序操作可能会非常频繁的执行,这显然是不行的,因为排序可能会占用较多的资源,导致布局卡顿。最理想的状态应该是数据变动或者排序规则变动才会触发排序,达到这种状态我们可以使用 remember或者将排序操作放到 ViewModel 当中:
@Composable
fun 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)
}
}
}
在开发过程中应该谨记一条规则:重组可能会频繁的执行,因此尽量避免在组合内写一些会引起副作用的代码。
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<ClientInfo>, 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<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 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、引用
本文转载自微信公众号「 搜狐技术产品」,作者「 蔡志学」,可以通过以下二维码关注。
转载本文请联系「 搜狐技术产品」公众号。