稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Jetpack Compose的性能优化建议

本文译自「Performance Optimization in Jetpack Compose」,原文链接https://carrion.dev/en/posts/performance-optimization-compose/,由Ignacio Carrión,发布于2025年4月8日。

译者按: Jetpack Compose是一个优秀的声明式UI框架,对开发者非常友好,可以高效率的撸各种UI页面和UI元素。但它仍然并不是很成熟,有些事情还做不了,而且渲染性能也略输于原生的View方式,毕竟它比原生的View多了一层组合树和渲染树。因此,在享受声明式UI带来的便捷的同时,就需要深入地了解其内部的工作机制,和学习一些高级技巧,以提升运行时的渲染性能。另外,需要 注意虽然这篇文章是针对for Android的Jetpack Compose,但大部分也适用于Compose Multiplatform。

性能优化对于在Jetpack Compose应用中提供流畅的用户体验至关重要。本文探讨了关键技术和最佳实践,以确保你的可组合函数高效且性能卓越。

理解组合(Composition)和重组(ReComposition)

译注: 组合与重组是Compose中非常重要 的概念,如果不熟悉的同学可以复习一下之前的文章降Compose十八掌之『潜龙勿用』| Thinking in Compose降Compose十八掌之『损则有孚』| Lifecycle

Compose 性能的一个基本方面是了解合成和重组的工作原理:

智能重组(Smart Recomposition)

Compose 使用智能重组功能,仅更新界面中需要更改的部分。了解触发重组的原因以及如何最小化重组的影响范围对于性能优化至关重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
fun ExpensiveCalculation(numbers: List<Int>) {
    // 不好:每次重组都会执行昂贵的操作
    val average = numbers.takeIf { it.isNotEmpty() }
        ?.average()
        ?: 0.0

    // 优点:昂贵的操作被缓存,并且仅在输入发生变化时重新计算
    val cachedAverage = remember(numbers) {
        numbers.takeIf { it.isNotEmpty() }
            ?.average()
            ?: 0.0
    }

    Column {
        // 每次重组时都会重新计算
        Text("Current Average: ${"%.2f".format(average)}")

        // 这将使用缓存住的值
        Text("Cached Average: ${"%.2f".format(cachedAverage)}")
    }
}

稳定类型(Stable types)和不可变性(Immutability)

稳定的类型对于Compose的智能重组系统至关重要。当Compose能够保证其 equals() 方法与其属性一致,并且属性本身不会在不触发重组的情况下发生变化时,该类型即被视为稳定类型。

1
2
3
4
5
6
7
8
9
10
11
12
// 不好:类型不稳定 - 可变属性可能会在不通知Compose的情况下发生变化
data class UserState(
    var name: String, // 可变属性可以偷偷地改变
    var age: Int      // 而且更改不会触发重组
)

// 优点:稳定类型 - 不可变属性和显式稳定性
@Stable  // 告诉Compose此类型具有可预测的相等性
data class UserState(
    val name: String,  // 不可变属性
    val age: Int      // 如要更改需要创建新实例
)

使用稳定类型有以下几个好处:

  1. 更高效的重组 - 当Compose确定数据未发生变化时,它可以跳过重组部分UI,换句话说可以减少很多不必要的重组,进而提高性能
  2. 可预测的行为 - 数据更改始终会触发正确的UI更新
  3. 线程安全(Thread safety) - 不可变数据可以安全地在协程之间共享

译注: 这里说的应该是可以在线程之间安全地共享,协程如果没有线程切换是不会有线程安全问题的。

性能优化的关键点

1. 合理地使用 remember 和 derivedStateOf 进行状态(State)管理

remember 和 derivedStateOf 函数在状态管理中起到不同的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Composable
fun UserProfile(user: User, items: List<Item>) {
    // 缺点:每次重新组合时都重新计算
    val filteredItems = items.filter { it.userId == user.id }

    // 好:使用记忆缓存计算
    val cachedItems = remember(items, user.id) {
        items.filter { it.userId == user.id }
    }

    // 更好的方式:使用 derivedStateOf 进行反应式计算
    val reactiveItems by remember(items) {
        derivedStateOf {
            items.filter { it.userId == user.id }
        }
    }

    // 当 items 发生变化时,reactiveItems 会自动更新
    // 并且仅在过滤结果发生变化时触发重组
    LazyColumn {
        itemsIndexed(
            items = reactiveItems,
            key = { _: Int, item: Item -> item.id }
        ) { _: Int, item: Item ->
            ItemRow(item)
        }
    }
}

