Jetpack Compose 性能优化

1,451 阅读18分钟

时至今日(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官网)来简单说明:
Declarative UI

声明式UI有两个特点:

  1. 类似于函数,作为输入的状态改变了,构建方法就需要重新运行以生成新的UI,就像Jetpack Compose文档中说的:

    The technique works by conceptually regenerating the entire screen from scratch, then applying only the necessary changes.

  2. 开发者只需要提供UI的构建方法,声明式UI的框架会在状态改变后自动触发构建函数的运行;

这两个特点有助于减少UI因为被多处更新而产生bug的几率,但也为性能低下埋下了隐患:

  1. UI的状态改变一般都是连续的、局部的,如果有一有变化就推翻重来,那么显然会执行很多非必要的计算;
  2. 构建方法运行时机的不可控,难以人为优化触发时机;

Jetpack Compose

Jetpack Compose是声明式UI框架的一种具体实现,对应到上面的声明式UI公式中,被@Composable注解修饰的kotlin函数(后面简称为Composable函数)即是构建UI的方法f;该函数的参数即是状态。

Composable函数用于描述UI,它运行的过程被称为组合(Composition),而之后随着状态改变而再次运行的过程称为重组(Recompostion),后面提到组合/重组就是说的这个过程。而组合,只是整个UI绘制过程的第一步。

绘制阶段

这部分内容来自这一篇官方文档,类似于View的绘制,Jetpack Compose将状态的改变到一帧画面的绘制分成了三个阶段:

Phases

  • Composition:决定要显示什么,即执行Composable函数,生成layout node树的阶段
  • Layout:决定在哪里显示,主要包括测量(measurement)和布局(layout)两部分,即计算各组件的大小以及位置
  • Drawing:绘制到屏幕

更形象的过程参考如下动图(图片来自这里):

jetpack_compose_phases.gif

这三个阶段对状态的读取是独立的,这样就可以限制状态变化所影响的范围,什么意思呢,看官方给的示意图:

phased_read.png

  • 在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观察重组情况:

restart scope

可以看到,虽然两个Button都定义在同一个Composable方法中,但状态变化只引起了读取该状态的位置的重组(闪蓝色的地方),对其他地方没有影响。关于restart scope的更多细节可以查阅上面列出的参考文档。

典型的restart scope如非inline的无返回值的Composable函数。像RowColumn等inline的函数不是restart scope;像ButtonDefaults.buttonColors()这样的有返回值的Composable函数也不是restart scope。

稳定性

前面提到,智能重组会跳过状态没有改变的重组范围,那么Jetpack Compose根据什么判断状态是否改变?

这里就引出了稳定性的概念:如果状态的类型是稳定的,则Jetpack Compose就能在重组时判断该状态是否改变,从而决定是否在重组时跳过与该状态相关的部分。

Jetpack Compose会认为符合以下条件的类型是稳定的:

  • 不可变类型:

    • 基本类型:IntLongDoubleChar……
    • 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函数属性详情报告,例如:
    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
    )
    
    可以看到这个Composable函数是一个restart group,但是因为有一个参数是不稳定的,所以不具备skippable属性,这意味着它无法通过判断入参是否改变来决定是否跳过执行,换句话说,每次重组都会执行。

关于这份报告的生成方法和更多内容解释可以查看官方文档,或者这一篇文档

但是一定要注意,在release build下生成Compose Compiler Metric才具备参考意义。

从上面的描述中,想必大家已经明白稳定性对性能的影响,只有skippable的Composable函数越多,重组过程中需要真正重组的部分才可能会越少,性能才会越好。

性能最佳实践

这一部分内容主要来自官方的性能最佳实践文档,但加入了个人的理解及其他参考文档中的内容。

从上面的论述中,可以得到一个结论:Jetpack Compose性能优化的主要方式是加快Compostion阶段的执行,又可以分为三个实现方向:

  1. 减少Composable函数中非必要的运算
  2. 避免非必要的执行阶段
  3. 减少非必要重组的执行

通过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)
        }
    }
}

上述例子中,如果用户的操作仅会改变入参的顺序而非内容,例如排序操作,则列表中相同位置但内容不同的项目都会重组:

lazylayout_key_before.gif

可以看到随着列表顺序的变化,内容改变的位置都会经历重组,但其实每一个子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)
        }
    }
}

重组情况:

lazylayout_key_after.gif

明显看到虽然内容改变了,但是列表项都跳过了重组阶段。

使用derivedStateOf减少重组

某些状态的改变频率远大于我们需要的频率,此时可以通过derivedStateOf将其转换成另一个状态,以过滤无效的状态变化。看官方例子:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

可以看到这里在每次重组发生时都会读取listState.firstVisibleItemIndex,而它改变非常频繁,所以会造成大量重组:

derived_before.gif

通过derivedStateOf将其转换为真正需要的状态:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

此时滚动时的重组情况:

derived_after.gif

滚动时没有影响其他组件。

尽可能延迟状态读取

延迟状态读取的目的在于:

  1. 尽量减小状态改变的影响区域,将其限制在真正需要状态数据的地方;
  2. 尽量跳过无需运行的阶段;

看官方给的例子:

@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根据内容滚动的位置确定偏移量,观察内容滚动时的重组情况:

defer_read_before.gif

可以看到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)
    ) {
        // ...
    }
}

观察重组情况:

defer_read_after_1.gif

可以看到此时滚动情况下只有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()) }
    ) {
        // ...
    }
}

观察滚动时的重组情况:

defer_read_after_2.gif

可以看到,滚动没有引起任何重组。因此官方也强调,如果要将频繁更新的状态值传给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状态的TextRow,此时点击Button1重组情况如何?

minimize_restart_group_before

