卷 II · 记住CH 08深度 8/24

四种「活下来」的时长

「状态放哪儿」这个问题真正在问的是「它该活多久」。Android 有四种会清掉东西的事件,五个常用的存放位置,它们组成一张 20 格的表——而选错格子的代价,通常在测试机上根本看不出来。

rememberrememberSaveableViewModel进程死亡

四种会清掉东西的事件

事件什么时候发生被清掉的
重组随时。一秒可能几十次函数里的局部变量
配置变更旋转屏幕、切换深色模式、改字号、分屏、折叠屏展开整个 Composition(所有 remember
进程死亡App 在后台,系统内存紧张把它杀了。用户完全无感内存里的一切,包括 ViewModel
用户退出返回键退出这个页面、或者从最近任务里划掉返回栈条目 + 挂在它上面的 ViewModel

第三行是最容易被忽略的:进程死亡在真机上很常见,在你的开发机上几乎不发生。你的手机内存大、App 一直在前台调试,所以你永远测不到。用户那边不是。

读这张表的正确顺序

大多数人是这样选的:「我需要一个状态 → 放哪儿呢 → 那就 remember 吧」。这是反的。

正确的顺序是先问活多久

这个东西,用户在什么情况下会觉得「它应该还在」?

只在这一屏、这一次交互里有意义        → remember
旋转屏幕后应该还在                    → rememberSaveable 或 ViewModel
从后台回来(哪怕被杀过)应该还在      → rememberSaveable / SavedStateHandle
下次打开 App 还应该在                 → DataStore / 数据库

然后从「刚好够用」的那一格里选,不要往上多选一格。原因下面就说。

为什么「活得太久」比「活得不够」更糟

「活得不够」的 bug 长这样:旋转屏幕,输入的内容没了。用户会立刻发现,你也会立刻发现。这类 bug 一天之内就会被报上来。

「活得太久」的 bug 长这样:

  • 用户在搜索页输入了什么,退出、过两天重新打开 App,那个词还在——因为你用了 rememberSaveable,而返回栈条目的 SavedState 被系统持久化了
  • 用户填了一半的表单,退出重进,还是那半张,他以为自己重新开始了,结果提交上去带着旧数据
  • 一个「加载中」的标记被 rememberSaveable 存下来了,进程被杀之后恢复,界面永远停在转圈上——因为那次加载早就不存在了

最后一条是真正的经典。它的特征是:只在特定的、难以复现的时序下出现,而且看代码完全正常。

⚠ rememberSaveable 不该存的三类东西
  • 正在进行的操作的状态(isLoading、正在上传的进度)。进程死过之后这些操作都没了,状态却还在,界面就卡死了。
  • 大对象。它走的是 Bundle,而 Bundle 跨进程要过 Binder,有大小上限(实际可用大概几百 KB,超了会直接 TransactionTooLargeException 崩溃)。存 id,不存对象。
  • 能从别处推出来的东西。能从 id 重新查出来的,就只存 id。

ViewModel 那一格的两个细节

ViewModel 在表里的位置有点特别:它能扛住配置变更,扛不住进程死亡。很多人以为它什么都能扛。

class SearchViewModel(
    private val savedStateHandle: SavedStateHandle    // ← 这个才扛得住进程死亡
) : ViewModel() {

    // ❌ 进程被杀之后没了
    var query by mutableStateOf("")

    // ✅ 走 Bundle,进程被杀也能恢复
    val query: StateFlow<String> = savedStateHandle.getStateFlow("query", "")
    fun onQueryChange(q: String) { savedStateHandle["query"] = q }
}

另一个细节是它的作用域到底挂在谁身上。这个决定了「用户退出」那一列的结果:

怎么获取挂在谁身上什么时候被清掉
viewModel()最近的 ViewModelStoreOwner,在 Compose 导航里通常是当前的返回栈条目这个条目被弹出返回栈时
viewModel(navBackStackEntry)你指定的那个条目那个条目被弹出时
viewModel(LocalActivity.current as ViewModelStoreOwner)整个 ActivityActivity 真正结束时。相当于全局单例,慎用

「多个页面要共享一个 ViewModel」的时候,正确做法是把它挂在那几个页面共同的父级导航图上,而不是挂到 Activity 上。挂 Activity 意味着它活到用户彻底退出 App 为止——这又是一个「活得太久」。

◆ 这一章的核心

五个位置,一句话记住各自的边界:

  • 局部变量——活到这次函数调用结束
  • remember——活到这段 group 离开组合(slot 表那一格
  • rememberSaveable——活到这个返回栈条目消失,穿得过进程死亡
  • ViewModel——活到它挂着的那个作用域结束,穿不过进程死亡(除非用 SavedStateHandle
  • DataStore / 数据库——活到用户卸载

注意 rememberSaveable 和 ViewModel 在「进程死亡」那一格上是反过来的,很多人搞混。

rememberSaveable 存自定义类型

默认它只能存 Bundle 认识的东西。存自己的类要给一个 Saver

// 方式一:让类型自己可序列化(最省事)
@Parcelize
data class Filter(val tag: String, val onlyUnread: Boolean) : Parcelable

var filter by rememberSaveable { mutableStateOf(Filter("", false)) }

// 方式二:写一个 Saver,拆成 Bundle 认识的东西
val FilterSaver = listSaver<Filter, Any>(
    save = { listOf(it.tag, it.onlyUnread) },
    restore = { Filter(it[0] as String, it[1] as Boolean) }
)
var filter by rememberSaveable(stateSaver = autoSaver()) { … }

Saver 的时候有个很好的自检:如果你觉得这个 Saver 写起来很麻烦,那多半是这个东西不该存在这儿。存 id、存关键字段,回来之后重新查——几乎总是更好的设计。

⌗ 到你手上:怎么在自己机器上测出「进程死亡」

这是这一章唯一必须动手的一件事,因为它默认测不到:

每个有表单、有筛选条件、有搜索框的页面都该走一遍这个流程。它能在十分钟内找出你项目里一半的状态存放错误。

卷 II 到此结束

回头看一下这一卷解决了什么:

  • 第 5 章:State 是什么——记录链、读者登记簿、相等策略
  • 第 6 章:谁读了谁重跑——所以「在哪一行读」是性能决策
  • 第 7 章:状态该放在哪一层——提到共同祖先就停
  • 第 8 章:状态该活多久——四种事件,五个位置

到这里,Compose 这一侧的「什么活下来、什么重算」你已经有完整的模型了。

接下来两卷要把同一套问题搬到另一边:协程和 Flow。你会发现它们问的是同样的三个问题——重跑的时候,什么活下来(replayStateFlow 的 value)、什么重算(冷流被重新收集)、什么被取消(Job 被掐)。只是词换了。

这一章的一句话

先问「它该活多久」,再选放哪儿。四种事件里,进程死亡是你测不到但用户天天遇到的那一种;而大多数状态 bug 的形状是「它活得比你以为的久」。

下一卷第一章:suspend 这个关键字到底做了什么。答案会有点扫兴——它只是把回调藏起来了——但把「怎么藏」拆开看,你会顺便理解协程为什么能被取消、为什么能跨线程、为什么栈上的局部变量得搬到堆上去。