Google I/O : Jetpack Compose 中常见的性能问题
Posted 初一十五啊
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Google I/O : Jetpack Compose 中常见的性能问题相关的知识,希望对你有一定的参考价值。
一丶前言
Jetpack Compose
的应用也逐渐广泛起来,由于声明式UI
的特点,Compose
在开发的易用性方面有较大优势,但相信很多人对于Compose
的性能问题有一些疑问。
这些问题有些是因为Compose
还是个新生事物,不够成熟导致的,有些则是因为开发者的使用不当导致的。
本文主要介绍如何编写和配置应用程序以获得最佳性能,并指出了一些要避免的问题。
更多内容参考:
GitHub
二丶正确配置应用
如果您的Compose
应用性能不佳,则可能意味着存在配置问题。 首先应该检查以下配置项
- 使用
Release
模式构建并且使用R8
如果您发现性能问题,请确保尝试在Release
模式下运行您的应用。Debug
模式对于发现许多问题很有用,但它会带来显着的性能成本,并且很难发现可能影响性能的其他代码的问题。
同时您还应该使用 R8
编译器从您的应用程序中删除不必要的代码。 默认情况下,在Release
模式下构建会自动使用 R8
编译器。
- 使用
baseline profile
Compose
作为一个单独的库分发,而不是作为 android
平台的一部分。这种方法让我们可以经常更新 Compose
并支持较旧的 Android
版本。但是,将 Compose
作为库分发也会产生一定的成本。 Android
平台代码已编译并安装在设备上。另一方面,库需要在应用程序启动时加载,并在需要功能时及时解释(即JIT
)。这可能会在启动时减慢应用程序的速度,并且每当它首次使用库功能时。
您可以通过定义baseline profile
来提高性能。这些配置文件定义了用户主流程所需的类和方法,并与您应用的 APK
一起分发。在应用程序安装期间,ART
会提前编译该关键代码(即AOT
),以便在应用程序启动时准备好使用。
定义一个好的baseline profile
并不总是那么容易,因此 Compose
默认附带一个。因此默认情况下你不需要做任何额外工作。
同时,如果您选择定义自己的配置文件,您可能会生成一个实际上不会提高应用程序性能的配置文件。您应该测试配置文件以验证它是否有帮助。一个很好的方法是为您的应用程序编写 Macrobenchmark测试,并在您编写和修改baseline profile
时检查测试结果。有关如何为 Compose UI
编写 Macrobenchmark
测试的示例,请参阅Macrobenchmark Compose 示例。
总得来说,使用baseline profile
即通过AOT
取代JIT
,加快Compose
首次运行的速度。
在默认情况下Compose
已经自带了一个默认的baseline profile
,你不需要做什么额外工作就可以支持。
但如果你要自定义baseline profile
的话,需要做好测试用例,验证自定义的配置是否有效。
自定义baseline profile
比较麻烦,不过根据Google I/O
上给出的数据,可以达到20%到30%的启动性能提升,大家可以根据情况决定是否使用
三丶关于Compose
的一些最佳实践
在编写Compose
代码时你可能会碰到一些常见的错误。这些错误不影响运行,但会损害您的 UI
性能。本节列出了一些最佳实践来帮助您避免它们。
- 使用
remember
减少计算
Compose
函数可以非常频繁地运行,就像动画的每一帧一样频繁。 出于这个原因,你应该尽可能少地在Compose
中做计算。
最常见的就是使用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, sortComparator)
contacts.sortedWith(sortComparator)
LazyColumn(modifier)
items(sortedContacts)
// ...
如上,当第一次组成 ContactList
时,列表被排序一次。 如果联系人或比较器更改,则重新生成排序列表。 否则,可组合项可以继续使用缓存的排序列表。
当然:如果可能,最好将计算完全移到Compose
之外,比如ViewModel
Lazy Layout
使用Key
Lazy Layout
使用智能重组,仅在必要时才会发生重组。 同时,我们可以帮助它做出最佳决策。
假设用户操作导致item
在列表中移动。 例如,假设您显示按修改时间排序的笔记列表,最近修改的笔记在最上面。
@Composable
fun NotesList(notes: List<Note>)
LazyColumn
items(
items = notes
) note ->
NoteRow(note)
不过,这段代码存在一定的问题。 假设底部的note
发生了变化。 它现在是最近修改的note
,所以它应该排在列表的顶部,而其他每个note
都向下移动一个位置。
这里的问题是,没有您的帮助,Compose
不会意识到未更改的项目只是在列表中移动。 相反,Compose
认为旧的“第 2 项”被删除并创建了一个新的,依此类推,第 3 项、第 4 项一直如此。 结果是,Compose
会重新组合列表中的每一项,即使其中只有一项实际发生了变化。
解决方案是提供item key
。 为每个item
提供一个稳定的key
可以让 Compose
避免不必要的重组。 在这种情况下,Compose
可以看到现在位于第 3 项和item
过去位于第 2 的item
相同。由于该项目的数据都没有更改,因此 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
可能会比你预期的发生更多的重组。
例如,假设您正在显示一个可滚动的列表。 您检查列表的状态以查看哪个item
是列表中的第一个可见item
:
val listState = rememberLazyListState()
LazyColumn(state = listState)
// ...
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton)
ScrollToTopButton()
这里的问题在于,如果用户滚动列表,listState
会随着用户拖动手指而不断变化。 这意味着该列表不断被重新组合,而showButton
的结果也会被不断计算。
但是,您只有在firstVisibleItemIndex
发生变化时才需要计算showButton
。 所以,这里多了很多额外的计算,会影响您的UI
性能
解决方案是使用derivedStateOf
。 derivedStateOf
告诉Compose
只有当我们关心的状态发生变化时,才需要重组。
在这种情况下,当firstVisibleItemIndex
发生变化时才需要重组。但如果用户还没有滚动到足以将新item
带到顶部的程度,则不需要重新组合。
val listState = rememberLazyListState()
LazyColumn(state = listState)
// ...
val showButton by remember
derivedStateOf
listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton)
ScrollToTopButton()
注意,如果把状态都放在ViewModel
里,也就不用考虑这个了
- 尽可能延迟读取
State
您应该尽可能推迟读取State
。 延迟读取State
有助于确保 Compose
在重组时重新运行尽可能少的代码。 例如,如果您的 UI
具有在composable
树中高高提升的状态,并且您在子composable
中读取状态,则可以将读取的状态包装在 lambda
函数中。 这样做会使读取仅在实际需要时发生。
我们来看一段Jetsnack
在列表滚动时实现Title
折叠展开的代码,为了达到这个效果,Title composable
需要知道滚动偏移量,以便使用修饰符来偏移自己。 在进行优化之前,这是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
会寻找最近的父重组作用域并使其无效。 在这种情况下,最近的父级是可组合的是Box
。 因此 Compose
重组了 Box
,并且还重组了 Box
内的任何可组合项。 如果您将代码重构为仅读取您需要的State
,那么您可以减少需要重组的元素数量。
@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
。
这是一个很好的改进,但我们还可以做得更好。 因为我们所做的只是更改可组合标题的偏移量,这可以在布局阶段完成,而不必经过组合阶段。
Compose
的阶段
与大多数其他界面工具包一样,Compose
会通过几个不同的“阶段”来渲染帧。如果我们观察一下 Android View
系统,就会发现它有 3 个主要阶段:测量、布局和绘制。Compose
和它非常相似,但开头多了一个叫做“组合”的重要阶段。
Compose
有 3 个主要阶段:
- 组合:要显示什么样的界面。
Compose
运行可组合函数并创建界面说明。 - 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据
2D
坐标来测量并放置自己及其所有子元素。 - 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。
优化状态读取
知道了Compose
的3个阶段,Compose
会执行局部状态读取跟踪,因此我们可以在适当阶段读取每个状态,从而尽可能降低需要执行的工作量
如果我们在布局阶段读取状态,就可以跳过组合阶段,如果我们在绘制阶段读取状态,就可以跳过组合和布局
因此我们可以将offset
的读取推迟到布局阶段,这样可以避免组合阶段重新执行
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int)
// ...
Column(
modifier = Modifier
.offset IntOffset(y = scrollProvider())
)
// ...
之前代码使用了Modifier.offset(x: Dp, y: Dp)
,它以偏移量为参数。 通过切换到修饰符的 lambda
版本,您可以确保函数在布局阶段读取滚动状态。 因此,当滚动状态发生变化时,Compose
可以完全跳过组合阶段,直接进入布局阶段。 当您将频繁更改的状态变量传递给modifier
时,应尽可能使用modifier
的 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))
这里盒子的背景颜色在两种颜色之间快速切换。 因此,这种状态变化非常频繁。 然后可组合项在背景modifier
中读取此状态。 结果,盒子必须在每一帧上重新组合,因为每一帧的颜色都在变化。
为了改善这一点,我们可以使用基于 lambda
的modifier
:在本例中为 drawBehind
。这意味着仅在绘制阶段读取颜色状态。
因此,Compose
可以完全跳过组合和布局阶段,当颜色发生变化时,Compose
会直接进入绘制阶段。
- 避免反向写入
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
重组此 Composable
,计数器会在无限循环中迅速增加,看到读取的状态已过期,因此会安排另一个重组 .
您可以通过从不在 Composition
中写入状态来完全避免向后写入。 如果可能,请始终响应事件来更新状态,并使用 lambda
表达式,就像前面的 onClick
示例一样。
四丶总结
本文主要介绍如何编写和配置Compose
以获得最佳性能,并指出了一些要避免的问题
转载:
作者:程序员江同学
链接:https://juejin.cn/post/7097066597222711327
更多内容参考:
GitHub
以上是关于Google I/O : Jetpack Compose 中常见的性能问题的主要内容,如果未能解决你的问题,请参考以下文章
谷歌 I/O 深度解析:Android Jetpack 最新变化
谷歌 I/O 深度解析:Android Jetpack 最新变化