Jetpack Compose 中的常见性能问题
配置(How to set up your Compose add for optimal performance)
首先,看看如何正确地配置应用,如何配置应用以测试和评估性能。在评估Compose应用的性能时,一定要确保应用在发布模式下运行并且要启用R8优化功能。
Running in release with R8 enabled
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
为什么呢?将应用部署为调试版本时,运行速度会变慢。因为Android运行时会关闭优化功能来改进调试体验。
例如,为了能够逐步检查代码缩减功能不会启用,在调试模式下运行应用时会停用许多优化功能而这些功能对于确保应用无卡顿至关重要。如果你注意到应用出现性能问题,就应该先检查应用在发布模式下运行是否存在同样的问题,应用可能根本不存在任何问题。现在你已了解如何配置应用。
我们来看几个常见问题并探讨一下如何解决这些问题
第一,需记住的事情(Caches calculations and allocations)
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// DON't DO THIS
items(contacts.sortedWith(comparator)) { contact ->
...
}
}
}
我们将探讨一个简单的“通讯录”应用,该应用显示了大量名字,看看上面这部分代码有什么问题?
每次重组时列表都会重新排序,可组合项可能会非常频繁地运行。在编写代码时必须谨记这一点。
在这个示例中,只要有新行出现在屏幕上,联系人列表就会重新排序。因为当有新行出现时,LazyList组合的作用域就会失效,Compose会对它进行重新组合,这意味着我们的所有代码会重新执行。因为排序操作再次作用域内执行,所以每次重组时系统都会调用排序函数。
我们可以使用remember函数来缓存开销大的操作和分配并确保这些操作或分配仅在需要时运行。
让我们回到ContactList,我们可以将排序操作移到remember函数内,将contacts和sortComparator用作remember函数的键,这将确保其中任一键有变化时列表都会重新排序。
@Composable
fun ContaceList(...) {
val sortedContacts = remember(contacts, sortComparator) {
contacts.sortedWith(sortComparator)
}
LazyColumn(modifier) {
items(sortedContacts) { ... }
}
}
一项更优化的改进方案是,将此排序操作完全移出Compose并移到ViewModel或DataSource中,仅在需要时更改Compose状态,这样就可以将开销尽可能降低至最低。
第二、键(Helps LazyList know what has changeed in a list)
刚才我们优化了联系人的排序操作,现在我们回到列表可组合项看看是否可以继续改进。
LazyColumn {
items(contacts) { contact ->
...
}
}
我们可以向LazyColumn提供更多信息,帮助它了解哪些项发生了变化,你知道是什么吗?那就是Key,你可以为LazyList中的项定义一个键,在不提供键的情况下,Compose会使用该项的位置作为键。当项在列表中移动时,这会给性能带来非常不利的影响,因为该项之后的每一项也会重组。
Use the key parameter to provide a unique key for each item
LazyColumn {
items(contacts, key = { it.id }) { contact ->
...
}
}
提供键的方式很简单,将keylambda添加到items函数,就可以提供键了。唯一需要注意的是,每个键都必须是唯一的。
现在,项在列表中移动时Compose就会知道哪个项移动了,只需要重组该项即可。
键的使用不仅优化了Lazylist而且解锁了众多功能。
第三、衍生更改(Avoids unnecessary recomposition for state changes we need)
接下来,设计人员要求我们添加一个按钮用于滚回顶部,关键在于他们只希望在列表向下滚动后才显示该按钮。
借助Compose声明式编程可以很容易实现这一目标,我们可以添加一个名为showButton的布尔变量,当第一个可见项索引大于0时,这个变量的值就变为true,我们将这个变量用作AnimatedVisibility的参数以确保在该按钮显示和消失时可以呈现出很好的淡入淡出效果。
val listState = rememberLazyListState()
LazyColumn(state = listState) {
//...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibilitu(visible = showButton) {
ScrollToTopButton()
}
不过,这里有一个陷阱,因为LazyList会在每次滚动的每一帧都更新listState变量并且我们会读取listState。所以会引入大量我们不需要的重组。
对于showButton变量,我们只关心第一个可见索引何时变为非0或变为0,还有另一个与remember类似的Compose函数可以在这里帮到我们,那就是derivedStateOf。Compose提供derivedStateOf函数正是为了应对类似的情况
val listState = rememberLazyListState()
LazyColumn(state = listState) { ... }
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) { ... }
derivedStateOf将接受频繁变化的listState,并且仅挑选出我们所需状态的变化。在本例中,那就是第一个课件索引大于0的情况。我们将条件封装在remember derivedStateOf函数中。
现在,我们仅在此条件实际发生变化时进行重组,也就是在列表向下滚动后以及回滚到上方后。derivedStateOf能很好地处理这种情况。它会将繁忙的信息流转换为布尔条件,在碰到将状态转换为布尔条件的任何情况时都可以考虑derivedStateOf能否帮上忙。
但也要记住不适合使用derivedStateOf的情况。你不需要在每次创建衍生变量时都使用derivedStateOf。在下面示例中,我们想知道联系人列表中的项数,你可能会想,因为我们要衍生状态,所以应该使用derivedStateOf,但这是不合适的,因为这实际上不会滤除任何变化,也就是说,项计数变量需要更新的次数与计数状态的变化次数完全一样,derivedStateOf用在这里实际上还会增加开销并且是多余的。
val contacts by viewModel.contacts.observeAsState()
// DON'T
val contactCount = remember {
derivedStateOf {
contacts.size
}
}
// DO
val contactCount = contacts.size
第四,拖延(Read state only when required)
下面我们将看到的示例与其说是一个问题还不如说以一次机会的错失。设计人员要求我们用动画呈现界面的背景。我们希望方框的背景色在青色和海洋红色之间以动画效果不断地来回呈现。
并不推荐这样做,但我们可以试一下。在Compose中创建这些动画非常容易,这完全达到了设计人员的要求并且效果似乎非常好。但在这里,我们可以进行一项潜在的优化,这项优化可能会很难发现。
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSizd().background(color))
我们在要求Compose做很多不必要的工作,动画需要这段代码在每一帧都进行重组。
为了理解为什么在这里可能没必要组合,我们首先应该了解Compose的运作方式。Compose包含三个主要阶段,那就是组合、布局和绘制。
- 在第一阶段,系统会执行可组合函数,在此阶段,系统会创建或更新应用的内容并定义接下来两个阶段将要执行的工作。
- 在第二阶段,即布局阶段,系统会测量由组合定义的内容并确定要将这些元素都放在屏幕上的哪些位置。这个阶段要考虑所有修饰符及对其他可组合函数的所有调用。这些函数包括
Text、Row和Column等,并会在单词传递中测量和防止所有内容。 - 最后一个阶段,即绘制阶段,系统会发出实际图形指令将内容绘制到应用的画布。这些指令涉及的是图元。例如,绘制线条、弧形、矩形、图片和文字并在由上一阶段(即布局阶段)确定的位置绘制这些图元。
这三个阶段会在它们读取的数据发生变化的每一帧重复执行。但是,如果数据没有变化就可以跳过这三个阶段中的一个或多个。
由于在我们的应用中,动画效果的呈现会使颜色在每一帧都发生变化,因此每一帧也会发生组合,由于我们只绘制不同的颜色,以你最好只需用新颜色重新绘制方框而完全跳过组合和布局阶段。
将状态读取移植延迟到需要时是Compose的一个重要概念,延迟读取可以减少需要重新执行的函数。本例就是这样一种情况并且可让我们完全跳过组合阶段甚至是布局阶段。
在此版本中,我们使用drawBehind取代background。drawBehind接受在Compose的绘制阶段调用的函数实例。由于这是唯一一次读取颜色值。在颜色发生变化时只会绘制阶段的结果需要更改。这样,绘制阶段就变成唯一需要重新执行的阶段,让Compose可以跳过组合和布局这两个阶段
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier.fillMaxSize().drawBehind {
drawRect(color)
}
)
这里的绝妙之处是在函数实例中读取颜色状态,而不是在组合函数中读取。由于函数实例没有变化,因此它读取的变量保持不变。从组合的角度来讲没有任何变化,因此不需要重新执行此函数。
像这样在函数实例中读取状态并将它作为参数传递很实用。使用这种做法不仅可以如本例一样跳过一些阶段,还可以减少在状态发生变化时需要重新执行的代码量。充分利用这种做法的一种方式是使用嵌套,因为嵌套可以隐式创建函数实例。
@Composable
fun ContactCart(contact: Contact) {
MyCard {
Text("Name: ${contact.name}")
}
}
举例来说,当联系人的名字发生变化时,仅会重新执行对Text的调用,对ContactCard和MyCard的调用会被跳过,因为它们不会读取联系人的名字,只有对Text的调用会读取,这项调用在函数实例中捕获。因为重组可以从任何组合函数实例的开头重新开始,因此函数实例可用于减少所读取数据发生变化时需要重新执行的代码量。
第五、向后运行(Don't write to state you have alread read)
在上个实例中,代码能够运行但可以优化,下一个性能问题与之不同,它涉及我们应该始终避免的代码。
var balance by remember { mutableStateOf(0) }
balance = 0
for (transaction in transactions) {
Row {
balance += transaction
Text("Transaction: $transaction Balance: $balance")
}
}
设计人员要求我们显示银行交易列表和响余额,就像银行对账单上显示的一样。为此,我们计算余额的累计总计并根据每次交易进行更新,然后显示交易和新的余额。
但是我们面临一个问题,在我们深入探讨之前,你能找出这个问题吗?
当我们跟踪应用的系统轨迹时吗,我们开始意识到这里面存在问题,在构建应用的发布版本并确保根据本篇博客最前面进行配置后,我们使用Android Studio的内置性能分析器在CPU视图中跟踪系统轨迹。该轨迹就是我们现在看到的轨迹。
我们很快注意到应用的主线程比预期要忙碌得多。事实上,我们期望主线程变为闲置状态因为屏幕根本没有任何变化。
由于Android Studio自动为我们启用了应用轨迹标记,因此我们可以看到重组标记出现在每一帧中,我们可以辨别出它确实出现在每一帧,因为Android Studio用对比鲜明的颜色条突出显示了每一帧。现在组合一直在发生似乎永远不会停止。
让我们回到代码,并试着找出原因,最后证明,问题出在更新余额的代码行,这行代码违反了Compose的核心假设。Compose假设:值一旦被读取,在组合完成之前会保持不变。你绝不应对已在组合中读取的值进行写入,对已读取的数据进行写入正是我们所说的向后写入,这违反了Compose的核心假设,可能会导致在每一帧都发生重组,就像本例中的情况一样。
为了更好地理解何时会发生向后写入,我们再回去看看这段代码。向后写入发生在更新余额这行代码,但读取操作好像发生在Text调用中的写入操作之后,那这怎么会是向后写入呢?
如果我们想这样展开循环,向后写入就更容易看出来了,在balance读取数据之前向其写入数据没有问题,正是在循环本身中向balance写入数据导致组合始终认为它已过期,需要重新执行,更新以列表中的第二项开头的值是这个问题的根本原因。当组合认为它已过期时它会为下一帧调度一个新组合。如果该组合将它自身标记为已过期,组合将针对下一帧进行自我调度,无休无止。
val balance by remember { mutableStateOf(0) }
balance = 0
transaction = transactions[0]
balance += transaction
Text("Transaction: $transaction Balance: $balance")
transaction = transactions[1]
balance += transaction
Text("Transaction: $transaction Balance: $balance")
transaction = transaction[2]
balance += transaction
Text("Transaction: $transaction Balance: $balance")
这是一个更优的代码版本,该版本再次用到了我们熟悉的remember函数而且可完全避免写至状态。
该版本仅在可组合函数首次出现时,将它执行一次并且仅在交易发生变化时才被视为已过期。
val balance = remember(transactions) {
transactions.runningReduce { a, b -> a + b }
}
for ((transaction, balance) in transaction.zip(balance))
Text("Transaction: $transaction Balance: $balance")
一个比上述版本还要优秀的版本是,在ViewModel中计算余额,就像前面讲到的排序示例,在组合开始之前就让这些计算执行。
在进行这些更改后我们再开跟踪系统轨迹,看到了最初预期的结果:主线程一开始很忙碌随后变为闲置状态
进一步查看轨迹标记会发现组合仅在交易列表显示时运行了一次并且没有再次运行的调度。
因此请谨记,为了避免向后写入绝不要写至已经读取的状态。
第六、基本配置文件(Optimises startup and other critical paths)
在Android Studio中运行应用时,我们注意到前几秒钟好像会发生卡顿,但之后,看起来很流畅。
我们首先检查并确认配置没有问题启用了发布模式和R8优化功能,但这个问题依然存在,对于这个问题的成因,我们看到的事即时编译的影响。
从Android Studio运行应用时,经常在启动时出现性能下降的问题,因为代码需要解译。你的用户极有可能永远察觉不到这种问题。这多亏了我们的下一项优化,那就是基本配置文件,将基本配置文件添加到应用,有助于加快启动速度、减少卡顿以及提高性能。
但究竟什么是基本配置文件呢?
Compose是一个未捆绑库,因此它允许我们支持旧的Android版本和设备,而且能够轻松地使用新功能和bug修复来更新Compose。
我们不必等待Android升级就能将这些更改传送给你,但是,这会有一个小缺陷:Android在应用之间共享系统资源包括工具包类和可绘制对象,这会加快启动并减少内存耗用。由于Compose是一个未捆绑库,因此它不参与这种共享,只被视为应用的另一部分。
当用户从Play商店安装应用时,下载到设备的APK包含你的所有代码及与应用捆包的所有库。在启动时,此代码必须由Android运行时解译并被编译为机器代码。这个过程比较耗时,因此会降低性能。
Play商店已推出相关功能来改善这种状态,那就是云配置文件。Play商店会逐步汇总有关在应用启动时使用的类和方法的数据。这些数据只是在启动期间应用所使用代码的列表。
我们称这个列表为云配置文件。之后,Play商店会在其他用户下载你的应用时附带此配置文件。在Android时,Android运行时会使用这些数据来预编译列出的类和方法。这意味着在启动时解译的代码减少了。如果应用更新较为频繁,大约不到两周更新一次,那么用户可能永远无法真正体验到这个优势,每次更新应用时也会被清除现有的云配置文件数据。
你可以使用基本配置文件自行向Play商店提供此列表,也就是提供基本。当用户下载你的应用时,Play商店会包含你的基本配置文件以确保在安装时,始终有可用的配置文件数据。这样,运行时就知道要预编译的内容。
Compose也附带自己的基本配置文件,该配置文件默认包含在你的APK中,你可能根本不需要执行任何额外操作就可以感受到基本配置文件的优势,只需要知道它的存在就行了。
但从Android Studio运行应用时,基本配置文件不会包含在内,因此在本地测试中觉察不到这个优势。如果你注意到应用在首次启动时运行缓慢,然后变得越来越快,那么默认基本配置文件很有可能可以解决这个问题。
你可以使用测试库Macrobenchmark将应用配置为在启动时启用基本配置文件。这有助于测试用户从Play商店安装应用时获得的首次运行体验。
由于我们已经包括了针对Compose库经过优化的配置文件,因此添加自己的基本配置文件不再是一项性能提升保证,因为配置文件需要调整和优化。如果你确定要添加基本配置文件,请一定要进行测试确保它会切实改善指标。配置问价你的生成和这些优势的测试都是使用Macrobenchmark库完成的。
配置文件得到正确优化后会大幅提升应用的性能,将基本配置文件那添加到我们的其中一个Compose示例Jetsnack后,启动性能提高了22%,将配置文件添加到Google地图应用后,应用的平均启动时间缩减了30%,这项改进带来的不仅仅是启动性能的提升,将配置文件添加到Play商店应用后,搜索页的其实渲染时间能缩短40%。
从中可看出添加基本配置文件是提升应用性能的重要举措之一。
如需详细了解如何生成自己的配置文件以及如何配置Macrobenchmark来生成和测试配置文件,请参考下面的链接:
总结
- 我们介绍了如何配置应用以获得最佳性能。发现性能问题后,首先要检查的是在启动发布模式和R8优化功能后问题是否依然存在。
- 然后介绍了一些常见的错误及修复方法:
remember {}、LazyList key、derivedStateOf {}、延迟读取、向后写入和基本配置文件。