深入理解 Jetpack Compose `key()` 方法

深入理解 Jetpack Compose key() 方法

在 Jetpack Compose 里,key() 是一个很重要、但又很容易被轻视的 API。

很多人只记得一句话:

列表里最好加 key

但如果继续追问,往往就说不清了:

  • key() 到底解决了什么问题?
  • 不加会发生什么?
  • 它和重组有什么关系?
  • 它到底算不算性能优化?
  • 它和 LazyColumnkeyremember(key) 又有什么区别?

这篇文章想把这件事讲透。核心结论先放前面:

key() 的本质,不是“加速渲染”,而是给一段组合内容一个稳定身份。

当列表发生插入、删除、重排时,这个身份能帮助 Compose 把“当前这段 UI”正确对应到“之前那段 UI”,从而避免状态错位,并减少不必要的重建。


一、Compose 默认如何判断“这是不是同一个节点”?

Compose 在重组时,需要判断一段 Composable 内容和上一次相比是不是“同一个东西”。

默认情况下,Compose 会结合调用位置来识别组合中的实例。

你可以先把它粗略理解成:

identity ≈ 调用位置 + 执行顺序

这也是为什么下面这种代码在“静态顺序”下通常没问题:

for (item in list) {
    Text(item.name)
}

但一旦 list 的顺序发生变化,Compose 就未必还能知道:

“现在第 0 个位置这个 Text,是不是之前那个 item 对应的 Text?”

这时,就需要额外的信息来告诉 Compose:“它是谁”,而不只是“它现在排第几个”。


二、不加 key(),动态列表最容易出现什么问题?

看一段常见代码:

for (stock in stocks) {
    StockRow(stock)
}

如果 stocks 只是内容刷新、顺序不变,通常问题不大。
但如果它会发生这些变化:

  • 按价格重新排序
  • 插入新项
  • 删除旧项
  • 拖拽重排

那么 Compose 可能会按“位置”去理解这些 item,而不是按“业务身份”去理解它们。

这类情况下,常见现象包括:

  • remember 的状态跟着位置走,而不是跟着 item 走
  • 动画中断或绑定到错误对象
  • 某些副作用被取消后重新启动
  • 原本可以复用的内容没有被正确复用

所以,问题的根源不是“列表变了”,而是:

Compose 缺少一个稳定标识,无法确认“重排前后的这个节点其实是同一个 item”。


三、key() 到底做了什么?

key() 的作用,就是为一段组合内容显式提供身份标识。

for (stock in stocks) {
    key(stock.code) {
        StockRow(stock)
    }
}

加上以后,Compose 在识别这段内容时,不再只依赖位置,还会参考你提供的 key。

可以把它理解成:

identity ≈ 调用位置 + 你提供的 key

这不是官方 API 文档里的逐字公式,而是对工作方式的简化理解。更准确地说,key() 会改变 Compose 为这段内容建立身份映射的方式,让它在重排时更容易找到“同一个实例”。


四、key() 解决的核心问题

1. 避免 remember 状态错位

这是最常见、也最值得重视的场景。

for (stock in stocks) {
    var expanded by remember { mutableStateOf(false) }
    StockRow(
        stock = stock,
        expanded = expanded,
        onExpandClick = { expanded = !expanded }
    )
}

如果列表重排,而你没有提供稳定 key,这个 expanded 很可能会跟着“位置”走。结果就是:

  • A 的展开状态跑到 B 身上
  • 原本展开的项突然变成别的项展开

而加上 key(stock.code) 之后,这段状态就更有机会稳定地跟着对应的业务对象走。

正确写法:

for (stock in stocks) {
    key(stock.code) {
        var expanded by remember { mutableStateOf(false) }
        StockRow(
            stock = stock,
            expanded = expanded,
            onExpandClick = { expanded = !expanded }
        )
    }
}

2. 让重排更像“移动”,而不是“换人”

很多人会说:

  • 没有 key,Compose 会销毁并重建节点
  • 有 key,Compose 只会移动节点

这个方向是对的,但说得太绝对。

更准确的说法是:

提供稳定 key 后,Compose 在集合变化时更容易把旧实例和新位置正确匹配起来,因此可以更稳定地复用已有组合、状态和相关节点。

也就是说,key() 的价值不是保证“绝对不重建”,而是让 Compose 在插入、删除、重排时拥有更准确的身份信息,从而减少因为误判身份带来的无谓重建。


3. 让列表性能更稳定

