25、性能优化:重组优化、稳定性注解、Key使用、懒加载优化

性能优化这个话题,说实话,是每个 Compose 开发者绕不开的坎。我记得刚用 Compose 做第一个商业项目时,列表稍微复杂一点就开始卡顿,滑动起来掉帧严重。当时我一度怀疑是不是 Compose 本身有问题,后来才发现——嗯,是我自己没用好。

这一章,我就把我在项目中踩过的坑、总结的经验,一次性讲清楚。核心就四个关键词:重组优化稳定性注解Key 使用懒加载优化

重组到底是个啥?

先说说重组。Compose 的 UI 是函数式的,状态变了,UI 自动更新。这个更新过程就叫重组。但问题来了——重组不是只更新变化的部分吗?理论上是的,但实际没那么简单。

我举个例子:

@Composable
fun MyScreen() {
    var count by remember { mutableStateOf(0) }
    Column {
        Text("点击次数: $count")
        Button(onClick = { count++ }) {
            Text("点击")
        }
        HeavyComposable()
    }
}

@Composable
fun HeavyComposable() {
    // 模拟耗时操作
    Thread.sleep(100)
    Text("我是重型组件")
}

你猜怎么着?每次点击按钮,HeavyComposable 也会跟着重组。明明它跟 count 没有任何关系!这就是典型的重组范围过大问题。

核心原则:重组的最小单位是函数。如果一个 Composable 函数读取了某个状态,当这个状态变化时,整个函数都会重组。

所以,优化重组的第一步就是——把不相关的代码拆出去。把 HeavyComposable 单独抽成一个函数,它就不会被 count 的变化影响了。

稳定性注解:@Stable 和 @Immutable

接下来聊稳定性注解。这个知识点,说实话,很多人容易忽略。但它在性能优化里非常关键。

Compose 编译器会分析你的数据类型是否“稳定”。稳定的意思是:如果两个实例的 equals 返回 true,那么它们的内容一定相同。Compose 就可以放心地跳过重组。

默认情况下,基本类型、String、Lambda 表达式是稳定的。但自定义的 data class 呢?

data class User(val name: String, val age: Int)

这个 User 类,Compose 编译器会认为它是稳定的吗?答案是:不一定。如果 User 的所有属性都是稳定的,那它就是稳定的。但如果某个属性是 List<String> 呢?List 本身是不稳定的,因为它是接口,编译器不知道它的 equals 实现。

这时候就需要手动加注解了:

@Immutable
data class User(val name: String, val age: Int, val tags: List<String>)

加了 @Immutable 后,Compose 就知道这个类永远不会变,可以放心跳过重组。但注意——如果你骗了编译器,比如实际上你修改了 tags 的内容但没创建新对象,那 UI 就不会更新了。

我的习惯:所有作为 Composable 参数的数据类,我都加上 @Immutable@Stable。除非它确实会变,那就用 @Stable

@Stable@Immutable 的区别很简单:@Immutable 表示对象永远不会变;@Stable 表示对象可以变,但 Compose 能感知到变化(比如通过 mutableStateOf)。

Key 的正确使用姿势

Key 这个东西,在 LazyColumn 里特别重要。我曾经遇到过一个 bug:列表数据更新后,某个 item 的动画效果错乱了。排查了半天,发现是 Key 没设置。

看这段代码:

@Composable
fun UserList(users: List<User>) {
    LazyColumn {
        items(users) { user ->
            UserItem(user)
        }
    }
}

这样写有什么问题?当 users 列表发生变化时(比如某个元素位置变了),Compose 会通过 索引 来识别每个 item。但索引是不可靠的——如果第一个元素被删了,原来第二个元素就变成了第一个,它的状态(比如滚动位置、动画)会被错误地复用。

正确的做法是:

LazyColumn {
    items(users, key = { it.id }) { user ->
        UserItem(user)
    }
}

id 作为 key,Compose 就能精确地追踪每个 item。即使列表顺序变了,每个 item 的状态也不会乱。

我曾经踩过的坑:hashCode() 作为 key。结果两个不同的对象 hashCode 冲突了,导致 UI 错乱。后来我统一用数据库里的唯一 ID,再也没出过问题。

懒加载优化:LazyColumn 的性能调优

LazyColumn 是 Compose 里最常用的懒加载组件。但用不好,照样卡。

我总结了几条实战经验:

  • 避免在 item 里做耗时操作——每个 item 的 Composable 函数应该尽量轻量。如果要做图片加载、网络请求,用 LaunchedEffectremember 配合协程。
  • 使用 contentType——如果你的列表里有多种类型的 item,告诉 Compose 它们类型不同,可以避免不必要的重组。
  • 控制 item 的复杂度——一个 item 里嵌套太多层 Composable,重组时开销会很大。能拆就拆。
LazyColumn {
    items(
        items = users,
        key = { it.id },
        contentType = { "user" }
    ) { user ->
        UserItem(user)
    }
}

另外,rememberderivedStateOf 也是好帮手。比如你有一个列表,需要根据某个条件过滤:

val filteredUsers by remember(users, query) {
    derivedStateOf {
        users.filter { it.name.contains(query) }
    }
}

这样只有当 usersquery 变化时,才会重新计算过滤结果。否则直接复用上一次的结果。

知识体系总览

下面这张图,是我自己整理的重组优化知识体系。你可以把它当作一个检查清单:

Compose 性能优化知识体系 重组优化 稳定性注解 Key 使用 懒加载优化 拆分 Composable 减少重组范围 @Immutable @Stable 唯一 ID 作为 Key 避免 hashCode contentType derivedStateOf 核心原则 状态变化时,只重组真正需要更新的部分 用稳定性注解告诉编译器哪些数据是安全的

总结一下

重组优化说白了就是一件事:让 Compose 少干活。你帮它把范围缩小、把数据标记清楚、把 Key 给对,它就能跑得飞快。

我个人习惯是:写每个 Composable 之前,先想想它会不会被不必要地重组。如果会,就拆。拆完之后加注解,再检查 Key。最后用 LazyColumn 时,把 contentType 和 derivedStateOf 用上。这一套组合拳打下来,性能基本不会出大问题。

嗯,今天就聊到这儿。记住一句话:性能优化不是玄学,是工程