译注: 对于状态的管理,可以复习一下之前专门讲解副作用(Side effects)的文章降Compose十八掌之『龙战于野』| Side Effects

2. 合理地使用CompositionLocal

译注: 关于CompositionLocal的使用可以看前面写过的文章用Compose中的CompositionLocal来暗渡陈仓,下面的示例是想要说明,应该在合理的地方访问CompositionLocal里面的数据,因数对CompositionLocal的访问地方会被触发重组(之前的文章有讲过重组的触发是状态使用的地方,而不是定义的地方),如果在所有的地方都 直接访问CompositionLocal,特别是嵌套较深的地方也都 直接访问,那都会触发重组,但大部分其实是不必要的。像下样示例展示的那样,在一定的级别中访问CompositionLocal,然后它的内部嵌套调用直接复用数值,可以避免过度重组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 不好:每个子组件都访问 CompositionLocal
@Composable
fun DeepNestedContent() {
    val theme = LocalTheme.current  // 直接访问
    val strings = LocalStrings.current  // 多个 CompositionLocal 访问
    val dimensions = LocalDimensions.current

    Column {
        Text(
            text = strings.title,
            style = theme.textStyle,
            modifier = Modifier.padding(dimensions.padding)
        )
        // 具有重复 CompositionLocal 访问的更多嵌套内容
    }
}

// 好:提升 CompositionLocal的值以最小化查找
@Composable
fun ParentContent() {
    // 单独访问 CompositionLocal 值
    val theme = LocalTheme.current
    val strings = LocalStrings.current
    val dimensions = LocalDimensions.current

    DeepNestedContent(
        theme = theme,
        strings = strings,
        dimensions = dimensions
    )
}

@Composable
fun DeepNestedContent(
    theme: Theme,
    strings: Strings,
    dimensions: Dimensions
) {
    // 使用传递的参数而不是查找 CompositionLocal 值
    Column {
        Text(
            text = strings.title,
            style = theme.textStyle,
            modifier = Modifier.padding(dimensions.padding)
        )
        // 使用传递的参数进行更多嵌套内容
    }
}

3. LazyList 优化技巧

高效的列表渲染对于流畅的滚动性能至关重要。以下是针对 LazyList 组件的关键优化:

1
2
3
4
5
6
7
8
9
10
11
12
@Composable
fun <T : Any> OptimizedList(items: List<T>) {
    LazyColumn {
        itemsIndexed(
            items = items,
            // 稳定的key有助于Compose在更新过程中跟踪项目
            key = { _: Int, item: T -> item.hashCode() }
        ) { _: Int, item: T ->
            // 每个item的内容
        }
    }
}

LazyList 的关键优化点:

  1. 提供稳定的键,帮助Compose在更新过程中跟踪项目
  2. 尽可能使用固定大小以避免重新测量
  3. 保持项目可组合项的轻量级
  4. 避免在项目内容中进行不必要的分配
  5. 记住要为每个项目缓存昂贵的计算

测量和监控性能

Layout Inspector和Composition Traces

Android Studio 中的布局检查器是一款强大的Compose界面性能调试工具。它能够帮助你深入了解应用的视图层次结构、重组计数以及应用于每个可组合项的修饰符。

要将布局检查器与Compose结合使用,请执行以下操作:

  1. 在调试模式下运行你的应用
  2. 在“正在运行的设备”窗口中,你将看到一个用于切换布局检查器的按钮
  3. 检查Compose层次结构:
    • 查看组件树
    • 检查重组计数
    • 分析修饰符链
    • 检查可组合项参数

Layout Inspector

布局检查器中需要监控的关键指标:

  1. 重组计数 - 数值较高表示存在潜在的优化机会
  2. 跳过计数 - 检查可组合项是否在应该跳过重组时跳过
  3. 修饰符链复杂度 - 较长的修饰符链可能会影响测量/布局性能

性能测试

1
2
3
4
5
6
7
8
9
10
11
12
@Test
fun performanceTest() {
    benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(FrameTimingMetric()),
        iterations = 5
    ) {
        composeTestRule.setContent {
            YourComposable()
        }
    }
}

最佳实践总结

  1. 使用稳定类型(Stable types)和不可变数据结构(Immutable data structures)
  2. 使用remember提升高开销计算
  3. 在惰性列表(lazy list)中实现合适的键(key)
  4. 最小化重组范围
  5. 定期分析和测量性能

遵循这些优化技巧将有助于确保你的Compose UI保持响应迅速且高效,从而为你的应用提供更好的用户体验。

Comments