先把结论放在前面:状态保存与恢复的核心不是"存进去",而是"判断什么该存、什么不该存、存的下限在哪"。Activity 状态保存走 onSaveInstanceState 拿到的 Bundle,有约 1MB 的事务上限,任何大对象进去都会在提交时抛 TransactionTooLargeException。这一关立住了,后面全是取舍。
这篇面试里聊到状态保存与恢复,最怕只背结论。要答好,得先知道常见的错误长什么样,再反向把正确姿势讲清。
先讲清三个状态的层次
Android 里的"状态"不是一个东西,至少分三层,混在一起是大部分错误答案的源头。
| 层次 | 载体 | 生命周期 | 典型内容 |
|---|---|---|---|
| 视图自身状态 | View 的 onSaveInstanceState |
视图树重建前后 | TextView 的文本、EditText 的选区、RecyclerView 的滚动位置 |
| 界面状态 | Activity.onSaveInstanceState 的 Bundle |
进程被杀后重建 | 选中的 tab、展开的分组、当前页码 |
| 业务数据 | ViewModel / Repository | 不随进程死 | 已拉取的数据列表、登录态 |
判据是"重建后还需要,且能被廉价重建的东西不该存"。列表数据能从接口拉回来,就不要塞进 Bundle;一个 tab 索引不花代价,重建后丢失体验就崩了,就该存。把这三层分清,答案的层次立刻就出来了。
再补一个容易被忽略的点:onSaveInstanceState 不保证被调用。只有"可能因用户行为而销毁"的场景(按 back、旋转、进入多任务被回收前)才会走回调;用户从最近任务里彻底划掉应用时,进程可能直接被杀,不会留下任何状态。所以任何"必须恢复"的业务状态都不该依赖这个回调。
还有一个维度值得单独讲,因为它决定了恢复策略的上限:进程死亡后,系统能给你什么、拿回什么。
三类数据在进程死亡后的命运完全不同。第一类是静态的"事实"(用户 ID、当前 tab、筛选条件),它们在 Bundle 里,重建时由 savedInstanceState 交还。第二类是"半成品"(已下载 40% 的文件、进行到第 3 步的表单),它们没有可序列化的形态,只能靠业务侧的中间存储(SavedStateHandle 里放断点参数、文件放临时目录)来接续。第三类是"数据本身"(列表、详情),它们在远程,重建后应重新拉取,且要有缓存兜底以免白屏。
按这个划分,每类数据的恢复策略是唯一的,写代码时只要问一句"这属于哪类",方案自然就出来了。这也是为什么很多项目会先建一个 StateStore 统一收口——把"这份状态存哪、怎么恢复"这件事从各个 Activity 里抽出来,避免每个人各写一套。
再补一个调试方法,比读代码高效得多:模拟进程死亡。在开发者选项里勾选"不保留活动"(Always finish activities),然后正常操作一遍 App,每次切页面都会重建 Activity,状态丢失会立刻暴露。这比手动杀进程快得多,也是 CI 里能做端到端状态测试的基础。配合 adb shell am kill <package> 可以只杀进程保 Activity,验证"进程死亡但任务还在"这条路径。
最常见的坑是
把 Bitmap 或大对象序列化进 Bundle,跳转瞬间抛 TransactionTooLargeException。
Bundle 走的是 Binder 事务,跨进程传输时要序列化成 parcelable 字节。1MB 是进程间共享的事务缓冲区上限(Android 早期只有 1MB 量级,虽然后续版本在有 adb 调试时放宽到系统的最大值,但生产设备上要按 1MB 守住)。往里塞一张 2MB 的图片 bitmap,崩溃点往往不是 put 的那一刻,而是稍后某个 Binder 事务时报出来,日志里只有一句 TransactionTooLargeException,栈顶指向一个毫不相干的位置,极难排查。
正确姿势分两层。第一层是别存大对象:
class DetailActivity : AppCompatActivity() {
// 存 ID,不存数据
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(KEY_ARTICLE_ID, articleId)
outState.putInt(KEY_SCROLL_OFFSET, list.computeVerticalScrollOffset())
}
private lateinit var articleId: String
private var list: RecyclerView? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_detail)
// 冷启动走 Intent,热重建走 Bundle,两条路径都要覆盖
articleId = savedInstanceState?.getString(KEY_ARTICLE_ID)
?: intent.getStringExtra(EXTRA_ARTICLE_ID)
?: ""
list = findViewById(R.id.list)
// 还原滚动位置要在布局完成后做
list?.post {
savedInstanceState?.getInt(KEY_SCROLL_OFFSET)?.let {
list?.scrollToPosition(it) }
}
}
companion object {
private const val KEY_ARTICLE_ID = "article_id"
private const val KEY_SCROLL_OFFSET = "scroll_offset"
}
}
第二层是大对象真的必须传时的三条旁路。① 让接收方自己按 ID 从缓存或磁盘读,只在 Bundle 里放路径或 key;② 用 onRetainCustomNonConfigurationInstance 承接大对象(配置变更不杀进程,进程被杀时它自然失效,正好符合"临时大对象"的需求);③ 进程被杀后重建的场景,回落到磁盘缓存——承认大数据不该靠 Bundle 活下来。
class PlayerActivity : AppCompatActivity() {
private var trackBitmap: Bitmap? = null
override fun onRetainCustomNonConfigurationInstance(): Any? = trackBitmap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 配置变更时拿到重建前留下的引用
trackBitmap = lastCustomNonConfigurationInstance as? Bitmap
}
}
还有个更隐蔽的坑:给状态保存与恢复建一份边界清单,注释里写清楚,单测覆盖边界与异常路径。具体做法是列一张表:状态项、类型、大小估算、存不存、存哪、恢复时机。一张表放在 onSaveInstanceState 旁边,半年后接手的人也能照着改。配套的单测用 Robolectric 或 instrumentation 模拟重建,断言关键状态被还原。
三条高频追问
问:为什么 onSaveInstanceState 里不适合放序列化后的列表?
答三个原因。① 事务上限,序列化的 ArrayList 通常几 KB 起,几百条就可能撞上 1MB;② 主线程 I/O,Bundle 的序列化在提交时发生,量大会掉帧;③ 生命周期错配,进程被杀后 savedInstanceState 可能为 null,而数据本来可以从网络重新拿到,等于付了两次成本。规范的做法是列表数据放 ViewModel 或 Repository,onSaveInstanceState 只放"回到同一位置"的最小描述(ID、页码、筛选条件)。
问:旋转屏幕重建后 ViewModel 为什么还在?
因为 ViewModelStore 挂在 NonConfigurationInstances 上,配置变更时由 ActivityThread 保留并重新分发,所以 ViewModel 跨配置变更存活;进程真被杀了 ViewModelStore 会清空,数据回到 SavedStateHandle 或磁盘。分界线就在这里——配置变更活、进程死亡不活。
问:SavedStateHandle 和 Bundle 什么关系?
ViewModel 的构造函数可以带 SavedStateHandle,它本质是给 ViewModel 用的、带类型安全 API 的 Bundle 包装,能存 Parcelable、序列化的 ArrayList 等。它解决的是两个痛点:往 Bundle 取值要手写 key 与强制转换,以及以前要自定义 ViewModelFactory 才能带参数——现在用 CreationExtras 里的 SAVED_STATE_REGISTRY_OWNER_KEY 就能拿到。写法是 savedStateHandle.get<String>("key"),配合 LiveData/StateFlow 还能自动响应变化。
落到项目里怎么做
一条能直接抄的规则:Bundle 里只放"重建后廉价可重建、但丢失会明显掉体验"的状态。具体分三档——ID/索引/开关这类几十字节的,放 Bundle;已加载的数据这类可重新拉取的,放 ViewModel;大对象与不可重建的数据,放磁盘或缓存并只传指针。
配套的三条工程实践:
- 状态清单文档化:
onSaveInstanceState旁维护一张表,新增状态时必须填表,评审时对照检查。 - 两条恢复路径都测:冷启动(
savedInstanceState == null)与热重建(模拟配置变更)各写一个用例,参数只取一处(savedInstanceState ?: intent)能天然避免这类 bug。 - 大数据一律走指针:跨 Activity 传大对象优先传 ID,让接收方自己取;这既是 1MB 限制的正解,也是架构上更干净的做法。
给正在准备面试的你
这道题的区分度不在"Bundle 能不能存状态",而在三件事说清没有:① 1MB 事务上限的成因(Binder 事务缓冲区),以及大对象的正确旁路;② 三种状态层次(视图/界面/业务)的划分判据——不可廉价重建才存;③ onSaveInstanceState 不保证被调用,所以业务关键状态不能只靠它。把这三层讲成一条链——"系统只保证给你一个小窗口,你要在里面只放该放的"——答案的层次就出来了。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Activity-启动模式:standard-到-singleInstance
下一篇预告:Fragment-生命周期:与-Activity-的联动陷阱
有任何问题欢迎在评论区留言交流。