Compose 性能

使用集合让一切井井有条 根据您的偏好保存内容并对其进行分类。

Jetpack Compose 旨在提供开箱即用的卓越性能。本页介绍了如何编写和配置应用以实现最佳性能,并指出了一些需要避免的模式。

在阅读本文之前,建议先熟悉 Compose 编程思想中的核心 Compose 概念。

正确配置您的应用

如果您的应用性能不佳,则可能意味着存在配置问题。建议首先检查以下配置选项。

在发布模式下构建并使用 R8

如果遇到性能问题,请务必尝试在发布模式下运行应用。调试模式有助于发现大量问题,但是会产生严重的性能开销,而且可能会难以发现其他可能影响性能的代码问题。您还应使用 R8 编译器从应用中移除不必要的代码。默认情况下,在发布模式下构建时会自动使用 R8 编译器。

使用基准配置文件

Compose 以库的形式分发,而不是作为 Android 平台的一部分分发。这种方式让我们能够经常更新 Compose,并且支持旧版 Android。然而,将 Compose 作为库分发会产生性能开销。Android 平台代码已完成编译并安装到设备上。另一方面,您需要在应用启动时加载库,并在需要功能时即时对库进行解释。这样一来,应用在启动时以及每次首次使用一个库功能时就会变得很慢。

您可以通过定义基准配置文件来提升性能。这些配置文件将定义关键用户历程所需的类和方法,并与应用的 APK 一起分发。在应用安装期间,ART 会预先编译该关键代码,以确保在应用启动时可供使用。

要定义一个良好的基准配置文件并不容易,因而此 Compose 随带了一个默认的基准配置文件。您无需执行任何操作即可直接使用该配置文件。但是,如果您选择定义自己的配置文件,则可能会生成一个无法实际提升应用性能的配置文件。您应当测试该配置文件并验证它是否有效。为此,建议为您的应用编写一些 Macrobenchmark 测试,并根据测试结果来编写和修改基准配置文件。如需查看如何为 Compose 界面编写 Macrobenchmark 测试的示例,请参阅 Macrobenchmark Compose 示例

如需详细了解发布模式、R8 和基准配置文件带来的影响,请参阅博文为什么应始终在发布版本中测试 Compose 性能?

三个 Compose 阶段对性能的影响

Jetpack Compose 阶段中所述,当 Compose 更新帧时,它会经历三个阶段:

  • 组合:Compose 确定要显示的内容 - 运行可组合函数并构建界面树。
  • 布局:Compose 确定界面树中每个元素的尺寸和位置
  • 绘图:Compose 实际渲染各个界面元素。

Compose 可以智能地跳过其中任何不需要的阶段。例如,假设单个图形元素在尺寸相同的两个图标之间切换。由于该元素不会改变尺寸,而且界面树中不会添加或移除任何元素,因此 Compose 可以跳过组合阶段和布局阶段,只重新绘制该元素。

但是,一些编码错误可能会导致 Compose 更加难以确定可以安全地跳过哪些阶段。如果存在任何疑虑,Compose 最终会运行所有三个阶段,而这可能会导致界面运行速度变慢。因此,许多提升性能的最佳做法都侧重于帮助 Compose 跳过不需要的阶段。

总体而言,应当遵循一些可提升性能的基本原则。

首先,尽可能从可组合函数中移除计算。每当界面发生变化时,都可能需要重新运行可组合函数;可能对于动画的每一帧,都会重新执行您在可组合函数中放置的所有代码。因此,您应当仅在可组合函数中放置构建界面实际所需的代码。

其次,请尽可能延后读取状态。通过将状态读取移至可组合子项或后续阶段,您可以尽可能减少重组或完全跳过组合阶段。为此,您可以传递 lambda 函数(而不是状态值)来表示经常更改的状态,并在传入经常更改的状态时优先使用基于 lambda 的修饰符。您可以在尽可能延后读取状态部分查看此技巧的示例。

下文将介绍一些可能导致此类问题的代码错误。希望本文中介绍的具体示例也有助于您发现代码中的其他类似错误。

使用工具帮助发现问题

您可能很难确定何处存在性能问题以及要开始优化哪些代码。可以使用工具帮助缩小问题范围。

获取重组次数

您可以使用布局检查器检查某个可组合项的重组频率或跳过重组的频率。

布局检查器中显示的重组次数

如需了解详情,请参阅工具部分

遵循最佳做法

您可能会遇到一些常见的 Compose 问题。这些错误可能不会影响代码的正常运行,但是可能会对界面性能造成负面影响。本部分列出了一些最佳做法,可帮助您避免这些错误。

使用 remember 尽可能降低计算开销

可组合函数可能会非常频繁地运行,可能针对动画中的每一帧都运行一次。因此,您应当在可组合函数的主体部分中尽可能减少计算。

