Compose原理十五之性能优化

5 阅读5分钟

在 Compose 声明式 UI 中,性能优化的核心理念是:尽可能地跳过不必要的重组。如果优化不当,微小的状态改变可能引起整个页面的大规模重构,从而导致卡顿。本文将介绍在业务开发中如何进行Compose性能优化。

1、缩小重组范围:抽取独立组件

Compose 的重组范围是基于读取状态的最小作用域(通常是最近的 @Composable 函数)。如果在一个庞大的组件中读取了某个频繁变化的状态,会导致整个大组件发生重组。将读取状态的部分抽取成独立的 @Composable 函数,可以有效缩小重组范围。

反面教材:

@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    // 假设 name 是一个很少变化的状态,而 scrollOffset 是一个高频变化的状态
    val name by viewModel.name.collectAsState()
    val scrollOffset by viewModel.scrollOffset.collectAsState()

    Column {
        Text("Hello, $name") // 静态内容
        // ... 大量其他静态 UI ...
        
        // 仅仅因为这里读取了 scrollOffset,当它变化时,整个 ProfileScreen 都会重组!
        Text("Scroll Offset: $scrollOffset")
    }
}

正确做法:

@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    val name by viewModel.name.collectAsState()

    Column {
        Text("Hello, $name")
        // ... 大量其他静态 UI ...
        
        // 将读取高频状态的 UI 抽取为独立的 Composable
        ScrollOffsetDisplay(viewModel)
    }
}

@Composable
fun ScrollOffsetDisplay(viewModel: ProfileViewModel) {
    // 状态读取被限制在这个小组件内部
    // 当 scrollOffset 变化时,只有 ScrollOffsetDisplay 会重组,ProfileScreen 不会!
    val scrollOffset by viewModel.scrollOffset.collectAsState()
    Text("Scroll Offset: $scrollOffset")
}
  • 内联函数不能作为最小重组范围,内联函数在编译期间会在调用处展开,重组的时候无法找到合适的调用入口。
  • 有返回值的函数不能作为最小重组范围,返回值需要在调用处使用,必须连同调用方一起重组。
  • 为了提升重组性能,可以将重组的组件放到一个空的非内联函数中。

2、驯服 "不稳定" 的状态

Compose 跳过重组的前提是:所有的参数都必须是稳定(Stable)的。 什么是稳定的?基本类型(Int, String)、被 @Stable@Immutable 标记的类。 什么是不稳定的?所有的集合接口(List, Map,因为 Compose 无法确定实现类是否可变)、var 属性暴露的普通类。

优化方案:

  • 使用只读集合:不要传 List<T>,使用 Kotlinx Immutable 库的 ImmutableList<T>
  • 给数据类打标签:如果你确定一个 UI State 类在创建后不会内部修改,给它加上 @Immutable
    @Immutable // 告诉编译器:信任我,里面的数据不会变!
    data class UserState(val name: String, val tags: List<String>)
    
  • 解构传递:不要把整个巨大的 ViewModelContext 传给底层的叶子节点。只要你需要其中的一个 id,那就只传 id: String

3、状态下放

这是 Compose 优化中最重要、最有效的一条规则! 如果一个状态仅仅是为了改变 UI 的绘制阶段(比如背景颜色、位移、透明度、滚动偏移量),千万不要在重组阶段读取它!

反面教材:

// scrollState.value 改变时,会导致整个 Column 重组!
val offset = scrollState.value
Column(Modifier.offset(y = offset.dp)) { ... } 

正确做法 (Lambda 延迟读取):

// Modifier.offset 接受一个 Lambda。
// state 的读取被推迟到了 Layout 阶段。
// scrollState 变化时,Column 不会发生重组 (Recomposition),只执行 Layout/Draw!性能提升巨大!
Column(Modifier.offset { IntOffset(0, scrollState.value.roundToInt()) }) { ... }

4、derivedStateOf:过滤高频状态

当你从一个高频变化的状态(比如列表滚动距离 scrollOffset)派生出一个低频变化的状态(比如是否显示“回到顶部”按钮 showFab)时,必须使用 derivedStateOf

反面教材:

// scrollOffset 每次微小的改变(每秒几十次),都会导致重组
val showFab = scrollState.value > 1000 
if (showFab) { Fab() }

正确做法:

// 只有当 showFab 的布尔值发生真正的反转(false -> true 或 true -> false)时,才会触发重组
val showFab by remember {
    derivedStateOf { scrollState.value > 1000 }
}
if (showFab) { Fab() }

5、列表优化:LazyColumn & key

长列表是卡顿的重灾区。

  • 必须提供 key:如果不提供 key,当在列表顶部插入一条数据时,Compose 会把下面所有的数据全部推倒重做。提供了唯一 key,Compose 就能聪明地只是将已有节点向下平移。
    LazyColumn {
        items(items = userList, key = { user -> user.id }) { user ->
            UserRow(user)
        }
    }
    
  • contentType (Compose 1.2+):如果列表中有多种样式的卡片,指定 contentType 可以让 Compose 更加高效地在底层的组件池中复用相同类型的节点,极大地减少滑动时的创建开销。

6、remember 缓存昂贵的计算

如果必须在 Composable 中进行一些计算(比如过滤列表、格式化时间),一定要用 remember 把结果缓存起来。如果不包裹 remember,每次重组都会重新执行这部分计算逻辑。

反面教材:

@Composable
fun FilteredList(users: List<User>, keyword: String) {
    // 每次上层重组,或者仅仅是动画发生,都会重新遍历过滤整个 List!
    val filteredUsers = users.filter { it.name.contains(keyword) }
    LazyColumn { ... }
}

正确做法:

@Composable
fun FilteredList(users: List<User>, keyword: String) {
    // 只有当 users 或 keyword 发生变化时,才会重新执行过滤计算
    val filteredUsers = remember(users, keyword) {
        users.filter { it.name.contains(keyword) }
    }
    LazyColumn { ... }
}

7、避免频繁改变 Modifier 实例

在老版本的 Compose 中,Modifier 的创建和比较是有开销的,特别是在动画中。虽然现在的 Compose 编译器(Modifier Node 架构)优化了这部分,但在写动画时,依然应该优先使用基于 Lambda 的 Modifier 动画,而不是基于值的动画来重建 Modifier

反面教材:

// 每一帧都会产生一个新的 Modifier 实例,导致不必要的 Recomposition 或 Layout
val padding by animateDpAsState(if (selected) 16.dp else 0.dp)
Box(modifier = Modifier.padding(padding))

推荐做法 (针对复杂/高频动画): 如果这是一个非常高频的自定义 Layout 或 Graphics 动画,尽量使用 Modifier.layout { ... }Modifier.drawBehind { ... },将状态读取放在 Lambda 内部,避免重建 Modifier 链。

8、避免在 Composable 中执行耗时操作

Composable 函数必须是无副作用极速的。 绝对不要在组件中直接进行:

  • 解析大型 JSON
  • 读写 SharedPreferences / DataStore
  • 复杂的过滤和排序计算(应该放在 ViewModel 或放在 remember(xxx) { ... } 中)