前言
Jetpack Compose的应用也逐渐广泛起来,由于声明式UI的特点,Compose在开发的易用性方面有较大优势,但相信很多人对于Compose的性能问题有一些疑问。
这些问题有些是因为Compose还是个新生事物,不够成熟导致的,有些则是因为开发者的使用不当导致的。
本文主要介绍如何编写和配置应用程序以获得最佳性能,并指出了一些要避免的问题。
正确配置应用
如果您的Compose应用性能不佳,则可能意味着存在配置问题。 首先应该检查以下配置项
使用Release模式构建并且使用R8
如果您发现性能问题,请确保尝试在Release模式下运行您的应用。Debug模式对于发现许多问题很有用,但它会带来显着的性能成本,并且很难发现可能影响性能的其他代码的问题。
同时您还应该使用 R8 编译器从您的应用程序中删除不必要的代码。 默认情况下,在Release模式下构建会自动使用 R8 编译器。
使用baseline profile
Compose 作为一个单独的库分发,而不是作为 Android 平台的一部分。这种方法让我们可以经常更新 Compose 并支持较旧的 Android 版本。但是,将 Compose 作为库分发也会产生一定的成本。 Android 平台代码已编译并安装在设备上。另一方面,库需要在应用程序启动时加载,并在需要功能时及时解释(即JIT)。这可能会在启动时减慢应用程序的速度,并且每当它首次使用库功能时。
您可以通过定义baseline profile来提高性能。这些配置文件定义了用户主流程所需的类和方法,并与您应用的 APK 一起分发。在应用程序安装期间,ART 会提前编译该关键代码(即AOT),以便在应用程序启动时准备好使用。
定义一个好的baseline profile并不总是那么容易,因此 Compose 默认附带一个。因此默认情况下你不需要做任何额外工作。
同时,如果您选择定义自己的配置文件,您可能会生成一个实际上不会提高应用程序性能的配置文件。您应该测试配置文件以验证它是否有帮助。一个很好的方法是为您的应用程序编写 Macrobenchmark测试,并在您编写和修改baseline profile时检查测试结果。有关如何为 Compose UI 编写 Macrobenchmark 测试的示例,请参阅 Macrobenchmark Compose示例。
总得来说,使用baseline profile即通过AOT取代JIT,加快Compose首次运行的速度。
在默认情况下Compose已经自带了一个默认的baseline profile,你不需要做什么额外工作就可以支持。
但如果你要自定义baseline profile的话,需要做好测试用例,验证自定义的配置是否有效。
自定义baseline profile比较麻烦,不过根据Google I/O上给出的数据,可以达到20%到30%的启动性能提升,大家可以根据情况决定是否使用

关于Compose的一些最佳实践
在编写Compose代码时你可能会碰到一些常见的错误。这些错误不影响运行,但会损害您的 UI 性能。本节列出了一些最佳实践来帮助您避免它们。
使用remember减少计算
Compose函数可以非常频繁地运行,就像动画的每一帧一样频繁。 出于这个原因,你应该尽可能少地在Compose中做计算。
最常见的就是使用remember, 这样,计算只运行一次,并且可以在需要时获取结果。
例如,这里有一些代码显示排序的名称列表,其中排序操作比较耗时
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// DON’T DO THIS
items(contacts.sortedWith(comparator)) { contact ->
// ...
}
}
}
问题在于,每次重组 ContactsList 时,整个联系人列表都会重新排序,即使列表没有更改。 如果用户滚动列表,只要出现新行,Composable 就会重新组合。
为了解决这个问题,在LazyColumn 之外对列表进行排序,并使用 remember 存储排序后的列表:
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
val sortedContacts = remember(contacts, sortComparator) {
contacts.sortedWith(sortComparator)
}
LazyColumn(modifier) {
items(sortedContacts) {
// ...
}
}
}
如上,当第一次组成 ContactList 时,列表被排序一次。 如果联系人或比较器更改,则重新生成排序列表。 否则,可组合项可以继续使用缓存的排序列表。
当然:如果可能,最好将计算完全移到Compose之外,比如ViewModel
Lazy Layout使用Key
Lazy Layout使用智能重组,仅在必要时才会发生重组。 同时,我们可以帮助它做出最佳决策。
假设用户操作导致item在列表中移动。 例如,假设您显示按修改时间排序的笔记列表,最近修改的笔记在最上面。
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes
) { note ->
NoteRow(note)
}
}
}
不过,这段代码存在一定的问题。 假设底部的note发生了变化。 它现在是最近修改的note,所以它应该排在列表的顶部,而其他每个note都向下移动一个位置。
这里的问题是,没有您的帮助,Compose 不会意识到未更改的项目只是在列表中移动。 相反,Compose 认为旧的“第 2 项”被删除并创建了一个新的,依此类推,第 3 项、第 4 项一直如此。 结果是,Compose 会重新组合列表中的每一项,即使其中只有一项实际发生了变化。
解决方案是提供item key。 为每个item提供一个稳定的key可以让 Compose 避免不必要的重组。 在这种情况下,Compose 可以看到现在位于第 3 项和item过去位于第 2 的item相同。由于该项目的数据都没有更改,因此 Compose 不必重新组合它。
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes,
key = { note ->
// Return a stable, unique key for the note
note.id
}
) { note ->
NoteRow(note)
}
}
}
使用 derivedStateOf 限制重组
在重组中使用状态的一个风险是,如果状态快速变化,你的 UI 可能会比你预期的发生更多的重组。
例如,假设您正在显示一个可滚动的列表。 您检查列表的状态以查看哪个item是列表中的第一个可见item:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
这里的问题在于,如果用户滚动列表,listState 会随着用户拖动手指而不断变化。 这意味着该列表不断被重新组合,而showButton的结果也会被不断计算。
但是,您只有在firstVisibleItemIndex发生变化时才需要计算showButton。 所以,这里多了很多额外的计算,会影响您的UI性能
解决方案是使用derivedStateOf。 derivedStateOf告诉Compose只有当我们关心的状态发生变化时,才需要重组。
在这种情况下,当firstVisibleItemIndex发生变化时才需要重组。但如果用户还没有滚动到足以将新item带到顶部的程度,则不需要重新组合。
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
注意,如果把状态都放在ViewModel里,也就不用考虑这个了
尽可能延迟读取State
您应该尽可能推迟读取State。 延迟读取State有助于确保 Compose 在重组时重新运行尽可能少的代码。 例如,如果您的 UI 具有在composable树中高高提升的状态,并且您在子composable中读取状态,则可以将读取的状态包装在 lambda 函数中。 这样做会使读取仅在实际需要时发生。
我们来看一段Jetsnack在列表滚动时实现Title折叠展开的代码,为了达到这个效果,Title composable需要知道滚动偏移量,以便使用修饰符来偏移自己。 在进行优化之前,这是Jetsnack代码的简化版本:
@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, scroll: Int) {
// ...
val offset = with(LocalDensity.current) { scroll.toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
当滚动状态发生变化时,Compose 会寻找最近的父重组作用域并使其无效。 在这种情况下,最近的父级是可组合的是Box。 因此 Compose 重组了 Box,并且还重组了 Box 内的任何可组合项。 如果您将代码重构为仅读取您需要的State,那么您可以减少需要重组的元素数量。
@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)
) {
// ...
}
}
滚动参数现在是一个 lambda。 这意味着 Title 仍然可以引用被提升的状态,但该值只能在 Title 内部读取,也就是实际需要的地方。 这样一来,当滚动值发生变化时,最近的重组范围现在是 Title composable,因此Compose 不再需要重组整个 Box。
这是一个很好的改进,但我们还可以做得更好。 因为我们所做的只是更改可组合标题的偏移量,这可以在布局阶段完成,而不必经过组合阶段。
Compose的阶段
与大多数其他界面工具包一样,Compose 会通过几个不同的“阶段”来渲染帧。如果我们观察一下 Android View 系统,就会发现它有 3 个主要阶段:测量、布局和绘制。Compose 和它非常相似,但开头多了一个叫做“组合”的重要阶段。
Compose 有 3 个主要阶段:
- 组合:要显示什么样的界面。
Compose运行可组合函数并创建界面说明。 - 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据
2D坐标来测量并放置自己及其所有子元素。 - 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。

