遵循最佳做法

您可能会遇到常见的 Compose 误区。这些错误可能会使代码看起来运行良好,但可能会影响界面性能。请按照最佳实践在 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, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

现在,列表只会在 ContactList 首次组合时执行一次排序。如果联系人或比较器发生变化,则系统会重新生成经过排序的列表。否则,可组合函数会继续使用缓存中的已排序列表。

使用延迟布局键

延迟布局可以高效地重复使用列表项,仅会在必要时重新生成或重组列表项。不过,您可以帮助优化延迟布局以进行重组。

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

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

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

如果您未提供帮助,则 Compose 不会意识到未更改的项只是在列表中移动而来。相反,Compose 会认为旧的“项 2”已被删除,并针对项 3、项 4 一直到最后为项 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 在其详情屏幕上实现了类似于工具栏的收起效果。如需了解此方法为何有效,请参阅博文 Jetpack Compose:调试重组

为了实现这种效果,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 会使最近的父级重组范围失效。在本例中,最接近的范围是 SnackDetail 可组合项。请注意,Box 是一个内联函数,因此不是重组作用域。因此,Compose 会对 SnackDetail 以及 SnackDetail 内的所有可组合项进行重组。如果您将代码更改为仅读取您实际使用的状态,则可以减少需要重组的元素数量。

@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</b>
}

此代码在上一行中读取计数后,会更新可组合项末尾的计数。如果运行此代码,您会看到:在您点击会导致重组的按钮后,计数器会在无限循环中快速增加,因为 Compose 会重组此可组合项,看到过时的状态读取,因此会安排另一项重组。

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

其他资源