remember 凭什么记得住:位置就是身份
函数每次都被重新调用,局部变量每次都重新初始化,那 remember { } 里的东西是怎么活下来的?答案不在变量名里,在「它是第几次被调用到的第几个槽」里——这个设计解释了一堆看起来毫无道理的 bug。
一个必须先想清楚的矛盾
看这两行:
@Composable
fun Counter() {
var n by remember { mutableStateOf(0) } // 它凭什么记得住?
Button(onClick = { n++ }) { Text("$n") }
}
Counter() 每次重组都会被完整地重新调用一遍。按照普通函数的规矩,n 是个局部变量,每次调用都应该从 0 开始。但它没有。
它没有,是因为 remember { } 做的事不是「声明一个变量」,而是:
「去 slot 表里我这个位置上看看有没有东西。 有 → 拿出来还给你。 没有 → 跑一遍花括号里的代码,把结果存进去,再还给你。」
关键词是「我这个位置」。不是「叫 n 的那个变量」,也不是「这段代码」——是位置。
slot 表:一条很长的带子
Compose 在运行时维护一个叫 slot table 的结构。你可以先把它想成一条很长的带子,上面按顺序记着这一次组合过程中发生的一切:
┌ group TodoScreen │ ├ group TopBar │ │ └ slot "今天" ← 上次传进去的参数,用来比较 │ ├ group TodoList │ │ ├ group TodoRow key=1 │ │ │ └ slot ScrollState@a1 ← 一个 remember 出来的对象 │ │ ├ group TodoRow key=2 │ │ └ group TodoRow key=3 │ └ group InputBar │ └ slot MutableState(draft)
每一次 composable 调用对应一段 group,每一个 remember 对应 group 里的一个 slot。重组的时候,重组器带着一个游标顺着这条带子走:第 N 次走到第 N 个位置,就该是同一格。
这就是「位置记忆」(positional memoization)。它有一个很实用的推论:你不需要给状态起名字、也不需要注册它,只要它在代码里的位置不变,它就还在。
但如果位置变了呢
上面这台 demo 里的第一个选项就是那个经典的坑:
if (tab == "A") {
PanelA() // 里面有 remember { ScrollState() }
} else {
PanelB() // 里面也有 remember { ScrollState() }
}
切换 tab 的时候,同一个位置上先是 PanelA,后是 PanelB。Compose 一看:名字都不一样,那不是同一个东西——整段 group 作废:里面所有 remember 的值被丢掉,所有 DisposableEffect 的 onDispose 触发,所有 LaunchedEffect 的协程被取消。
然后新建一段 group,从头来过。
这通常正是你要的。但如果你写的是这样:
// ❌ 两个分支各有一份「自己的」滚动位置
if (isGrid) {
LazyVerticalGrid(state = rememberLazyGridState()) { … }
} else {
LazyColumn(state = rememberLazyListState()) { … }
}
用户切一次布局,滚动位置就归零一次。你想让它保住,就得把状态提到 if 外面——这也是下一卷「状态提升」的动机之一。
翻页的时候用同一个 composable 显示不同的页:
// ❌ 位置没变 → remember 命中 → 新一页用着上一页的滚动位置 Page(id = currentPageId)
Compose 认为「同一个位置、同一个函数 = 同一个东西」,于是 Page 内部的 remember 全部命中,你会看到翻到新一页时滚动条停在上一页的位置、动画从上一页的状态接着播。
解法就是 demo 的第二个选项:给它换一把钥匙。
key():手动接管身份
key(currentPageId) {
Page(id = currentPageId)
}
key() 做的事只有一件:给这段 group 贴一个标识。重组的时候,Compose 不再只看「位置 + 函数名」,还要看这把钥匙对不对得上。
- 钥匙一样 → 还是原来那段 group,
remember全部命中 - 钥匙变了 → 认定这是另一个东西:旧 group 整段丢弃(触发
onDispose、取消协程),新建一段
demo 里切换第二个模式再点一次,日志会把这个过程一条条列出来:discard → dispose → 新的 remember 首算 → LaunchedEffect 用新 key 启动。
Compose 认「东西」的身份,靠三样:位置、函数、key。
- 三样都没变 → 同一个东西 → 活下来
- 任何一样变了 → 换了个东西 → 旧的整段作废,该清理的清理,新的从头开始
你平时遇到的「状态莫名其妙没了」和「状态莫名其妙还在」,全部是这条规则的两个方向。
remember 的 key 参数是另一回事
这里有个很容易混淆的地方。remember 自己也能带 key:
val formatted = remember(amount, currency) {
formatMoney(amount, currency) // 只有这两个变了才重算
}
它和 key(…) { } 完全是两码事:
remember(k) { } | key(k) { } | |
|---|---|---|
| 管的是 | 一个槽里的值要不要重算 | 一整段 group 是不是同一个东西 |
| key 变了 | 重新跑一遍花括号,换一个值 | 整段丢弃、重建,effect 全部重来 |
| 会触发 onDispose 吗 | 不会 | 会 |
| 典型用途 | 缓存一次计算结果 | 列表项、翻页、tab 切换 |
remember 的存活范围是「这段 group 还在组合里」。用户退出这个屏幕、切走一个 tab、列表项滚出可视区(在 LazyColumn 里)——它就没了。
更要命的是旋转屏幕:整个 Composition 重建,所有 remember 一起归零。想跨过配置变更,得用 rememberSaveable;想跨过更多,得往上提。这是第 8 章那张表要处理的事。
还有一条:remember { } 里不要放需要清理的东西(订阅、监听器、Job)。它没有「离开时通知我」的机制——那是 DisposableEffect 的活(第 18 章)。
为什么 composable 里不能写 if 包住 remember
现在你可以自己推出这条规矩了:
// ❌ 别这么写
@Composable
fun Foo(showExtra: Boolean) {
val a = remember { A() }
if (showExtra) {
val b = remember { B() } // 有时候有、有时候没有
}
val c = remember { C() } // ← 它的槽位会漂移
}
showExtra 为 true 的时候,槽位顺序是 a, b, c;为 false 的时候是 a, c。游标一走,c 就对上了原本属于 b 的那一格。
好消息是:你几乎不会真的写出这个 bug,因为 Compose 编译器会在 if、when、循环这些地方自动插入 group 边界,把每个分支包成独立的一段。这也是为什么它必须是编译器插件而不是普通库。
但你要知道它在做这件事,因为这解释了:
- 为什么
@Composable函数不能在普通 lambda 里随便调用(编译器需要知道 group 边界在哪) - 为什么循环里的 composable 需要
key(第 22 章会用一个 100 项的列表把这件事量给你看)
Flutter 解决同一个问题的办法是 Element 树:Widget 是每帧新建的配置,Element 才是那个持久的、持有 State 的东西。Element 按「同一位置 + 同一 runtimeType + 同一 Key」来复用,规则和这里几乎一模一样。
两边的 Key 也是同一个东西,连踩的坑都一样:列表插入一项而没写 key,所有项的状态串位。
差别在存储方式:Flutter 把状态挂在 Element 对象上(一棵真的树),Compose 把它摊平进一条带子(slot 表)。摊平的好处是遍历极快、缓存友好;代价是「位置」变成了一个隐式的身份,写代码时要更小心。
这一章的一句话
slot 表按位置存东西,不按名字。位置、函数、key 三样中任何一样变了,Compose 就认为「这是另一个东西」——旧的整段作废,该清理的清理,新的从头开始。
下一章:位置对上了、group 复用了,Compose 接下来要做一个判断——「这次能不能干脆不跑?」这个判断有一套很短的规则,也有一次影响巨大的默认值变更(强跳过)。它是你满屏 @Immutable 的来历,也是你现在可以把它们删掉的理由。