卷 I · 重跑CH 03深度 3/24

remember 凭什么记得住:位置就是身份

函数每次都被重新调用,局部变量每次都重新初始化,那 remember { } 里的东西是怎么活下来的?答案不在变量名里,在「它是第几次被调用到的第几个槽」里——这个设计解释了一堆看起来毫无道理的 bug。

slot 表位置记忆key()onDispose

一个必须先想清楚的矛盾

看这两行:

@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 的值被丢掉,所有 DisposableEffectonDispose 触发,所有 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 里切换第二个模式再点一次,日志会把这个过程一条条列出来:discarddispose → 新的 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 不是缓存,别拿它当缓存用

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 编译器会在 ifwhen、循环这些地方自动插入 group 边界,把每个分支包成独立的一段。这也是为什么它必须是编译器插件而不是普通库。

但你要知道它在做这件事,因为这解释了:

  • 为什么 @Composable 函数不能在普通 lambda 里随便调用(编译器需要知道 group 边界在哪)
  • 为什么循环里的 composable 需要 key(第 22 章会用一个 100 项的列表把这件事量给你看)

这一章的一句话

slot 表按位置存东西,不按名字。位置、函数、key 三样中任何一样变了,Compose 就认为「这是另一个东西」——旧的整段作废,该清理的清理,新的从头开始。

下一章:位置对上了、group 复用了,Compose 接下来要做一个判断——「这次能不能干脆不跑?」这个判断有一套很短的规则,也有一次影响巨大的默认值变更(强跳过)。它是你满屏 @Immutable 的来历,也是你现在可以把它们删掉的理由。