您可能会遇到常见的 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 会重组 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) )
在这里,盒子的背景颜色在两种颜色之间快速切换。因此,此状态变化非常频繁。然后,可组合项在背景修饰符中读取此状态。结果,盒子必须在每一帧上重组,因为颜色在每一帧上都在变化。
要改进这一点,请使用基于 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
示例中那样。
其他资源
为您推荐
- 注意:当 JavaScript 关闭时,会显示链接文本
- 状态和 Jetpack Compose
- 图形修饰符
- Compose 思维模式