遵循最佳实践

您可能会遇到常见的 Compose 陷阱。这些错误可能会导致您的代码看起来运行良好,但会损害 UI 性能。请遵循最佳实践以优化 Compose 上的应用。

使用 remember 最小化昂贵的计算

Composable 函数 可能非常频繁地运行,甚至可以像动画的每一帧一样频繁地运行。因此,您应该尽可能减少 Composable 主体中的计算量。

一项重要的技术是使用 存储计算结果 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, comparator) {
        contacts.sortedWith(comparator)
    }

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

现在,列表在 ContactList 首次组合时排序一次。如果联系人或比较器发生变化,则会重新生成排序后的列表。否则,Composable 可以继续使用缓存的排序列表。

使用延迟布局键

延迟布局 可有效地重用项目,仅在必须时重新生成或重新组合它们。但是,您可以帮助优化延迟布局以进行重新组合。

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

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

但是,此代码存在问题。假设底部的笔记发生了更改。它现在是最新的笔记,因此它会移动到列表的顶部,其他所有笔记都会向下移动一个位置。

在没有您的帮助的情况下,Compose 无法意识到未更改的项目只是在列表中移动。相反,Compose 认为旧的“项目 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 限制重新组合

在组合中使用状态的一个风险是,如果状态快速变化,您的 UI 可能会比您需要的次数更多地重新组合。例如,假设您正在显示一个可滚动的列表。您检查列表的状态以查看列表中第一个可见的项目是什么

val listState = rememberLazyListState()

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

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

这里的问题是,如果用户滚动列表,则 listState 会随着用户拖动手指而不断变化。这意味着列表会不断重新组合。但是,您实际上不需要那么频繁地重新组合它——您只需要在列表底部出现新项目时才需要重新组合。因此,这会产生很多额外的计算,从而导致 UI 性能下降。

解决方案是使用 派生状态。派生状态允许您告诉 Compose 哪些状态更改实际上应该触发重新组合。在本例中,指定您关心第一个可见项目何时更改。当状态值更改时,UI 需要重新组合,但如果用户尚未滚动足够多以将新项目置于顶部,则它不需要重新组合。

val listState = rememberLazyListState()

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

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

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

尽可能延迟读取

当识别出性能问题时,延迟状态读取可以提供帮助。延迟状态读取将确保 Compose 在重新组合时重新运行尽可能少的代码。例如,如果您的 UI 具有在 Composable 树中较高位置提升的状态,并且您在子 Composable 中读取该状态,则可以使用 lambda 函数包装状态读取。这样做会使读取仅在实际需要时发生。作为参考,请参阅 Jetsnack 示例应用中的实现。Jetsnack 在其详细信息屏幕上实现了类似于折叠工具栏的效果。要了解此技术为何有效,请参阅博文 Jetpack Compose:调试重新组合

为了实现此效果,Title Composable 需要滚动偏移量才能使用 Modifier 对其自身进行偏移。以下是优化前 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 会使最近的父重新组合范围失效。在本例中,最近的范围是 SnackDetail Composable。请注意,Box 是一个内联函数,因此不是重新组合范围。因此,Compose 会重新组合 SnackDetailSnackDetail 内部的任何 Composable。如果您更改代码以仅在实际使用状态的地方读取状态,则可以减少需要重新组合的元素数量。

@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

这是一个很好的改进,但您还可以做得更好!如果您只是为了重新布局或重新绘制 Composable 而导致重新组合,则应该对此表示怀疑。在本例中,您所做的只是更改 Title Composable 的偏移量,这可以在布局阶段完成。

@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)
)

在此,框的背景颜色在两种颜色之间快速切换。因此,此状态变化非常频繁。然后,Composable 在背景修饰符中读取此状态。结果,框必须在每一帧上重新组合,因为颜色在每一帧上都在变化。

为了改进这一点,请使用基于 lambda 的修饰符——在本例中为 drawBehind。这意味着颜色状态仅在绘制阶段读取。结果,Compose 可以完全跳过组合和布局阶段——当颜色发生变化时,Compose 直接进入绘制阶段。

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

避免反向写入

Compose 的一个核心假设是您永远不会写入已读取的状态。当您这样做时,这称为反向写入,它会导致每一帧都发生重新组合,并且会无限循环。

以下 Composable 显示了此类错误的示例。

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

此代码在前面一行读取计数后,在 Composable 的末尾更新计数。如果运行此代码,您会发现单击按钮(导致重新组合)后,计数器会以无限循环的方式快速增加,因为 Compose 会重新组合此 Composable,看到一个已过期的状态读取,因此会安排另一个重新组合。

您可以通过永远不在 Composition 中写入状态来完全避免反向写入。如果可能,请始终响应事件并在 lambda 中写入状态,如前面的 onClick 示例中所示。

其他资源