第13周:页面状态保存 + 数据恢复优化

做第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:idandroid:saveEnabledView.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/getIntisFinishing
  • 系统机制: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 / 类:ViewModelby viewModels()(activity-ktx)、ViewModelStoreViewModelProvider
  • 系统机制:NonConfigurationInstances 在配置变化时传递 ViewModelStore
  • Jetpack / 三方库:lifecycle-viewmodel-ktx:2.10.0activity-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 / 类:SavedStateHandleSavedStateRegistrySavedStateRegistryOwnerstate.getLiveData()
  • 系统机制:数据在宿主 Activity stopped 时统一落进系统保存流程;序列化副本存于系统进程侧
  • Jetpack / 三方库:lifecycle-viewmodel-ktx(SavedStateHandle 随 ViewModel 提供)
  • 常见坑:以为要手写 ViewModel Factory 才能注入 SavedStateHandle;往里塞大对象(和 Bundle 同限制);以为写入立即持久(实际 onStop 才落)

六、轻量化保存:存 ID,不存数据

前面几节反复出现一个约束:Bundle 系列方案(onSaveInstanceStateSavedStateHandle)只适合小数据。这节把它量化。

为什么必须小?两个原因,官方文档都写了:

  1. 序列化在主线程执行。配置变化本身就要重建整个页面,这期间主线程再多干一段序列化的活,直接表现为掉帧、卡顿。
  2. 占用系统进程内存。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,而是搞清楚每种方案死在哪个场景

几个真正值得记的结论:

  1. View 自动保存的前提是"唯一 android:id"。动态 addView 的输入控件忘了 setId(),状态会静默丢失------没有崩溃、没有日志,只有用户投诉。
  2. onSaveInstanceState 在用户主动退出时不触发。如果你的需求是"退出再进也要恢复"(比如草稿箱),那答案从来都不是 Bundle,而是持久化存储。
  3. ViewModel 的价值不是"保存数据",而是"配置变化时不需要保存"------免序列化、免大小限制。它活不过进程死亡是设计边界,不是缺陷。
  4. SavedStateHandle 让 ViewModel 架构有了扛进程死亡的官方通道,但它只该存"重建线索"(搜索词、ID),存数据和 onSaveInstanceState 犯一样的错。
  5. 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 单次独占;崩溃后才排查
"不保留活动" 开发者选项,模拟进程死亡的测试手段 区域③④ 的验证方法 低配机进程回收场景的低成本复现 与真实低内存杀进程行为高度接近但不完全等同
相关推荐
万事可爱^1 小时前
Claude 新发布的 Opus 5,系统提示语删了 80%,半价还能逼近 Fable 5
android·服务器·数据库·人工智能·claude
alexhilton2 小时前
响应式的Android身份验证架构
android·kotlin·android jetpack
喵都学不动了3 小时前
Android 自动化测试完全指南(新人版)
android
码云骑士3 小时前
76-全量微调vs-LoRA-vs-QLoRA-三种微调方式对比与选型
android
光头闪亮亮3 小时前
Fyne ( go跨平台GUI )项目实战-WebView 组件开发技术详解
android·go
嵌入式小周4 小时前
Genymotion 安卓模拟器在 Intel 芯片 Mac 上的运行(附带下载方式)
android·macos
zzq77976 小时前
Android 16 API 36 升级后 APP 加固兼容性问题解析
android·开发语言·安全·kotlin·安卓·安全架构
2601_961391467 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能
2501_9159184116 小时前
深入对比iOS开发中常用性能监控工具的底层原理与优缺点分析
android·ios·小程序·https·uni-app·iphone·webview