四种「活下来」的时长
「状态放哪儿」这个问题真正在问的是「它该活多久」。Android 有四种会清掉东西的事件,五个常用的存放位置,它们组成一张 20 格的表——而选错格子的代价,通常在测试机上根本看不出来。
四种会清掉东西的事件
| 事件 | 什么时候发生 | 被清掉的 |
|---|---|---|
| 重组 | 随时。一秒可能几十次 | 函数里的局部变量 |
| 配置变更 | 旋转屏幕、切换深色模式、改字号、分屏、折叠屏展开 | 整个 Composition(所有 remember) |
| 进程死亡 | App 在后台,系统内存紧张把它杀了。用户完全无感 | 内存里的一切,包括 ViewModel |
| 用户退出 | 返回键退出这个页面、或者从最近任务里划掉 | 返回栈条目 + 挂在它上面的 ViewModel |
第三行是最容易被忽略的:进程死亡在真机上很常见,在你的开发机上几乎不发生。你的手机内存大、App 一直在前台调试,所以你永远测不到。用户那边不是。
读这张表的正确顺序
大多数人是这样选的:「我需要一个状态 → 放哪儿呢 → 那就 remember 吧」。这是反的。
正确的顺序是先问活多久:
这个东西,用户在什么情况下会觉得「它应该还在」? 只在这一屏、这一次交互里有意义 → remember 旋转屏幕后应该还在 → rememberSaveable 或 ViewModel 从后台回来(哪怕被杀过)应该还在 → rememberSaveable / SavedStateHandle 下次打开 App 还应该在 → DataStore / 数据库
然后从「刚好够用」的那一格里选,不要往上多选一格。原因下面就说。
为什么「活得太久」比「活得不够」更糟
「活得不够」的 bug 长这样:旋转屏幕,输入的内容没了。用户会立刻发现,你也会立刻发现。这类 bug 一天之内就会被报上来。
「活得太久」的 bug 长这样:
- 用户在搜索页输入了什么,退出、过两天重新打开 App,那个词还在——因为你用了
rememberSaveable,而返回栈条目的 SavedState 被系统持久化了 - 用户填了一半的表单,退出重进,还是那半张,他以为自己重新开始了,结果提交上去带着旧数据
- 一个「加载中」的标记被
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) | 整个 Activity | Activity 真正结束时。相当于全局单例,慎用 |
「多个页面要共享一个 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、存关键字段,回来之后重新查——几乎总是更好的设计。
这是这一章唯一必须动手的一件事,因为它默认测不到:
开发者选项 ▸ 应用 ▸ 后台进程限制 ▸ 不得超过 1 个进程
然后:打开你的 App ▸ 走到某个填了一半的页面
▸ 按 Home 回桌面
▸ 打开另外两三个别的 App
▸ 从最近任务切回来
或者用 adb(更精确,不用改系统设置):
adb shell am kill <你的包名> # 模拟系统杀后台进程
# 注意不是 force-stop —— force-stop 连返回栈都清了,
# 那模拟的是「用户手动划掉」,不是「系统回收」
Android Studio 也有一个按钮:
Logcat 旁边的 Running Devices ▸ ⋮ ▸ Terminate Application
每个有表单、有筛选条件、有搜索框的页面都该走一遍这个流程。它能在十分钟内找出你项目里一半的状态存放错误。
卷 II 到此结束
回头看一下这一卷解决了什么:
- 第 5 章:State 是什么——记录链、读者登记簿、相等策略
- 第 6 章:谁读了谁重跑——所以「在哪一行读」是性能决策
- 第 7 章:状态该放在哪一层——提到共同祖先就停
- 第 8 章:状态该活多久——四种事件,五个位置
到这里,Compose 这一侧的「什么活下来、什么重算」你已经有完整的模型了。
接下来两卷要把同一套问题搬到另一边:协程和 Flow。你会发现它们问的是同样的三个问题——重跑的时候,什么活下来(replay、StateFlow 的 value)、什么重算(冷流被重新收集)、什么被取消(Job 被掐)。只是词换了。
这一章的一句话
先问「它该活多久」,再选放哪儿。四种事件里,进程死亡是你测不到但用户天天遇到的那一种;而大多数状态 bug 的形状是「它活得比你以为的久」。
下一卷第一章:suspend 这个关键字到底做了什么。答案会有点扫兴——它只是把回调藏起来了——但把「怎么藏」拆开看,你会顺便理解协程为什么能被取消、为什么能跨线程、为什么栈上的局部变量得搬到堆上去。