做第13周之前,我一直觉得"旋转屏幕丢数据"是件小事------Demo 里丢了就丢了,重新输入一遍而已。
直到我开始认真想几个场景:用户在填写一个长表单,填到第八个字段时手机转了一下;用户在搜索页输入了关键词、滑到了第三屏,切出去回了个微信,回来时 App 进程被系统回收了。这两种情况下,页面会重建。重建之后,用户之前填的东西还在不在?
如果不在,用户不会觉得"这是 Android 的机制问题",他只会觉得这个 App 很难用。
这一周我把状态保存的几条路径都跑了一遍:系统免费的 View 自动保存、手动的 onSaveInstanceState、配置变化下存活的 ViewModel、进程死亡后还能恢复的 SavedStateHandle,最后还用 Parcel 实测了 Bundle 序列化的体积,搞清楚"轻量化保存"到底省的是什么。
一、先搞清楚:状态到底什么时候会丢
状态丢失不是一种情况,而是三种完全不同的场景,对应的解决方案也不一样:
| 场景 | 系统行为 | 典型触发 |
|---|---|---|
| 配置变化 | Activity 销毁并重建,进程还在 | 旋转屏幕、切深色模式、分屏、改字体大小 |
| 进程死亡 | 整个进程被系统回收,回来时 Activity 重建 | 切后台后内存不足被回收 |
| 用户主动退出 | Activity 正常结束 | 按返回键、调 finish() |
关键细节在第三行:用户主动退出时,系统不会帮你保存任何状态 。这是官方文档明确写的------onSaveInstanceState 在用户显式关闭 Activity 或调用 finish() 时不会被调用。逻辑也合理:用户都主动离开了,恢复"上次填到一半的内容"反而奇怪。
所以状态保存要解决的,是前两种"非用户本意"的重建。
这三种场景对应到方案选择上,官方文档给出的对比非常清楚:
| 维度 | ViewModel | Saved State(Bundle / SavedStateHandle) | 持久化存储 |
|---|---|---|---|
| 配置变化后存活 | 是 | 是 | 是 |
| 进程死亡后存活 | 否 | 是 | 是 |
| 用户完全退出后存活 | 否 | 否 | 是 |
| 数据限制 | 受可用内存限制,可放复杂对象 | 仅限基本类型和小对象 | 仅受磁盘限制 |
| 读写速度 | 快(纯内存) | 慢(需序列化/反序列化) | 最慢(磁盘 IO) |
这张表就是第13周全部内容的骨架。后面每一节都是在把其中一行展开。
二、View 自动保存:系统的免费能力
先说一个很多人没意识到的事实:你什么都不写,EditText 里的文字旋转后也不会丢。
系统默认会把布局里每个 View 的状态存进 instance state Bundle------EditText 的文本、CheckBox 的勾选、SeekBar 的进度、ScrollView 的滚动位置,都在其中。
但有一个硬性前提,官方文档写得很明确:
每个 View 必须有唯一 ID(
android:id),系统才能恢复它的状态。
Demo 里区域①就是这个验证:
ini
<EditText
android:id="@+id/et_auto_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="输入文字,然后旋转屏幕" />
<CheckBox
android:id="@+id/cb_auto_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="勾选后旋转屏幕,状态自动恢复" />
这段 XML 没有任何 Kotlin 代码配合,但输入文字、勾选 CheckBox 之后旋转屏幕,两样都还在。
少了 android:id 会怎样?系统在 Bundle 里保存 View 状态时,是按 View 的 id 做 key 的。没有 id,就没有 key,这个 View 的状态直接不进 Bundle。实际项目里动态 addView 添加的输入控件最容易踩这个坑------代码里 new 出来的 EditText 如果忘了 setId()(或 View.generateViewId()),旋转后用户输入就没了,而且测试时很难发现,因为大多数人测试不转手机。
反过来,如果一个 View 你明确不希望系统保存(比如密码输入框),可以显式关闭:
ini
<EditText
android:id="@+id/et_password"
android:saveEnabled="false"
... />
大厂实践映射
- 参考方向:内容社区、电商 App 的发布页和搜索页
- 解决问题:发布页通常有标题、正文、标签等多个输入字段,全部手写保存是重复劳动;利用系统自动保存可以覆盖掉大部分纯 UI 状态,手写代码只补业务变量
相关技术清单
- API / 类:
android:id、android:saveEnabled、View.generateViewId()、View.onSaveInstanceState()(View 层面的同名方法) - 系统机制:View 层级状态按 id 为 key 存入 instance state Bundle
- 常见坑:动态创建的 View 忘记设置 id,状态静默丢失;密码框忘了关
saveEnabled,明文进 Bundle
三、onSaveInstanceState:成员变量的救生艇
View 自动保存管不了成员变量。Demo 区域② 我放了两个计数器做对比:一个是普通成员变量,一个在 onSaveInstanceState 里写入 Bundle。
kotlin
private var notSavedCounter = 0 // 普通成员变量
private var savedCounter = 0 // 会被写进 Bundle 的变量
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_SAVED_COUNTER, savedCounter)
}
旋转屏幕后:Activity 是全新实例,notSavedCounter 归零;savedCounter 从 Bundle 里读回来,数值保留。界面上红色和绿色两行数字对比非常直观。
这段代码里有三个必须说的点:
第一,super.onSaveInstanceState(outState) 不能少。 View 层级的自动保存就是在父类实现里做的。你不调 super,上一节说的 EditText 自动恢复就没了------手动保存和自动保存走的是同一个 Bundle。
第二,恢复有两条路径,语义不同。
kotlin
// 路径一:onCreate 里读,必须判 null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
savedCounter = savedInstanceState.getInt(KEY_SAVED_COUNTER, 0)
}
}
// 路径二:onRestoreInstanceState,系统在 onStart 后调用,无需判 null
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
// 只有存在可恢复状态时系统才会调这个方法
}
判不判 null 的区别是:全新实例的 onCreate 也会被执行,那时 Bundle 就是 null;而 onRestoreInstanceState 只在"确实有状态可恢复"时才被调用。Demo 里我把两条路径都打了日志,旋转一次就能在界面上看到它们的执行顺序:onCreate(路径一)→ onStart → onRestoreInstanceState(路径二)。
实际项目里我建议统一放 onCreate 恢复,理由是初始化逻辑都集中在一个方法里,不用在两个回调之间维护"哪些状态在哪恢复"的心智负担。onRestoreInstanceState 更适合需要等 View 层级恢复完再执行的场景。
第三,它什么时候被调用,业务代码不应该依赖。 官方文档的表述是"activity 开始停止时"。至于它和 onStop 的精确先后,
还有一个反向知识点值得记:用户按返回键或调 finish() 时,onSaveInstanceState 不会被调用。Demo 里按返回退出再进,计数器归零是正确行为,不是 bug。
大厂实践映射
- 参考方向:表单填写页、下单确认页、滤镜/编辑参数页
- 解决问题:多步表单的中间态(当前第几步、已选项 ID 集合)必须扛住配置变化;编辑类页面的参数(裁剪框位置、滤镜强度)丢失等于用户白操作
- 取舍判断:
onSaveInstanceState的写入和读取都要序列化,且发生在主线程。它适合"几个 Int/String/小对象",不适合"整个页面的数据模型"------后者是第六节的反面教材
相关技术清单
- API / 类:
onSaveInstanceState(Bundle)、onRestoreInstanceState(Bundle)、Bundle.putInt/getInt、isFinishing - 系统机制:instance state Bundle 由系统进程侧持有,Activity 重建时交还
- 常见坑:忘记调
super导致 View 自动保存失效;在onSaveInstanceState里做 IO;指望finish()后还能恢复状态
四、ViewModel:配置变化场景的最优解
onSaveInstanceState 有两个天生的短板:数据必须可序列化、序列化在主线程。如果页面状态里有一个几百 KB 的对象(比如网络请求回来的完整数据列表),放进 Bundle 是给自己挖坑。
ViewModel 解决的就是这个:它活不过进程死亡,但在配置变化时存活,而且不需要序列化。
kotlin
class Week13StateViewModel(
private val state: SavedStateHandle // 第五节再讲它
) : ViewModel() {
/** 纯内存状态:旋转存活,进程死亡丢失 */
var memoryCounter: Int = 0
fun increaseMemoryCounter() {
memoryCounter++
}
}
Activity 里用 by viewModels() 获取:
csharp
private val viewModel: Week13StateViewModel by viewModels()
// 界面上直接显示实例 hash,旋转后对比
binding.tvVmInstance.text =
"ViewModel 实例:${System.identityHashCode(viewModel)}(旋转后不变 = 存活)"
by viewModels() 是 activity-ktx 提供的委托,背后做的是:向当前 Activity 的 ViewModelStore 要这个类的实例,有就直接返回,没有才创建。配置变化时 Activity 重建,但 ViewModelStore 被系统保留下来(通过 NonConfigurationInstances 机制传递),所以新 Activity 拿到的是同一个 ViewModel------界面上 hashCode 不变,计数继续累加。
少了 ViewModel 会怎样?没有它,配置变化后想保住内存数据,要么全塞进 Bundle(序列化开销 + 大小限制),要么用静态变量(内存泄漏风险 + 进程死亡时行为不可控)。ViewModel 是官方给的正解。
但边界必须说清楚:ViewModel 活不过进程死亡。Demo 里开启"不保留活动"后按 Home 再回来,界面上 hashCode 变了,计数归零------因为整个进程都是新的,ViewModelStore 自然也不存在。
所以 ViewModel 的正确定位是:配置变化场景下,放那些"重建成本高但丢了也能重新加载"的数据(网络缓存、列表数据、临时计算结果),而不是万能的保存方案。
大厂实践映射
- 参考方向:信息流列表页、搜索结果页(成熟团队 MVVM 架构的标配)
- 解决问题:列表页旋转后要重新请求网络吗?不应该------数据放 ViewModel,旋转后直接复用,零网络开销零等待。这个体验差距用户能直接感知
- 取舍判断:ViewModel 省掉了序列化,但这也意味着它不参与系统保存流程。进程死亡后数据模型丢失是设计使然,补救方案是第五节的 SavedStateHandle 存"恢复线索"(比如搜索词、ID),重建后按线索重新加载
相关技术清单
- API / 类:
ViewModel、by viewModels()(activity-ktx)、ViewModelStore、ViewModelProvider - 系统机制:
NonConfigurationInstances在配置变化时传递 ViewModelStore - Jetpack / 三方库:
lifecycle-viewmodel-ktx:2.10.0、activity-ktx:1.10.1 - 常见坑:ViewModel 里持有 Activity/Context 引用导致泄漏;以为 ViewModel 能扛进程死亡;在 ViewModel 里直接操作 View
五、SavedStateHandle:进程死亡也能恢复的组合拳
第四节的结论留了个口子:ViewModel 里的数据进程死亡后全丢。但搜索页的真实需求是------进程被杀后回来,搜索词应该在,哪怕列表要重新加载。
SavedStateHandle 就是补这个口子的。它是 ViewModel 构造参数里的一个类 Map 结构,写入它的数据会被系统以序列化副本的形式保存在系统进程侧,进程死亡重建后依然可取。
kotlin
class Week13StateViewModel(
private val state: SavedStateHandle
) : ViewModel() {
companion object {
private const val KEY_SEARCH_QUERY = "week13_search_query"
}
/** SavedStateHandle 状态:旋转 + 进程死亡都存活 */
val savedQuery: MutableLiveData<String> = state.getLiveData(KEY_SEARCH_QUERY, "")
fun updateQuery(query: String) {
state[KEY_SEARCH_QUERY] = query
}
}
构造函数只声明 SavedStateHandle 一个参数,ComponentActivity 默认的 SavedStateViewModelFactory 会自动注入,不需要手写 Factory------这是新手最容易卡住的点,很多人以为必须自己写 Factory 才能用 SavedStateHandle。
Activity 侧:
arduino
viewModel.savedQuery.observe(this) { query ->
binding.tvSavedQuery.text = "SavedStateHandle 中的搜索词:"$query""
}
binding.btnSaveQuery.setOnClickListener {
viewModel.updateQuery(binding.etSearchQuery.text?.toString().orEmpty())
}
测试方式:输入搜索词并写入 → 开发者选项打开"不保留活动" → 按 Home → 从最近任务回来。此时界面上能看到两件事同时发生:ViewModel 的 hashCode 变了(新实例,第四节的内容成立),但搜索词还在(SavedStateHandle 生效)。
有一个机制层面的细节官方文档写得很清楚,而且直接影响使用方式:写入 SavedStateHandle 的数据,只在宿主 Activity 进入 stopped 状态时才真正交给系统保存 。也就是说你 state[key] = value 之后,如果 Activity 一直停在 Resumed 状态然后进程突然被杀(比如被强杀),最近写入的值可能没落进系统保存流程。正常使用场景(按 Home、切任务)都会经过 onStop,所以不用恐慌,但要知道它不是"写入即持久"。
底层的机制是 SavedStateRegistry:ComponentActivity 实现了 SavedStateRegistryOwner,各组件(包括 ViewModel 的 SavedStateHandle)向它注册 SavedStateProvider,系统在保存状态时统一回调 saveState() 收集成 Bundle。SavedStateHandle 本质上是这套机制给 ViewModel 开的官方通道。
实践
- 参考方向:搜索页、筛选页、多 Tab 页面(当前选中 Tab)
- 解决问题:搜索词、筛选条件、选中 Tab 是"重建页面的最小线索"。进程死亡后,只要有这些线索,就能重新请求数据把页面恢复到用户离开时的样子;没有这些线索,页面只能回到初始态
- 取舍判断:SavedStateHandle 和
onSaveInstanceState一样受 Bundle 限制------只放基本类型和小对象,同样要主线程序列化。它的价值不是"存更多",而是"让 ViewModel 架构下的状态保存有官方通道",避免为了保存状态又把数据倒腾回 Activity
相关技术清单
- API / 类:
SavedStateHandle、SavedStateRegistry、SavedStateRegistryOwner、state.getLiveData() - 系统机制:数据在宿主 Activity stopped 时统一落进系统保存流程;序列化副本存于系统进程侧
- Jetpack / 三方库:
lifecycle-viewmodel-ktx(SavedStateHandle 随 ViewModel 提供) - 常见坑:以为要手写 ViewModel Factory 才能注入 SavedStateHandle;往里塞大对象(和 Bundle 同限制);以为写入立即持久(实际 onStop 才落)
六、轻量化保存:存 ID,不存数据
前面几节反复出现一个约束:Bundle 系列方案(onSaveInstanceState、SavedStateHandle)只适合小数据。这节把它量化。
为什么必须小?两个原因,官方文档都写了:
- 序列化在主线程执行。配置变化本身就要重建整个页面,这期间主线程再多干一段序列化的活,直接表现为掉帧、卡顿。
- 占用系统进程内存。Bundle 的序列化副本是放在系统进程侧的,你写得越多,系统进程替你背的内存越多。
更硬的限制在 Binder:跨进程传输走 Binder 事务,每个进程的事务缓冲区是共享的约 1MB(注意是"共享",不是单次独占------并发事务多时实际可用更少)。onSaveInstanceState 的 Bundle 要跨进程交给系统服务保存,塞大了直接抛 TransactionTooLargeException,而且是崩溃级的。
Demo 区域⑤ 用 Parcel 实测了两种写法的体积差:
kotlin
private fun measureBundleSize(bundle: Bundle): Int {
val parcel = Parcel.obtain()
return try {
parcel.writeBundle(bundle)
parcel.dataSize()
} finally {
parcel.recycle()
}
}
// 写法一(正确):只存 ID 和滚动位置
val small = Bundle().apply {
putInt("selected_product_id", 42)
putInt("scroll_position", 1280)
}
// 实测:约 100+ 字节
// 写法二(反面):存 500 条模拟商品文本
val big = Bundle().apply {
putStringArrayList("product_list", fakeList) // 500 条长文本
}
// 实测:约 70+ KB
Parcel 就是 Bundle 跨进程前的实际序列化格式,所以 parcel.dataSize() 量的就是系统真实要付的成本。
500 条短文本已经 70+ KB。真实列表里是带图片 URL、多字段的数据对象,几百条破 1MB 并不困难。正确姿势是只存恢复所需的最小线索:
vbscript
// 进程死亡后:按 ID 重新加载,而不是恢复整个列表
outState.putInt("selected_product_id", currentSelectedId)
outState.putInt("scroll_position", scrollY)
重建后用 ID 从缓存(ViewModel / 内存缓存 / Room)或网络把数据重新拿出来。这就是官方文档反复强调的原则:saved state 里只放"重建数据所需的最少信息量"。
大厂实践映射
- 参考方向:电商商详页、外卖点单页、内容详情页
- 解决问题:商详页的"当前选中 SKU id + 规格面板展开状态 + 滚动位置"才是需要保存的,整个商品模型对象必须留在缓存层;把模型塞进 Bundle 的 App 在低配机上旋转屏幕时会出现可感知的卡顿,极端情况直接 TransactionTooLargeException 崩溃
- 取舍判断:"存 ID 重建时重新加载"的代价是重建后可能有一次加载等待,所以需要配合缓存策略:ViewModel 还在就直接用,不在就查内存/磁盘缓存,最后才走网络。这是状态保存和缓存策略的配合问题,不是二选一
相关技术清单
- API / 类:
Parcel.obtain()、parcel.writeBundle()、parcel.dataSize()、TransactionTooLargeException - 系统机制:Binder 约 1MB 共享事务缓冲区;Bundle 序列化副本存于系统进程侧;序列化在主线程执行
- 性能工具:Parcel 实测体积、Logcat 观察 TransactionTooLargeException
- 常见坑:把 Bitmap、整个数据列表、大 JSON 字符串放进 Bundle;误以为 1MB 是单次事务独占额度(实际是进程共享)
七、方案选型:一张表收口
| 要保存的东西 | 方案 | 理由 |
|---|---|---|
| View 自身状态(文本、勾选、滚动位置) | 系统 View 自动保存 | 有 id 就免费,别手写 |
| 几个基本类型成员变量 | onSaveInstanceState |
官方通道,量小直接放 |
| 网络数据、列表、复杂对象(配置变化场景) | ViewModel | 免序列化,旋转零成本 |
| 重建页面的最小线索(搜索词、ID、选中 Tab) | SavedStateHandle | ViewModel 架构下扛进程死亡 |
| 必须长期保留的数据(草稿、用户资料) | Room / DataStore / 文件 | 前四种都不保证用户主动退出后存活 |
真实项目里不是选一个,而是组合用:View 状态交给系统,页面数据放 ViewModel,恢复线索放 SavedStateHandle,草稿落磁盘。
八、这一周真正学到了什么
状态保存的核心不是背 API,而是搞清楚每种方案死在哪个场景。
几个真正值得记的结论:
- View 自动保存的前提是"唯一 android:id"。动态 addView 的输入控件忘了
setId(),状态会静默丢失------没有崩溃、没有日志,只有用户投诉。 onSaveInstanceState在用户主动退出时不触发。如果你的需求是"退出再进也要恢复"(比如草稿箱),那答案从来都不是 Bundle,而是持久化存储。- ViewModel 的价值不是"保存数据",而是"配置变化时不需要保存"------免序列化、免大小限制。它活不过进程死亡是设计边界,不是缺陷。
- SavedStateHandle 让 ViewModel 架构有了扛进程死亡的官方通道,但它只该存"重建线索"(搜索词、ID),存数据和 onSaveInstanceState 犯一样的错。
- Bundle 的体积就是帧率,就是稳定性。主线程序列化 + 系统进程内存 + Binder 约 1MB 共享缓冲区,三个约束指向同一个结论:存 ID,不存数据。
技术清零表
| 技术 | 它是什么 | Demo 落点 | 真实项目价值 | 常见坑 |
|---|---|---|---|---|
| View 自动保存 | 系统按 android:id 自动保存 View 状态进 Bundle |
区域① et_auto_save / cb_auto_save 零代码恢复 |
表单页纯 UI 状态全覆盖,省掉手写保存 | 动态创建的 View 忘设 id;密码框忘关 saveEnabled |
onSaveInstanceState() |
Activity 停止前把状态装箱进 Bundle 的回调 | 区域② 已保存计数器旋转后恢复 | 少量成员变量扛配置变化和进程死亡 | 忘调 super 丢失 View 自动保存;在里面做 IO |
onRestoreInstanceState() |
onStart 后的恢复回调,有状态才触发 | 区域② 日志区展示路径二执行时机 | 需等 View 恢复完再执行的场景 | 与 onCreate 恢复路径混用导致状态覆盖 |
onCreate Bundle 判 null |
恢复路径一:onCreate 里读 Bundle | 区域② 日志区展示"Bundle 非空/为 null" | 初始化逻辑集中在一处 | 不判 null 直接读,新实例 NPE 风险 |
| ViewModel | 配置变化存活的内存数据持有者 | 区域③ hashCode 旋转后不变 | 列表/网络数据旋转零成本复用 | 持有 Activity 引用泄漏;以为能扛进程死亡 |
by viewModels() |
activity-ktx 提供的 ViewModel 委托 | 区域③ Activity 中获取 ViewModel | 一行代码接入 ViewModelStore | 不用委托手动 new,每次得到新实例 |
| SavedStateHandle | ViewModel 内进程死亡可恢复的类 Map 存储 | 区域④ 搜索词"不保留活动"后仍在 | ViewModel 架构下保存重建线索 | 以为要手写 Factory;塞大对象;以为写入即持久 |
| SavedStateRegistry | 各组件注册保存回调、系统统一收集的机制 | 文章机制说明(未单独做 Demo) | 自定义组件也可接入系统保存流程 | Activity stopped 时才统一落盘,Resume 状态强杀会丢最近写入 |
isFinishing |
区分"正常结束"和"系统重建"的标志 | Activity onDestroy 日志 |
决定 onDestroy 里要不要清理资源 | 误判导致该清理的没清理 |
| Parcel 体积测量 | 用 Parcel.writeBundle + dataSize 实测 Bundle 序列化大小 |
区域⑤ 两个测量按钮 | 上线前量化保存成本的手段 | 凭感觉估算,直到 TransactionTooLargeException 才发现 |
| TransactionTooLargeException | Binder 事务超约 1MB 共享缓冲区抛出的崩溃 | 区域⑤ 反面示范文案 | 理解为什么列表/Bitmap 不能进 Bundle | 以为 1MB 单次独占;崩溃后才排查 |
| "不保留活动" | 开发者选项,模拟进程死亡的测试手段 | 区域③④ 的验证方法 | 低配机进程回收场景的低成本复现 | 与真实低内存杀进程行为高度接近但不完全等同 |