
📰 每日要闻
• 比特币跌破 7.4 万美元,年内重挫超 30%,单日爆仓人数超 13 万,加密市场情绪急转。
• 美银喊出白银百元目标价但同时警示,光伏"去银化"正成为银价持续上涨的最大阻力。
• 美联储 Goolsbee:能源通胀比预期更具持续性,即使油价回落也仍显著高于战前水平。
• AI 内容共创平台 FunloomAI 完成数千万元 Pre-A 融资,估值达 2 亿元。
• Compose Multiplatform v1.12.0-alpha02 发布,Material3 Adaptive 1.3.0-beta02、Navigation 2.10.0-alpha02 同步推进。
前阵子线上来了一个特别诡异的反馈:用户说在我们 App 里填到一半的表单,从微信跳回来就空了。我们看埋点,跳转链路是正常的;看 Activity 生命周期,也只走了 onPause;看 ViewModel,里面的 StateFlow 也还在。但用户截图证据确凿——页面就是白的。
排查了大半天,最后确认是系统在低内存压力下杀掉了我们的进程,用户回到 App 时 Activity 是被重建的。我们用 ViewModel 的 StateFlow 持有了表单数据,进程一死,ViewModel 跟着进程一起灰飞烟灭。我们一直以为 ViewModel 能扛住"销毁重建",结果它扛得住屏幕旋转,扛不住 process death。
这事儿暴露了我们架构里一个挺基础的盲区:状态恢复。Android 这门手艺最难的从来不是写出某个功能,而是处理那些"代码以为页面没死、其实进程已经被回收了"的边界场景。今天就把"状态恢复"这件事拉出来,在架构层面捋一遍——它到底分几层,每一层该用什么工具,工程上怎么做才不会再踩坑。
一、Android 状态丢失的三种"死法"
聊架构方案前,得先把场景分清楚。Android 上"状态丢失"其实是三件不同的事,对应三种不同的对抗手段。我用一张对照表先把场景列清楚:
场景 | 触发原因 | 什么会丢 |
|---|---|---|
配置变更 | 屏幕旋转、字体缩放、深色模式切换 | Activity 实例 |
系统回收 | 后台太久、内存压力、Don't keep activities | 整个进程 |
主动销毁 | 用户 finish、Back 出栈、App 主动退出 | 页面级状态 |
这三种场景,对应三种不同的存活粒度:
• 配置变更:进程没死、ViewModel 没死、应用层 Singleton 都没死,只有 Activity 实例被销毁重建。这是 ViewModel 最擅长的场景。 • 系统回收:进程都没了,内存里所有东西全部清零,但用户视角是"我刚才还在这个页面"。回来时系统会重建任务栈、重建 Activity。这是 SavedStateHandle / onSaveInstanceState 该负责的场景。 • 主动销毁:状态本来就该丢,但有些数据(比如草稿、未提交表单)需要跨页面持久化。这是 DataStore / 数据库 该负责的场景。
我们最初那个 bug 的本质,就是把第二种场景误当成了第一种。ViewModel 在第一种场景下确实能"扛住",但在第二种场景下它必然死透。架构设计里,必须显式区分这三种场景,不能模糊处理。
二、状态分层:四种存活粒度的"状态宇宙"
把场景搞清楚之后,下一步是从架构上对状态做分层。我现在给团队定的标准是:所有页面状态在写代码前,必须先明确它属于哪一层。这一层决定了它该用什么容器、该怎么恢复。
L1:UI 瞬时状态
比如某个按钮按下时的 Ripple 效果、某个 Tooltip 是不是在显示。这些状态本来就应该跟着 Composable 或 View 一起死,没必要恢复。
容器:remember(Compose)/ View 内部字段。
L2:Activity 实例级状态
比如列表的滚动位置、TextField 里输入了一半的内容、某个 Dialog 是否在显示。这类状态在屏幕旋转时不能丢,但允许在进程被杀后丢失(用户重新进 App 不会期望恢复滚动位置)。
容器:rememberSaveable(Compose)/ ViewModel 中的 StateFlow。
L3:进程级状态
比如填到一半的表单、用户的搜索词、当前看到第几个详情。这类状态进程死了也不能丢——用户期望"我从微信回来还在那"。
容器:SavedStateHandle(推到磁盘 Bundle)。
L4:跨进程持久化状态
比如登录态、用户偏好、本地草稿、离线缓存。这些是真正的应用数据,应该被显式持久化,跟 UI 无关。
容器:DataStore / Room / 文件 / 远端。
架构原则:L1 不要用 ViewModel(多余);L2 不要塞进 SavedStateHandle(占 Bundle 配额);L3 必须用 SavedStateHandle,绝不能只放 ViewModel;L4 永远不要从 SavedStateHandle 读,那是 Repository 的活。每一层各司其职,混层就会出 bug。
三、SavedStateHandle 的正确打开方式
L3 是大多数线上故障的重灾区。Google 给出的标准答案是 SavedStateHandle,但我看过太多团队把它用成"放点 key-value 的小本子",没有真正发挥它和 ViewModel 集成的优势。
正确的姿势是把 SavedStateHandle 当成 ViewModel 内部 StateFlow 的持久化映射层,而不是手动 get/put 的 Map。
class FormViewModel(
private val savedState: SavedStateHandle
) : ViewModel() {// L3 状态:
// 进程死也要恢复
val draft: StateFlow<String> =
savedState.getStateFlow(
"draft",
""
)fun updateDraft(text: String) {
// 写回 SavedStateHandle
// 自动同步给 StateFlow
savedState["draft"] = text
}// L2 状态:
// 旋转要在,进程死无所谓
private val _isExpanded =
MutableStateFlow(false)
val isExpanded: StateFlow<Boolean> =
_isExpanded.asStateFlow()
}关键点有几个:
• 用 getStateFlow 而不是 getLiveData。前者会自动把"写入 SavedStateHandle"和"StateFlow 发射"绑定起来,你不用手动同步两份状态。
• 同一个 ViewModel 里 L2 和 L3 状态分开存。L2 用普通 MutableStateFlow,L3 用 SavedStateHandle。混在一起会让 SavedState Bundle 越来越胖。
• SavedStateHandle 只能放 Bundle 支持的类型——基本类型、Parcelable、可序列化的 Java 对象。复杂的领域对象一般不该塞进去。
SavedStateHandle 的容量陷阱
系统对每个 Bundle 是有大小限制的(实测大约 500KB 左右,看不同 ROM),超过会直接 TransactionTooLargeException。我见过有团队为了"保险"把整个列表数据都塞 SavedStateHandle,结果线上一翻车就是几千 crash。
原则是只存"够重建 UI 的最少索引"——比如列表的当前页码而不是列表本身、详情页的 ID 而不是详情对象。真正的数据放 Repository(L4),重建 UI 的时候根据这些索引重新查。
四、把"状态恢复"做成架构默认能力
真正难的不是单个页面会不会写恢复,而是怎么让团队所有页面都默认正确。我现在的工程做法分三步:
第一步:在 ViewModel 基类里强制声明状态层级
给每个 ViewModel 一个抽象基类,要求子类显式声明哪些状态是 L3、哪些是 L2。这样 review 代码时一眼能看出"这个表单 draft 到底有没有挂在 SavedStateHandle 上"。
abstract class RestorableViewModel(
protected val savedState:
SavedStateHandle
) : ViewModel() {// 声明 L3 状态:
// 必须挂在 savedState 上
protected fun <T> persisted(
key: String,
default: T
): StateFlow<T> =
savedState.getStateFlow(key, default)// 声明 L2 状态:
// 进程死掉就丢
protected fun <T> transient(
initial: T
): MutableStateFlow<T> =
MutableStateFlow(initial)
}子类用 persisted("draft", "") 声明 L3 状态、用 transient(false) 声明 L2 状态,类型签名一眼就能看出来。
第二步:用 Don't keep activities 做日常自测
开发选项里那个"不保留活动"开关,是检验 L3 状态恢复是否正确的最简单方法。我们团队约定每个新页面提测前必须打开这个开关跑一遍——只要打开它还能正常用,process death 大概率扛得住。
更进一步:CI 里跑 Espresso 时也开启这个开关,关键页面强制做"按 Home 再回来"的恢复测试。这一步上 CI 之后,我们 process death 类的线上 crash 直接降了 80%。
第三步:监控 SavedStateHandle 的体积
给 ViewModel 基类加一个 hook,在 onCleared 时统计当前 SavedStateHandle 里所有键值的序列化总大小,超过阈值(比如 50KB)就上报一条警告埋点。
这是个偏运维的能力,但效果很好——能在 TransactionTooLargeException 真正爆掉之前提前发现问题。我们用这套机制提前拦截过两次"试图把整个商品列表塞 SavedStateHandle"的代码合并。
五、ViewModel scope 的边界
很多人把 ViewModel 当成"页面的状态宇宙",但 ViewModel 本身的 scope 也是有讲究的。架构里需要明确三种 scope:
• 页面级 ViewModel(最常见):跟 Activity/Fragment 生命周期绑定,destinationId 不同就是不同实例。
• 导航图级 ViewModel:跨多个页面共享,常用于多步表单(注册流程、下单流程)。Compose Navigation 提供 hiltViewModel(navController.getBackStackEntry(graphId))。
• Activity 级 ViewModel:所有 Fragment 共享,但容易变成"什么都往里塞"的全局袋子,要慎用。
scope 选错有两种典型后果:scope 太大(比如把页面状态挂到 Activity 上),不同实例之间会互相污染;scope 太小(比如多步表单每页一个 ViewModel),跨页面传递 L3 状态时只能走 NavBackStackEntry 的 SavedStateHandle,绕来绕去出错率高。
ViewModel scope 是架构题,不是 API 题。决策的核心问题永远是:这块状态的"逻辑生命周期"是哪一段?
六、Compose 时代的几个新坑
切到 Compose 之后,状态恢复又多了几个新坑值得提一下:
• remember vs rememberSaveable:前者只扛 recomposition,后者还能扛 Activity 重建。但 rememberSaveable 有类型限制(必须能放 Bundle),复杂对象需要自定义 Saver。新人最常见的错误是:以为 rememberSaveable 能扛 process death——它扛不住,因为它存的也是 Activity 的 Bundle。
• Composable 函数里直接 collectAsStateWithLifecycle:这是新接口,比老的 collectAsState 更省电(页面不可见时停止收集)。但要注意:从不可见恢复可见时,会拿到 ViewModel 当前的 state,不会"补"中间错过的状态——这跟 RxJava 时代的 Hot/Cold Observable 思维不一样。
• SaveableStateHolder 的滥用:在 BottomNavigation 多 tab 场景下,很多人为了让每个 tab 自己的滚动位置不丢,无脑用 SaveableStateHolder 包一层。但这会把所有 tab 的状态都塞 Activity Bundle,体积爆炸。正确做法是按需选择性持久化,而不是整棵子树都包进去。
七、一个完整的状态恢复 Checklist
最后给一个我们内部 review 时用的 checklist,每次新页面合并前对照走一遍:
• 页面所有的可变状态我都标过 L1/L2/L3/L4 了吗? • L3 的状态全部挂在 SavedStateHandle 上了吗? • SavedStateHandle 里只放了"恢复 UI 必须的最小索引",没有放完整业务对象吗? • 多步表单的跨页面状态用了 NavBackStackEntry scope 而不是 Activity scope 吗? • Compose 里所有需要扛屏幕旋转的状态用了 rememberSaveable 吗? • 我开了"不保留活动"自测过一遍流程吗? • 关键 ViewModel 加了 SavedStateHandle 体积监控吗?
这 7 条全部勾掉,process death 类的线上反馈基本就能消失。这套 checklist 我们用了大半年,新人入职第一件事就是背这个表,效果比讲再多原理都管用。
八、写在最后
Android 架构这些年从 MVP 卷到 MVVM 再到 MVI、Compose、单向数据流,其实底层一直没变的事情就两件:状态怎么管,状态怎么恢复。功能写得再花,process death 一来全白搭。
状态恢复这件事工程难度不高,但思维难度不低——你得分清楚"页面没了但进程还在"和"页面没了进程也没了"是两件事,得分清楚 ViewModel 和 SavedStateHandle 各自负责什么,得在团队里把这套层级讲清楚、写到基类里、放进 review checklist 里。这套东西做扎实之后,整个 App 的稳定性会提一档,用户体验也会顺滑很多。