一种重要的技巧是使用 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 时都会重新对整个联系人列表进行排序,即使该列表并没有发生变化。如果用户滚动列表,则每当出现新行时,可组合函数都会执行重组。

如需解决此问题,请在 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 首次组合时执行一次排序。如果联系人或比较器发生变化,则系统会重新生成经过排序的列表。否则,可组合函数会继续使用缓存中的已排序列表。

使用延迟布局键

延迟布局会尽可能智能化地重用各个项,即仅在必要时才会重新生成或重组这些项。但是,您可以帮助其做出最佳决策。

假设某项用户操作会导致项在列表中移动。例如,假设您显示一个按修改时间排序的备注列表,最近修改的备注位于最顶部。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

但这段代码有一个问题。假设底部的备注发生了更改。它现在是最近修改过的备注,因此会移到列表顶部,而其他备注都会向下移动一个位置。

问题在于,如果您未提供帮助,则 Compose 并不会意识到未更改的项只在列表中发生了移动。Compose 会认为旧的“项 2”已被删除,并且创建了一个新的“项 2”;对于项 3、项 4 一直到最后一个项,都依此类推。其结果是,Compose 会重组列表中的每一个项,即使其中只有一个项实际发生了更改。

此处的解决方案是提供项键。为每一个项提供一个稳定的键可确保 Compose 避免不必要的重组。在这种情况下,Compose 可以看到现在位于位置 3 的项与之前位置 2 上的项是相同的。由于该项数据并未发生任何更改,因此 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 限制重组

在组合中使用状态的一项风险就是,如果状态快速变化,则界面的重组次数可能会超出您的实际需求。例如,假设您要显示一个可滚动列表。您可以检查列表状态并确定列表中的哪一项是第一个可见项:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

问题在于,当用户滚动列表时,listState 会随着用户拖动手指而不断变化。这意味着该列表会不断重组。但是,您实际上并不需要如此频繁地进行重组 - 在底部显示新项之前,并不需要重组。因此,这将完成大量的额外计算,从而导致界面性能较差。

解决方案是使用派生状态借助派生状态,您可以告知 Compose 哪些状态更改应当实际触发重组。针对本例,请指定需要跟踪第一个可见项发生更改的时间。当该状态值发生更改时,界面需要重组。而如果用户尚未滚动到新项,则不需要重组。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

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

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

尽可能延后读取

您应当尽可能延后读取状态变量。延后读取状态有助于确保 Compose 在重组时重新运行尽可能少的代码。例如,如果界面的状态在可组合项树中向上提升,而您在可组合子项中读取状态,则可以将状态封装在 lambda 函数中。这种方式可以确保仅在实际需要时才会执行读取操作。您可以了解我们如何将此方法应用于 Jetsnack 示例应用。Jetsnack 在其详情屏幕中实现了类似于工具栏的折叠效果。

为了实现这种效果,Title 可组合项需要知晓滚动偏移,以便于使用 Modifier 实现自行偏移。下面列出了未进行优化的简化版 Jetnack 代码:

@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 中的所有可组合项进行重组。如果您将代码更改为仅在实际使用时才读取状态,则可以减少需要重组的元素数量。

@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 可组合项 - Compose 不再需要重组整个 Box

这是一项非常重大的改进,但是您还可以做得更好!如果您触发重组只是为了重新布局或重新绘制可组合项,那么您肯定会充满了疑惑。在本例中,您只是更改了 Title 可组合项的偏移量,而此操作可以在布局阶段完成。

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
      // ...
    }
}

之前,代码使用 Modifier.offset(x: Dp, y: Dp) 接受偏移量作为参数。通过切换为 lambda 版本的修饰符,您可以确保函数在布局阶段读取滚动状态。因此,当滚动状态发生变化时,Compose 可以完全跳过组合阶段,而直接进入布局阶段。当您将频繁更改的状态变量传递到修饰符中时,应当尽可能使用其 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))

在此代码中,Box 的背景颜色会在两种颜色之间快速切换。因此,其状态也会非常频繁地变化。随后,可组合项会在后台修饰符中读取此状态。因此,该 Box 在每一帧上都需要重组,因为其颜色在每一帧中都会发生变化。

为了改进这段代码,我们可以使用基于 lambda 的修饰符,在本例中为 drawBehind。这将仅在绘制阶段读取颜色状态。因此,Compose 可以完全跳过组合阶段和布局阶段 - 当颜色发生变化时,Compose 会直接进入绘制阶段。

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

避免向后写入

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 执行重组,随后计数器会进入无限增长的循环,状态读取会过期。因此,Compose 会再次安排重组。

您完全可以避免向后写入数据,只需避免在组合中写入状态即可。请尽可能在响应事件时写入状态,并采用 lambda 的形式,如上文中的 onClick 示例所示。