这里也需要一个很重要的措辞修正。

key() 不是那种“加了以后所有界面都更快”的万能优化。
它更像是一个结构正确性 + 复用稳定性工具。

所以更准确的说法是:

key() 优化的不是绘制速度本身,而是列表变化时的匹配和复用质量。

特别是在这些场景里更明显:

  • 动态排序列表
  • 插入 / 删除频繁的列表
  • 带动画的列表项
  • 列表项内部有 remember
  • 依赖副作用或异步加载的列表项

五、key()LazyColumn 里的 key 有什么区别?

这是最容易混淆的地方之一。

普通组合里的 key()

for (item in list) {
    key(item.id) {
        ItemRow(item)
    }
}

这是一个组合作用域里的 API。它直接影响这段内容在 Composition 中的身份识别。

LazyColumn 里的 key

LazyColumn {
    items(
        items = list,
        key = { it.id }
    ) { item ->
        ItemRow(item)
    }
}

这是 lazy list 提供的 item key。

两者机制相关,但作用层级不同:

API 主要作用
key() 给一段组合内容指定身份
items(key = …) 给 lazy item 指定稳定身份,帮助列表在数据变化时正确复用和保留状态

所以你可以把它们理解为:

本质思想一致,使用位置不同。

如果你在 LazyColumn 里展示列表,优先使用 items(..., key = { ... })
如果你是在普通 for 循环、条件分支或手写组合结构里管理身份,则使用 key()


六、它和 remember(key) 完全不是一回事

很多人会把这两个写法混在一起:

key(item.id) { ... }

remember(item.id) { ... }

它们解决的是两个完全不同的问题。

key()

改变的是这段组合内容的身份

remember(key)

改变的是remember 保存的值什么时候失效并重新计算

所以:

  • key() 关心的是“这是谁”
  • remember(key) 关心的是“这个值要不要重新算”

一个偏结构身份,一个偏值缓存。不要互相替代。


七、什么时候应该优先考虑使用 key()

下面这些场景,建议你认真检查是否需要 key:

1. 列表会重排

例如:

  • 按价格排序
  • 按时间倒序
  • 拖拽排序
  • 服务端返回顺序变化

这是 key 最典型的使用场景。

2. 列表项内部有 remember

只要 item 内部有局部状态,就要特别警惕“状态跟位置走”的问题。

3. 列表项包含动画或副作用

对于带图片加载、网络请求、动画、计时器的 item,这会更明显。

4. 使用 LazyColumnLazyRow、Pager 一类动态容器

尤其是数据不是固定顺序时,更应该提供稳定 key。


八、什么时候没必要滥用?

不是所有地方都需要 key()

下面这些情况通常不用太紧张:

  • 静态 UI
  • 元素顺序固定不变
  • 没有局部状态
  • 没有插入、删除、重排
  • 只是简单渲染一次文本或图片

如果结构稳定,Compose 基于调用位置的默认识别通常已经足够。


九、写 key() 时的两个注意点

1. key 必须稳定

不要用每次都变的值,比如随机数、当前时间戳、hashCode() 不稳定对象等。
如果 key 不稳定,Compose 反而会把它们当成“全新节点”。这会破坏复用。

2. key 应该唯一

至少在同一组兄弟节点范围内,它应该唯一。

最常见的安全选择是:

  • 数据库主键
  • 服务端返回的唯一 id
  • 业务上不会变化的 code / uuid

十、一句话总结

key() 的本质,是告诉 Compose:这段内容是谁,而不是它现在排在第几个。

它真正带来的价值不是“神奇提速”,而是:

  • 防止状态错位
  • 提高列表重排时的复用准确性
  • 减少因身份误判带来的额外重建
  • 让动画、副作用、remember 状态更稳定

如果你在做这些界面:

  • 股票 / Trading 列表
  • 可折叠列表
  • 动态排序列表
  • 拖拽重排列表
  • 带动画的 LazyColumn
  • 内部有 remember 状态的 item

那么 key 往往不是“锦上添花”,而是保证结构正确性的基础设施。


结尾

理解 key() 的最好方式,不是把它当成“性能开关”,而是把它当成 Compose 身份系统的一部分。

当你开始从“位置”切换到“身份”去思考列表和重组时,你对 Compose 的理解就会更深入一层。

因为在声明式 UI 里,真正重要的问题从来不只是:

要画什么

而是:

当数据变化时,谁还是原来的那个谁。