优化状态读取
知道了Compose的3个阶段,Compose 会执行局部状态读取跟踪,因此我们可以在适当阶段读取每个状态,从而尽可能降低需要执行的工作量
如果我们在布局阶段读取状态,就可以跳过组合阶段,如果我们在绘制阶段读取状态,就可以跳过组合和布局

因此我们可以将offset的读取推迟到布局阶段,这样可以避免组合阶段重新执行
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
Column(
modifier = Modifier
.offset { IntOffset(y = scrollProvider()) }
) {
// ...
}
}
之前代码使用了Modifier.offset(x: Dp, y: Dp),它以偏移量为参数。 通过切换到修饰符的 lambda 版本,您可以确保函数在布局阶段读取滚动状态。 因此,当滚动状态发生变化时,Compose 可以完全跳过组合阶段,直接进入布局阶段。 当您将频繁更改的状态变量传递给modifier时,应尽可能使用modifier的 lambda 版本。
绘制阶段读取状态的一个例子
上面我们看了一个在布局阶段读取状态的例子,下面来看一下绘制阶段读取状态的一个例子
// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSize().background(color))
这里盒子的背景颜色在两种颜色之间快速切换。 因此,这种状态变化非常频繁。 然后可组合项在背景modifier中读取此状态。 结果,盒子必须在每一帧上重新组合,因为每一帧的颜色都在变化。
为了改善这一点,我们可以使用基于 lambda 的modifier:在本例中为 drawBehind。这意味着仅在绘制阶段读取颜色状态。
因此,Compose 可以完全跳过组合和布局阶段,当颜色发生变化时,Compose 会直接进入绘制阶段。
避免反向写入
Compose 有一个核心假设,即您永远不会写入已读取的状态。 当你这样做时,它被称为反向写入,它会导致重组在每一帧上发生,无休止。
以下代码显示了此类错误的示例。
@Composable
fun BadComposable() {
var count by remember { mutableStateOf(0) }
// Causes recomposition on click
Button(onClick = { count++ }, Modifier.wrapContentSize()) {
Text("Recompose")
}
Text("$count")
count++ // Backwards write, writing to state after it has been read
}
这段代码在读取了状态之后在可组合项末尾的更新了状态。 如果您运行此代码,您会看到在单击导致重组的按钮后,随着 Compose 重组此 Composable,计数器会在无限循环中迅速增加,看到读取的状态已过期,因此会安排另一个重组 .
您可以通过从不在 Composition 中写入状态来完全避免向后写入。 如果可能,请始终响应事件来更新状态,并使用 lambda 表达式,就像前面的 onClick 示例一样。
总结
本文主要介绍如何编写和配置Compose以获得最佳性能,并指出了一些要避免的问题。
