遵循最佳实践

您可能会遇到常见的 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 以及所有后续项目创建了一个新的。结果是 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 在重组时重新运行尽可能少的代码。例如,如果您的 UI 具有在可组合树中提升的较高状态,并且您在子可组合项中读取该状态,则可以将状态读取封装在 lambda 函数中。这样做可以使读取仅在实际需要时才发生。作为参考,请参阅 Jetsnack 示例应用中的实现。Jetsnack 在其详细信息屏幕上实现了类似可折叠工具栏的效果。要理解此技术为何有效,请参阅博客文章 Jetpack Compose: Debugging Recomposition

为了达到这种效果,Title 可组合项需要滚动偏移量,以便使用 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 可组合项。请注意,Box 是一个内联函数,因此不是重组范围。所以 Compose 会重组 SnackDetailSnackDetail 内部的任何可组合项。如果您更改代码,只在实际使用状态时才读取状态,那么您可以减少需要重组的元素数量。

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

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

要改进这一点,请使用基于 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 示例中那样。

其他资源