列表:key 与稳定性决定重跑多少项
列表是 Compose 性能问题最集中的地方,原因很简单:别的地方多跑一次是多跑一个节点,列表里多跑一次是多跑一百个。这一章有一个 100 项的列表,三个开关,八种组合——每一种的数字都在下面。
先把三个开关全部打开看一遍
三个开关拨出八种组合,最值得看的是这四格:
动作 key 稳定 新 List 重跑 跳过 结果 改第 50 项 ✓ ✓ ✓ 3 99 最好的情况 在头部插入 ✓ ✓ ✓ 3 100 key 起作用了 在头部插入 ✗ ✓ ✓ 103 0 整个列表全跑 就地改数组 ✓ ✓ ✗ 0 0 界面根本不更新
三个截然不同的失败模式,值得一个一个说。
key:给 Compose 认人用的,不是给你排序用的
先看第二、三行的差别。同样是「在头部插入一项」:
// ✅ 3 个重跑
LazyColumn {
items(items = state.items, key = { it.id }) { item -> TodoRow(item) }
}
// ❌ 103 个重跑
LazyColumn {
items(items = state.items) { item -> TodoRow(item) }
}
第 3 章讲过原因:没有 key,group 按位置匹配。头部插了一项,所有项的位置往后错了一格:
插入前(按位置) 插入后(按位置)
第 0 格:待办 1 第 0 格:新的一项 ← 参数变了,重跑
第 1 格:待办 2 第 1 格:待办 1 ← 参数变了,重跑
第 2 格:待办 3 第 2 格:待办 2 ← 参数变了,重跑
… … 全部重跑
写了 key = { it.id } 之后,Compose 就能认出「这些还是原来那些行,只是往下挪了一格」,于是复用整段 group,参数一比没变,全部跳过。
性能只是顺带的收益。key 更重要的作用是保住每一行自己的状态:
- 每行里
remember的东西(展开/收起、编辑中的内容、动画进度) - 每行的
LaunchedEffect(比如图片加载) - item 的进入/退出动画(
animateItem()完全依赖 key)
没有 key 的时候,头部插入一项会导致你在第 3 行打的字出现在第 4 行——这是个功能 bug,不是性能问题。
key 该用什么
| 用什么 | 行不行 | 说明 |
|---|---|---|
| 数据库 id、服务端 id | 最好 | 稳定、唯一、和位置无关 |
| 业务上唯一的字段(订单号、手机号) | 好 | 确认真的唯一 |
index | 等于没写 | 它就是位置,key 的意义全没了 |
| 整个 item 对象 | 危险 | 它得可序列化(要进 Bundle)而且 equals 要稳定;内容一变 key 就变,等于每次都重建 |
随机数 / UUID.randomUUID() | 灾难 | 每次组合都是新 key → 每次都整段重建 |
还有一条硬要求:key 必须唯一,重复了会抛 IllegalArgumentException 直接崩溃。合并多个数据源的列表时要特别小心(两个源可能有相同的 id),常见做法是加前缀:key = { "${it.type}-${it.id}" }。
另外,key 会被存进 Bundle(用于恢复滚动位置),所以它必须是 Bundle 认识的类型——基本类型、String、Parcelable。
那一格「界面根本不更新」
把第三个开关关掉(不新建 List,就地改数组),你会看到:
重跑 0 · 跳过 0 · 没走到 101 —— 什么都没发生。用户点了勾选框,界面纹丝不动。
第 5 章解释过原因:mutableStateOf 默认用结构相等,你给它的还是同一个数组对象,它一比:一样,于是当作没发生。
// ❌ 界面不动
val list = _uiState.value.items // MutableList
list[49] = list[49].copy(done = true) // 就地改
_uiState.value = _uiState.value.copy(items = list) // 引用没变,equals 相等
// ✅
_uiState.update { s ->
s.copy(items = s.items.map { if (it.id == 50) it.copy(done = true) else it })
}
这是新手在 Compose 上最常撞的墙,而且它不报错。你会看到数据在 Logcat 里明明变了,界面就是不动。
预防办法只有一条,而且很有效:状态里只放不可变类型。List 而不是 MutableList,data class + copy() 而不是 var。这样你想「就地改」都改不了。
如果你确实想要「就地改」的写法(比如一个可拖拽排序的列表),Compose 提供了会自己通知的容器:
val items = remember { mutableStateListOf<Todo>() }
items.add(todo) // 会通知
items[3] = items[3].copy(…) // 会通知
items.removeAt(0) // 会通知
它是 @Stable 的,所以传给 composable 也不会破坏跳过。
但它只适合放在 UI 层——它是 Compose 运行时的类型,把它放进 ViewModel 会让 ViewModel 依赖 Compose,也不好测。ViewModel 里还是用不可变 List + StateFlow。
稳定性那个开关
把中间那个开关关掉(item 类型里有一个 var),你会发现结果几乎没变——还是重跑 3 个。
这不是 bug,是第 4 章讲的强跳过在起作用:不稳定的参数改用引用相等来比。你 map 出的新 List 里只有第 50 项是新对象,其它 99 项引用没变,所以照样能跳过。
但这里有一个隐含的前提,而它在列表场景下特别容易被打破:
// ❌ 每次组合都产生 100 个新对象 → 引用全变 → 一个都跳不过
items(state.items.map { it.toUiModel() }) { … }
// ❌ 每次组合都产生一个新 List → LazyColumn 自己就跳不过
items(state.items.filter { !it.done }) { … }
// ✅ 记住它
val visible = remember(state.items) { state.items.filter { !it.done } }
items(visible, key = { it.id }) { … }
在参数位置上做集合变换,是强跳过时代最常见的性能问题。更好的做法是把变换挪到 ViewModel 里去做——那里本来就该做数据加工,而且结果会被 StateFlow 自然地缓存住。
LazyColumn 特有的三件事
① contentType:让不同类型的项复用各自的槽
LazyColumn {
items(
items = feed,
key = { it.id },
contentType = { it::class } // ← 告诉它「这是哪一类」
) { item ->
when (item) {
is Post -> PostCard(item)
is Ad -> AdBanner(item)
is Divider -> DividerRow()
}
}
}
混合类型的信息流里,不写 contentType 会导致「一条广告的槽被复用给了一条帖子」,结构完全对不上,只能整段重建。只要你的列表里有两种以上的行,就该写它。
② 别在 items 的 lambda 外面做重活
// ❌ 这一行在每次 LazyColumn 重组时都会跑,哪怕只有一项可见
LazyColumn {
val sorted = feed.sortedByDescending { it.time } // 1000 项排序
items(sorted) { … }
}
LazyColumn 的 content 块本身不是 lazy 的——lazy 的是 items 里那个渲染 lambda。块里的其它代码每次都会执行。
③ 滚动位置相关的东西要延迟读取
第 2 章和第 20 章的组合拳,在列表上是必修:
// ❌ 每帧重组整个屏幕
val showFab = listState.firstVisibleItemIndex > 3
// ✅
val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } }
listState 的几乎所有属性都是每帧变的,在最外层读任何一个,都等于让整屏每帧重组一次。
对项目里每一个 LazyColumn / LazyRow / LazyVerticalGrid: 1. 有没有写 key? 没有 → 加上,用业务 id 2. 有多种行类型吗? 有 → 加 contentType 3. items(…) 的参数里有没有 .map / .filter / .sorted? 有 → 挪到 ViewModel 或包 remember 4. content 块里(items 之外) 有没有重活? 有 → 挪出去 5. 有没有在外层读 listState 的属性? 有 → 包 derivedStateOf 或改用 lambda 验证:Layout Inspector 打开,滚动一下, 看 TodoRow 的 Recomposition count 涨得有多快。 正常情况下它应该只在「这一行的数据真的变了」时涨。
这一章的一句话
头部插入一项:写了 key 重跑 3 个,没写 key 重跑 103 个。而 key 更重要的作用不是性能,是保住每一行自己的状态。至于「界面完全不更新」那一格——那不是 bug,是你没告诉它变了。
下一章:前面讲了这么多「什么时候重跑」,最后一个问题是——怎么真的看见它?三个工具,一条铁律,外加一个会让你意外的数字:两种写法「真的重跑」的次数几乎一样,但一种要走过 150 个节点,另一种只要 30 个。