时至今日(2023-11-27),Jetpack Compose 版本已经更新到了1.5.4,基础的功能组件已经能满足大部分开发需求。而性能问题作为早期最为被人诟病的问题之一,也越来越被官方重视。官方也更新了性能最佳实践文档。
本篇文章综合了官方的性能最佳实践文档和其他参考文档,并增加了示例。限于作者水平有限,难免有错漏,欢迎指出。
注意:本篇文章的例子大部分基于Jetpack Compose 1.5.4
,运行于Pixel 5, Android 14的真机上,环境和版本不同的话,运行结果可能有所不同。
Jetpack Compose 基本原理
在讨论性能话题之前,有必要了解一点Jetpack Compose的实现原理,以帮助我们更好地理解性能问题发生的原因及解决办法。这里不会深入讨论Jetpack Compose的实现细节,只涉及与性能相关的部分。
声明式UI范式
我们知道,Jetpack Compose是一个声明式UI范式的实现,对于声明式UI的详细介绍可以参见其他文章,这里通过一张图片(来自Flutter官网)来简单说明:
声明式UI有两个特点:
- 类似于函数,作为输入的状态改变了,构建方法就需要重新运行以生成新的UI,就像Jetpack Compose文档中说的:
The technique works by conceptually regenerating the entire screen from scratch, then applying only the necessary changes.
- 开发者只需要提供UI的构建方法,声明式UI的框架会在状态改变后自动触发构建函数的运行;
这两个特点有助于减少UI因为被多处更新而产生bug的几率,但也为性能低下埋下了隐患:
- UI的状态改变一般都是连续的、局部的,如果有一有变化就推翻重来,那么显然会执行很多非必要的计算;
- 构建方法运行时机的不可控,难以人为优化触发时机;
Jetpack Compose
Jetpack Compose是声明式UI框架的一种具体实现,对应到上面的声明式UI公式中,被@Composable
注解修饰的kotlin函数(后面简称为Composable函数)即是构建UI的方法f
;该函数的参数即是状态。
Composable函数用于描述UI,它运行的过程被称为组合(Composition),而之后随着状态改变而再次运行的过程称为重组(Recompostion),后面提到组合/重组就是说的这个过程。而组合,只是整个UI绘制过程的第一步。
绘制阶段
这部分内容来自这一篇官方文档,类似于View的绘制,Jetpack Compose将状态的改变到一帧画面的绘制分成了三个阶段:
- Composition:决定要显示什么,即执行Composable函数,生成layout node树的阶段
- Layout:决定在哪里显示,主要包括测量(measurement)和布局(layout)两部分,即计算各组件的大小以及位置
- Drawing:绘制到屏幕
更形象的过程参考如下动图(图片来自这里):
这三个阶段对状态的读取是独立的,这样就可以限制状态变化所影响的范围,什么意思呢,看官方给的示意图:
- 在Composable函数和Composable lambda中读取状态会触发Recomposition,如果生成的UI树没有改变,则不再执行后续的步骤;
- 在
@Composable Layout()
函数或者Modifier.offset{}
等影响组件位置或者大小的方法中读取状态,只会影响Layout及后面的阶段,不会引起Composition阶段重新执行。值得一提的是,该阶段包括measurement和layout两个阶段,其状态读取也是互相隔离的; - 在
@Composable Canvas()
以及Modifier.drawBehind{}
等函数内读取状态,不会引起前面的阶段重新执行;
从上面的的描述可以推断出,Composition阶段是三个阶段中最执行最频繁的,因此这一阶段是性能优化的重点。那么Composition阶段怎么优化呢?
重组范围
我们知道Composition阶段会频繁运行,这一阶段运行的实体就是Composable函数,如果UI比较复杂,完全重组会非常耗时,Jetpack Compose框架解决这个问题的方法叫做智能重组,即只运行受状态变化影响的部分,跳过状态未改变的部分。另外需要注意的是,状态变化影响的是读取它的地方,而非定义它的地方。
这样的能力是通过编译时插入代码将完整的Composable函数分割成更小的可独立运行的部分实现的,这样的可以被独立执行的部分称为restart scope,有的文章也把它称为 recompose scope 或者 recomposition scope 即重组范围,可能是因为restart scope在框架中的的实现是RecomposeScope
接口。
知道restart scope有什么意义呢?在写代码的时候控制重组范围,就可以降低状态变化所波及的范围,减少无关代码运行的次数,从而提升UI效率。前面提到的绘制的三个阶段,也是通过将他们划分为不同的restart scope来实现互不影响的。
这里放一个例子(来自这篇文章)简单理解一下,代码如下:
@Composable
fun Sample() {
var button1 by remember { mutableStateOf("button 1") }
var button2 by remember { mutableStateOf("button 2") }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Recomposes when `button1` changes, but not when button2 changes
Button(onClick = { button1 += "1" }) { Text(text = button1) }
Spacer(modifier = Modifier.height(8.dp))
// Recomposes when `button2` changes, but not when button1 changes
Button(onClick = { button2 += "2" }) { Text(text = button2) }
}
}
点击两个按钮改变状态,使用Layout Inspector观察重组情况:
可以看到,虽然两个Button都定义在同一个Composable方法中,但状态变化只引起了读取该状态的位置的重组(闪蓝色的地方),对其他地方没有影响。关于restart scope的更多细节可以查阅上面列出的参考文档。
典型的restart scope如非inline的无返回值的Composable函数。像Row
、Column
等inline的函数不是restart scope;像ButtonDefaults.buttonColors()
这样的有返回值的Composable函数也不是restart scope。
稳定性
前面提到,智能重组会跳过状态没有改变的重组范围,那么Jetpack Compose根据什么判断状态是否改变?
这里就引出了稳定性的概念:如果状态的类型是稳定的,则Jetpack Compose就能在重组时判断该状态是否改变,从而决定是否在重组时跳过与该状态相关的部分。
Jetpack Compose会认为符合以下条件的类型是稳定的:
-
不可变类型:
- 基本类型:
Int
、Long
、Double
、Char
…… String
- 枚举类
- 所有属性都是不可变类型的data class,例如:
data class Contact(val name: String, val number: String)
- Kotlin Immutable Collections,注意,标准库中的普通集合类型是不稳定的
- 函数类型(Function):lambda表达式
- 基本类型:
-
显式指定的稳定类型: 框架提供了两个注解帮助开发者手动标注符合条件的稳定的类型,以向编译器提供更多信息用于优化性能:
@Immutable
,应用于创建后一定不会改变的类型,例如所有公开属性在任何情况下都不可变的类;@Stable
,用于向编译器保证某些类型是稳定的,这些类型必须满足:equals
方法必须稳定- 公开属性一旦被更新,必须通知到Composition
- 所有公开属性的类型必须是稳定的
- 当
@Stable
用于函数或者属性时,表明函数或者属性在入参不变的情况下返回值也不会变,此时它只对参数类型以及结果类型本身具有稳定性的函数有意义;
上面提到,重组发生时,如果框架判断状态没有改变,则会跳过相关restart scope的执行,这里就引出了Composable函数的两个属性:
- Skippable:在重组时,Composable函数可以在入参(状态)没有改变的情况下,跳过执行,这里隐含的条件就是入参的类型都必须是稳定的,否则就无法判断是否改变;
- Restartable:函数是否可以作为restart scope被独立执行。
上面提及的稳定性和可跳过性属性,在开发时是不可见的,除了通过经验判断,还可以通过生成Compose Compiler Metric直接查看,可以通过如下方法生成该报告:
配置task:在根目录下的build.gradle.kts
中加入下面的代码,如果只想针对某个module生成,也可以将配置加在具体的module下或者根据project名字配置:
subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
if (project.findProperty("composeCompilerReports") == "true") {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.buildDir.absolutePath}/compose_compiler"
)
}
if (project.findProperty("composeCompilerMetrics") == "true") {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.buildDir.absolutePath}/compose_compiler"
)
}
}
}
}
运行命令,生成报告,注意一定要生成release build下的报告:
./gradlew assembleRelease -PcomposeCompilerReports=true
生成的报告中最主要的有两个:
- 类型稳定性报告,例如下面的内容:
从报告中可以看到该类是不稳定的,因为它的公开属性中有不稳定的类型。unstable class Snack { stable val id: Long stable val name: String stable val imageUrl: String stable val price: Long stable val tagline: String unstable val tags: Set<String> <runtime stability> = Unstable }
- Composable函数属性详情报告,例如:
可以看到这个Composable函数是一个restart group,但是因为有一个参数是不稳定的,所以不具备skippable属性,这意味着它无法通过判断入参是否改变来决定是否跳过执行,换句话说,每次重组都会执行。restartable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks( stable index: Int unstable snacks: List<Snack> stable onSnackClick: Function1<Long, Unit> stable modifier: Modifier? = @static Companion )
关于这份报告的生成方法和更多内容解释可以查看官方文档,或者这一篇文档。
但是一定要注意,在release build下生成Compose Compiler Metric才具备参考意义。
从上面的描述中,想必大家已经明白稳定性对性能的影响,只有skippable的Composable函数越多,重组过程中需要真正重组的部分才可能会越少,性能才会越好。
性能最佳实践
这一部分内容主要来自官方的性能最佳实践文档,但加入了个人的理解及其他参考文档中的内容。
从上面的论述中,可以得到一个结论:Jetpack Compose性能优化的主要方式是加快Compostion阶段的执行,又可以分为三个实现方向:
- 减少Composable函数中非必要的运算
- 避免非必要的执行阶段
- 减少非必要重组的执行
通过remember
缓存执行结果
重复的、繁重的计算工作尽量不要放在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 ->
// ...
}
}
}
上面代码的问题在于排序方法会随着每次重组过程执行,使用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) {
// ...
}
}
}
这样在列表内容和排序方法没有改变的时候,可以复用之前的排序结果。其实更好的方法是将此类计算放在ViewModel中在后台执行。
在LazyLayout中使用key
目的在于向框架提供更多的信息帮助框架识别组件,以复用之前的组合结果,来看例子:
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes
) { note ->
NoteRow(note)
}
}
}
上述例子中,如果用户的操作仅会改变入参的顺序而非内容,例如排序操作,则列表中相同位置但内容不同的项目都会重组:
可以看到随着列表顺序的变化,内容改变的位置都会经历重组,但其实每一个子item的内容和布局等都是没有变化的。如果给每个item一个key的话:
@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
减少重组
某些状态的改变频率远大于我们需要的频率,此时可以通过derivedStateOf
将其转换成另一个状态,以过滤无效的状态变化。看官方例子:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
可以看到这里在每次重组发生时都会读取listState.firstVisibleItemIndex
,而它改变非常频繁,所以会造成大量重组:
通过derivedStateOf
将其转换为真正需要的状态:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
此时滚动时的重组情况:
滚动时没有影响其他组件。
尽可能延迟状态读取
延迟状态读取的目的在于:
- 尽量减小状态改变的影响区域,将其限制在真正需要状态数据的地方;
- 尽量跳过无需运行的阶段;
看官方给的例子:
@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)
) {
// ...
}
}
这里Title
根据内容滚动的位置确定偏移量,观察内容滚动时的重组情况:
可以看到Title
随着滚动不停重组,而且影响到了它的兄弟组件,因为它们同处于一个restart group中,虽然Header
等跳过了重组,但Body
组件就没法幸免了。尝试使用lambda将状态读取延迟:
@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)
) {
// ...
}
}
观察重组情况:
可以看到此时滚动情况下只有Title
经历了重组,完全没有影响到其他组件。原因就是通过lambda将状态读取延迟到了Title
组件中,而它是一个独立的restart group,可以独立重组,这样就会将状态改变的影响“内部消化”,与其他组件隔离开。
更进一步,观察到Title
中只改变了偏移,内容并未改变,说明滚动值在layout阶段起作用,重组其实是没有意义的,这种情况下,就可以使用Modifier
提供的特殊的修饰符,将状态读取推迟到后面的阶段,以避免引起重组:
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
Column(
modifier = Modifier
.offset { IntOffset(x = 0, y = scrollProvider()) }
) {
// ...
}
}
观察滚动时的重组情况:
可以看到,滚动没有引起任何重组。因此官方也强调,如果要将频繁更新的状态值传给Modifier,那么就尽可能使用lambda版本的modifier函数,同样的例子还有背景颜色动画等,这里不再赘述。
避免背写(backwards writes)
@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
fun Sample() {
var button1 by remember { mutableStateOf("button 1") }
var button2 by remember { mutableStateOf("button 2") }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { button1 += "1" }) { Text(text = button1) }
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { button2 += "2" }) { Text(text = button2) }
// New row added here, expected to recompose when button1 changes.
Row {
Text(text = button1)
}
}
}
新加了一个包含读取button1
状态的Text
的Row
,此时点击Button1
重组情况如何?
与开始的例子相比,只是添加了一个状态读取的组件,button1
状态的改变竟然导致整个UI树都经历重组。用重组范围这部分知识理解:Text(text=button1)
读取了状态button1
,当它改变时,会导致读取它的restart group重组,而Row
是内联的,无法作为restart group,Column
同理,因此就影响到了最外层的Sample
,而它一旦重组,则会影响所有的子组件(一些子组件可以跳过重组),影响比想象中大。
那么遇到这种情况如何改善,可以人为制造更小的restart group,将状态改变的影响缩小:
@Composable
fun MyRow(content: @Composable () -> Unit) {
content()
}
@Composable
fun Sample() {
var button1 by remember { mutableStateOf("button 1") }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// other composables
MyRow {
Text(text = button1)
}
}
}
此时的重组情况:
如我们预期的一样。
另一个例子(来自这个视频):
优化前:
优化后:
但是一定要注意,一定是识别到性能问题是重组范围一起的再进行优化,否则会导致代码可读性下降。
修复稳定性问题
从前面的内容中,我们已经理解了稳定性问题的定义以及检查方法,那么一旦遇到稳定性问题如何修复?
- 确保类型中所有属性是不可变的
val
而非var
,且为不可变类型; - 基础数据类型和String都是不可变类型;
- 如果无法做到上面一点,那么确保可变属性是被Compose中的
State<T>
包装;
顾名思义,标准库中的集合类是不稳定类型,而Kotlin Immuitable Collections中的集合类是稳定类型,有必要的情况下可以替换。 例如:
unstable class Snack {
…
unstable val tags: Set<String>
…
}
修改为:
data class Snack{
…
val tags: ImmutableSet<String> = persistentSetOf()
…
}
如果上述手段无法实现,则可通过Jetpack Compose提供的@Immutable
或者@Stable
注解与框架约定某些类型为稳定或者不可变类型。
例如:
@Immutable
data class Snack(
…
)
注意上述注解都是有使用条件的(参见这两个注解的注释),注解无法将类型转换为真正的不可变或者稳定类型,只是一种约定,而Jetpack Compose不负责检查被标注的类是否满足这些条件,如果误用,则会引发错误。
另外需要注意的是,对于普通集合而言,即便集合内元素的类型是不可变或者稳定的,框架也不会认为集合本身是不可变的,此时可以:
- 使用不可变集合
- 将集合包装,对包装类使用注解
@Immutable data class SnackCollection( val snacks: List<Snack> )
简而言之,Jetpack Compose编译器只认它编译过的类,除非显示注解,否则如果其他模块没有被它的编译器编译过,那么这些模块中的非基本类型就不会被识别为稳定/不可变的。举个简单的例子,如果数据层和UI层不在同一个模块之内,那么就会遇到这种问题,外部依赖也是同理。
如何解决:
- 在合适位置添加
@Immutable
或者@Stable
注解 - 使用Jetpack Compose 编译器编译外部模块,这就要求依赖Jetpack Compose Runtime包
- 或者在UI模块内创建新的数据类,确保其稳定性
前面提到过,lambda类型的参数是稳定的,但是实际上在使用中,某些情况下给Composable函数的lambda参数中传入了不稳定的类型,那么Composable函数的skippable属性就会消失,什么意思呢,看一个常见的例子:
class ContentVM(private val client: OkHttpClient = OkHttpClient()) : ViewModel() {
var lastRefreshAt by mutableStateOf("Last refresh at: -")
private set
var mainContent by mutableStateOf("Main content")
private set
fun refresh() {
lastRefreshAt = "Last refresh at: ${SystemClock.currentThreadTimeMillis()}"
}
fun loadMore() {
// Do nothing
}
}
@Composable
fun ContentContainer(content: String, lastRefreshAt: String, refresh: () -> Unit, loadMore: () -> Unit) {
Column {
Text(text = content)
Divider()
Text(text = lastRefreshAt)
Divider()
CustomCta(text = "Refresh", action = refresh)
Divider()
CustomCta(text = "Load More...", action = loadMore)
}
}
@Composable
fun CustomCta(text: String, action: () -> Unit) {
Button(onClick = action) {
Text(text = text)
}
}
@Composable
@Preview
fun ContentPreview() {
val vm = viewModel { ContentVM() }
ContentContainer(
lastRefreshAt = vm.lastRefreshAt,
content = vm.mainContent,
refresh = vm::refresh,
loadMore = vm::loadMore
)
}
稳定性和可跳过性指标如下:
unstable class ContentVM {
unstable val client: OkHttpClient
stable var lastRefreshAt$delegate: MutableState<String>
stable var mainContent$delegate: MutableState<String>
<runtime stability> = Unstable
}
...
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun CustomCta(
stable text: String
stable action: Function0<Unit>
)
从指标来看,CustomCta
是可跳过的,它的参数都是稳定的,但实际运行时,action
参数间接引用了不稳定的ContentVM
,但它在重组期间并未发生改变,那么它对重组有影响吗,看实际情况:
可以看到实际运行中,即使最底部的CustomCta
参数没有改变,它也发生了重组,这种重组是不期望的。那么如何消除这种情况?
- 给ViewModel增加
@Stable
注解 - 使用
remember
记住lambda:@Composable @Preview fun ContentPreview() { val vm = viewModel { ContentVM() } val rememberedRefresh = remember(vm) { { vm.refresh() } } val rememberedLoadMore = remember(vm) { { vm.loadMore() } } ContentContainer( lastRefreshAt = vm.lastRefreshAt, content = vm.mainContent, refresh = rememberedRefresh, loadMore = rememberedLoadMore ) }
- 使用静态方法,这种方式使用机会不多
val vm = ContentVM() val refresh = vm::refresh val loadMore = vm::loadMore @Composable @Preview fun ContentPreview() { ContentContainer( lastRefreshAt = vm.lastRefreshAt, content = vm.mainContent, refresh = refresh, loadMore = loadMore ) }
值得注意的是,这个问题在不同版本中行为可能不同,标题中给出的参考文章有些例子我没有复现出来,所以这里也没有列出。
Composable函数为了灵活性和易用性,大量使用了默认参数,如果你注意观察生成的编译器指标文件,可以看到默认参数也有两种属性:@dynamic
和@static
,什么意思呢?
@static
:表明编译器认为该参数不需要读取状态或者执行其他Composable函数@dynamic
:与@static
相反,编译器认为该参数可能会读取状态或者执行其他Composable函数,在这种情况下,编译器需要插入大量代码来实现这些功能,这样无论是对编译期还是运行期来说都是负担。
看一个例子:
restartable fun Image(
unstable bitmap: ImageBitmap
stable contentDescription: String?
stable modifier: Modifier? = @static Companion
stable alignment: Alignment? = @dynamic Companion.Center
stable contentScale: ContentScale? = @dynamic Companion.Fit
stable alpha: Float = @static DefaultAlpha
stable colorFilter: ColorFilter? = @static null
)
一般情况下,尽量让默认参数具有@static
属性,但是编译器可能无法判断所有的情况,此时也需要我们使用@Stable
注解帮助编译器获得这一信息,例如上面的Companion.Fit
参数:
@Stable
val Fit = object : ContentScale {
override fun computeScaleFactor(srcSize: Size, dstSize: Size): ScaleFactor =
computeFillMinDimension(srcSize, dstSize).let {
ScaleFactor(it, it)
}
}
上面提到了编译器判断静态性的条件,因此在改善静态性问题的时候需要排除下面两种情况:
- 默认参数显示读取了可观察的动态变量,例如从
CompositionLocal
或状态中读取; - 默认参数显示调用了Composable函数,例如
remember
;
并非所有Composable函数都必须具有skippable属性
可跳过性对性能优化很重要,但是也没必要要求所有Composable函数都是skippable的,例如下面几种情况:
- Composable函数很少或者根本不会重组的,例如静态页面;
- 本身只是调用skippable函数的Composable函数。
- 参数非常多,参数比较的耗时已经超过了重组耗时的Composable函数
另外,有一些具有restartable属性但不具备skippable属性的Composable函数,同时其内部也并未直接读取状态变量,例如:
- 根Composable函数
- 简单的Composable包装函数
对于此类函数,restart group对其是没有意义的,但编译器无法得知这种情况,保守起见也会插入生成restart group的代码。此时可以通过手动标注@NonRestartableComposable
注解,避免编译器插入生成restart group的代码,从而提升些许性能,例如Spacer:
@Composable
@NonRestartableComposable
fun Spacer(modifier: Modifier): Unit
其他建议
这部分内容基本来自于Conscious Compose optimization这篇文章的相应部分。
看官方给的例子:
val movableContent = remember(content) { movableContentOf(content) }
if (vertical) {
Column {
movableContent()
}
} else {
Row {
movableContent()
}
}
缓存状态及创建的UI节点树,可在不同的地方复用,避免重组和状态丢失,这也是LazyLayout通过key实现复用的内部机制。更多的例子可以参考这篇文章。
如果一个CompositionLocal
所提供的值很少变化或者根本不会变,那么可以使用staticCompositionLocalOf
来创建它以提升少许性能,看官方示例:
@Immutable
data class Elevations(val card: Dp = 0.dp)
internal val LocalElevations = staticCompositionLocalOf { Elevations() }
如果一个Composable函数中只是在传入的composer上执行了读操作,那么就可以通过标注@ReadOnlyComposable
来阻止编译器为其生成restart group,从而提升代码性能,看官方例子:
/**
* A composable function that returns the [Resources]. It will be recomposed when `Configuration`
* gets updated.
*/
@Composable
@ReadOnlyComposable
private fun resources(): Resources {
LocalConfiguration.current
return LocalContext.current.resources
}
可用于实现较为复杂的Layout动画,这里不展开,有兴趣的可以参考官方文档和这篇文章。
少用ComposeView
在同一个页面尽量少用ComposeView
。
另外,尽量避免将Compose用于首页或者启动页,因为ComposeView第一次启动需要预热,会影响App打开的速度,参考这篇文章
升级到最新版Jetpack Compose库
如题,下面是官方2023年8月发布的路线图的编译器和运行时部分,可以看到官方也在着手提升性能:
如题。
总结
上面简单介绍了一些比较通用的性能建议,了解它们有助于我们写出性能更佳的UI,但是其中一些方法会降低代码的可读性和可维护性,所以针对这些优化,一定要:
- 确认有性能问题再进行优化
- 了解清楚优化手段的原理和作用,避免误用
- 权衡优化的收益和代价,并非每一个问题都需要处理
最后,性能优化不是一次性的工作,而是一个不断循环的过程(图片来自Understand Recomposition Performance Pitfall 31分21秒):
参考
- Jetpack Compose performance
- Interpreting Compose Compiler Metrics
- Conscious Compose optimization
- Optimize or Die. Profiling and Optimization in Jetpack Compose
- Composable metrics
- Gotchas in Jetpack Compose Recomposition
- Performance in Jetpack Compose
- Understanding Recomposition Performance Pitfalls
- More performance tips for Jetpack Compose
- Jetpack Compose: Debugging Recomposition
- Debugging the recomposition in Jetpack Compose
- Jetpack Compose Stability Explained
- Scoped recomposition in Jetpack Compose — what happens when state changes?
- How does Compose determine which block of code to recompose? — Recomposition Scope in Jetpack Compose
- What is “donut-hole skipping” in Jetpack Compose?
- movableContentOf and movableContentWithReceiverOf
- Introducing Jetpack Compose’s New Layout: “LookaheadLayout”
- Faster Jetpack Compose <-> View interop with App Startup and baseline profile