与开始的例子相比,只是添加了一个状态读取的组件,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)
        }
    }
}

此时的重组情况:

miniminze_restart_group_after

如我们预期的一样。

另一个例子(来自这个视频):

优化前:

recompose_scope_big.png

优化后:

recompose_scope_small.png

但是一定要注意,一定是识别到性能问题是重组范围一起的再进行优化,否则会导致代码可读性下降

修复稳定性问题

从前面的内容中,我们已经理解了稳定性问题的定义以及检查方法,那么一旦遇到稳定性问题如何修复?

确保状态类型是不可变的或者稳定的

  • 确保类型中所有属性是不可变的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()
    …
}

添加@Immutable或者@Stable注解

如果上述手段无法实现,则可通过Jetpack Compose提供的@Immutable或者@Stable注解与框架约定某些类型为稳定或者不可变类型。 例如:

@Immutable
data class Snack(
…
)

注意上述注解都是有使用条件的(参见这两个注解的注释),注解无法将类型转换为真正的不可变或者稳定类型,只是一种约定,而Jetpack Compose不负责检查被标注的类是否满足这些条件,如果误用,则会引发错误。

另外需要注意的是,对于普通集合而言,即便集合内元素的类型是不可变或者稳定的,框架也不会认为集合本身是不可变的,此时可以:

  1. 使用不可变集合
  2. 将集合包装,对包装类使用注解
    @Immutable
    data class SnackCollection(
       val snacks: List<Snack>
    )
    

跨模块的稳定性

简而言之,Jetpack Compose编译器只认它编译过的类,除非显示注解,否则如果其他模块没有被它的编译器编译过,那么这些模块中的非基本类型就不会被识别为稳定/不可变的。举个简单的例子,如果数据层和UI层不在同一个模块之内,那么就会遇到这种问题,外部依赖也是同理。

如何解决:

  1. 在合适位置添加@Immutable或者@Stable注解
  2. 使用Jetpack Compose 编译器编译外部模块,这就要求依赖Jetpack Compose Runtime包
  3. 或者在UI模块内创建新的数据类,确保其稳定性

不稳定的lambda

前面提到过,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,但它在重组期间并未发生改变,那么它对重组有影响吗,看实际情况:

unstable_lambda_before.gif

可以看到实际运行中,即使最底部的CustomCta参数没有改变,它也发生了重组,这种重组是不期望的。那么如何消除这种情况?

  1. 给ViewModel增加@Stable注解
  2. 使用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
        )
    }
    
  3. 使用静态方法,这种方式使用机会不多
    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)
        }
}

上面提到了编译器判断静态性的条件,因此在改善静态性问题的时候需要排除下面两种情况:

  1. 默认参数显示读取了可观察的动态变量,例如从CompositionLocal或状态中读取;
  2. 默认参数显示调用了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这篇文章的相应部分。

movableContentOf

看官方给的例子:


val movableContent = remember(content) { movableContentOf(content) }

if (vertical) {
    Column {
        movableContent()
    }
} else {
    Row {
        movableContent()
    }
}

缓存状态及创建的UI节点树,可在不同的地方复用,避免重组和状态丢失,这也是LazyLayout通过key实现复用的内部机制。更多的例子可以参考这篇文章

staticCompositionLocalOf

如果一个CompositionLocal所提供的值很少变化或者根本不会变,那么可以使用staticCompositionLocalOf来创建它以提升少许性能,看官方示例

@Immutable  
data class Elevations(val card: Dp = 0.dp)  
  
internal val LocalElevations = staticCompositionLocalOf { Elevations() }

@ReadOnlyComposable

如果一个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
}

LookaheadScope

可用于实现较为复杂的Layout动画,这里不展开,有兴趣的可以参考官方文档和这篇文章

少用ComposeView

在同一个页面尽量少用ComposeView

另外,尽量避免将Compose用于首页或者启动页,因为ComposeView第一次启动需要预热,会影响App打开的速度,参考这篇文章

升级到最新版Jetpack Compose库

如题,下面是官方2023年8月发布的路线图的编译器和运行时部分,可以看到官方也在着手提升性能:

image.png

使用Baseline Profile

如题。

总结

上面简单介绍了一些比较通用的性能建议,了解它们有助于我们写出性能更佳的UI,但是其中一些方法会降低代码的可读性和可维护性,所以针对这些优化,一定要:

  1. 确认有性能问题再进行优化
  2. 了解清楚优化手段的原理和作用,避免误用
  3. 权衡优化的收益和代价,并非每一个问题都需要处理

最后,性能优化不是一次性的工作,而是一个不断循环的过程(图片来自Understand Recomposition Performance Pitfall 31分21秒):

image.png

参考

  1. Jetpack Compose performance
  2. Interpreting Compose Compiler Metrics
  3. Conscious Compose optimization
  4. Optimize or Die. Profiling and Optimization in Jetpack Compose
  5. Composable metrics
  6. Gotchas in Jetpack Compose Recomposition
  7. Performance in Jetpack Compose
  8. Understanding Recomposition Performance Pitfalls
  9. More performance tips for Jetpack Compose
  10. Jetpack Compose: Debugging Recomposition
  11. Debugging the recomposition in Jetpack Compose
  12. Jetpack Compose Stability Explained
  13. Scoped recomposition in Jetpack Compose — what happens when state changes?
  14. How does Compose determine which block of code to recompose? — Recomposition Scope in Jetpack Compose
  15. What is “donut-hole skipping” in Jetpack Compose?
  16. movableContentOf and movableContentWithReceiverOf
  17. Introducing Jetpack Compose’s New Layout: “LookaheadLayout”
  18. Faster Jetpack Compose <-> View interop with App Startup and baseline profile