前言
Jetpack Compose
的应用也逐渐广泛起来,由于声明式UI
的特点,Compose
在开发的易用性方面有较大优势,但相信很多人对于Compose
的性能问题有一些疑问。
这些问题有些是因为Compose
还是个新生事物,不够成熟导致的,有些则是因为开发者的使用不当导致的。
本文主要介绍如何编写和配置应用程序以获得最佳性能,并指出了一些要避免的问题。
正确配置应用
如果您的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
以获得最佳性能,并指出了一些要避免的问题。