卷 VI · 落地CH 22深度 22/24

列表:key 与稳定性决定重跑多少项

列表是 Compose 性能问题最集中的地方,原因很简单:别的地方多跑一次是多跑一个节点,列表里多跑一次是多跑一百个。这一章有一个 100 项的列表,三个开关,八种组合——每一种的数字都在下面。

LazyColumnkey稳定性就地修改

先把三个开关全部打开看一遍

三个开关拨出八种组合,最值得看的是这四格:

动作          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 真正解决的是什么

性能只是顺带的收益。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 而不是 MutableListdata class + copy() 而不是 var。这样你想「就地改」都改不了。

✎ 另一条路:SnapshotStateList

如果你确实想要「就地改」的写法(比如一个可拖拽排序的列表),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) { … }
}

LazyColumncontent 块本身不是 lazy 的——lazy 的是 items 里那个渲染 lambda。块里的其它代码每次都会执行。

③ 滚动位置相关的东西要延迟读取

第 2 章和第 20 章的组合拳,在列表上是必修:

// ❌ 每帧重组整个屏幕
val showFab = listState.firstVisibleItemIndex > 3

// ✅
val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } }

listState 的几乎所有属性都是每帧变的,在最外层读任何一个,都等于让整屏每帧重组一次。

⌗ 到你手上:列表五连查

这一章的一句话

头部插入一项:写了 key 重跑 3 个,没写 key 重跑 103 个。而 key 更重要的作用不是性能,是保住每一行自己的状态。至于「界面完全不更新」那一格——那不是 bug,是你没告诉它变了。

下一章:前面讲了这么多「什么时候重跑」,最后一个问题是——怎么真的看见它?三个工具,一条铁律,外加一个会让你意外的数字:两种写法「真的重跑」的次数几乎一样,但一种要走过 150 个节点,另一种只要 30 个。