
干安卓开发久了你肯定会碰到一个场景用户在页面上填了好长的表单接了个电话切回去再从最近任务里点回应用结果页面空白一片刚才填的东西全没了。也有人在横竖屏切换的时候发现 ViewModel 倒是还在可一旦进程被系统回收ViewModel 也跟着没了界面上只剩 onSaveInstanceState 里那点可怜的数据。SavedStateHandle 就是专门来收拾这种局面的。它属于 AndroidX 里的 SavedState 模块和 ViewModel 配合是处理“界面状态在 Activity 重建、进程被杀死之后还能恢复”的官方推荐方案。这篇我会从原理讲到实战再列一些我踩过的坑适合正在学 ViewModel、被状态恢复折磨过的中初级安卓开发者。1. 为什么会有 SavedStateHandle先搞懂安卓里的数据丢失1.1 一次屏幕旋转引发的“数据消失”Android 的应用界面是跟着 Activity 走的而 Activity 的生命周期完全受系统控制。你在手机上把屏幕从竖屏转成横屏系统会认为当前的资源配置已经完全变了于是它做了一件很“粗暴”的事把当前 Activity 销毁再重新创建一个新的 Activity。这就是所谓的配置变更也是很多新手第一次遇到“数据莫名其妙就没了”的根源。比如你写了一个计数器页面在 Activity 的成员变量里存了一个 countint count 0; button.setOnClickListener(v - { count; textView.setText(当前数值 count); });屏幕一转Activity 重建成员变量 count 被重置为 0。这就是典型的状态丢失。要解决这个问题最基础的做法是重写 onSaveInstanceStateOverride protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(count, count); } Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (savedInstanceState ! null) { count savedInstanceState.getInt(count, 0); } }这套机制能解决配置变更时的状态恢复问题但代码写起来很啰嗦而且必须是 Activity 或 Fragment 自己来处理。如果页面逻辑复杂onSaveInstanceState 里的 key 会越来越多最后变成一个谁都不愿意动的杂货铺。1.2 ViewModel 的边界能跨配置变更却怕进程被杀后来 Google 推出了 ViewModel它的核心意义就是解决配置变更时的数据保留问题。ViewModel 的生命周期和 Activity 的 onDestroy 不对等它会在配置变更时被保留下来不会因为屏幕旋转而销毁。我经常用一个比喻Activity 是演员ViewModel 是后台的工作人员。演员换了工作人员还在所以数据的“即时状态”还留在工作人员手里。计数器改成 ViewModel 之后屏幕怎么转count 都不会丢。但 ViewModel 有一个天然的边界——它只存在于 App 进程的内存中。一旦进程被系统杀了比如用户很久没打开应用系统内存吃紧系统把后台进程回收掉或者用户在多任务界面手动把应用滑掉了那么 ViewModel 里的所有数据也会跟着进程一起消失。这是很多人没有意识到的问题ViewModel 只能管住“配置变更”管不住“进程死亡”。系统在进程被杀死之前会给 Activity 一次机会让 Activity 通过 onSaveInstanceState 保存一份数据这份数据会被系统临时保存下来等用户重新回到页面时再交给新的 Activity。可问题来了页面是 Activity 重建了但如果你把状态都放在 ViewModel 里而 ViewModel 的 init 代码又不去恢复这些数据那页面还是空的。1.3 SavedStateHandle 到底解决了什么痛点SavedStateHandle 是从 androidx.lifecycle 2.2.0 开始引入的一个类它本质上是一个“把状态保存交给系统生命周期管理”的容器。你可以把它塞进 ViewModel 的构造参数里ViewModel 里那些需要跟随页面生命周期一起保存的状态通过 SavedStateHandle 来读写。就算整个 App 进程被系统回收了SavedStateHandle 持有的数据也会通过 Activity 的 onSaveInstanceState 机制保存下来等 Activity 重建之后再自动恢复到 ViewModel 里。换句话说SavedStateHandle 把两种方案的优点结合到了一起你拥有 ViewModel 那种“不用自己写序列化逻辑、直接在内存里读写”的便利性同时又拥有 onSaveInstanceState 那种“进程死了还能救回来”的可靠性。对开发者来说你只是多写了一个构造参数其余的恢复逻辑基本看不出来甚至不需要关心。2. 环境准备与最小实现把 SavedStateHandle 跑起来2.1 添加依赖一个依赖搞定在项目开始之前先确认一下依赖。SavedStateHandle 虽然名字听起来很独立但实际使用时通常和 ViewModel 绑定所以你需要的是 lifecycle 的 viewmodel-savedstate 依赖。implementation androidx.lifecycle:lifecycle-viewmodel-savedstate:2.5.1如果你的项目里已经使用了lifecycle-viewmodel-ktx:2.5.1或者androidx.activity:activity-ktx:1.6.0这类依赖通常会自动把 viewmodel-savedstate 带进来。但为了代码可读性和版本可控我建议显式声明。注意一定要保证 lifecycle 版本一致别出现 2.4.0 的 ViewModel 配上 2.5.1 的 SavedStateHandle容易出一些奇奇怪怪的方法找不到问题。2.2 改造 ViewModel用 SavedStateHandle 做计数器为了快速理解它怎么用还是用计数器来做例子。这次不是把 count 做成普通变量而是放在 SavedStateHandle 里。class CounterViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val countKey count val count savedStateHandle.getLiveData(countKey, 0) fun addCount() { savedStateHandle[countKey] (savedStateHandle[countKey] ?: 0) 1 } }Activity 里获取 ViewModel 的方式和之前一样class MainActivity : AppCompatActivity() { private val viewModel: CounterViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) viewModel.count.observe(this) { number - findViewByIdTextView(R.id.tvCount).text 当前数值$number } findViewByIdButton(R.id.btnAdd).setOnClickListener { viewModel.addCount() } } }看到没有Activity 的代码几乎没有变化只是 ViewModel 的构造函数多了一个参数。屏幕旋转后count 不会归零把这个 App 切到后台等系统把进程杀了再回来只要 Activity 没有被用户手动滑掉count 依然还在。这里有个细节需要注意SavedStateHandle 的读取用savedStateHandle[key]或者savedStateHandle.get(key)写入用savedStateHandle[key] value或者savedStateHandle.set(key, value)。如果你想拿到的数据能响应式地更新 UI可以直接用getLiveData(key, defaultValue)这样就不用自己写 MutableLiveData 再手动同步了省掉很多样板代码。2.3 SavedStateHandle 是怎么钻进 ViewModel 的依赖注入原理很多人第一次看到 SavedStateHandle 时会疑惑这个构造参数我明明没传它是从哪来的这其实是 AndroidX 的 SavedStateViewModelFactory 在帮你做依赖注入。它专门负责创建带有 SavedStateHandle 参数的 ViewModel。整个过程大概是这样的ViewModelProvider 初始化的时候如果检测到 ViewModel 的构造方法里有 SavedStateHandle 参数就会通过 SavedStateController 从 Activity 或 Fragment 的 SavedStateRegistry 里拿到一个已经恢复好的 Bundle然后把它包装成 SavedStateHandle 对象再反射调用 ViewModel 的构造函数。所以你的 ViewModel 才天然能拿到那一份“死而复生”的状态。这也能解释一个常见坑如果你在代码里手动 new 一个 ViewModelval vm CounterViewModel(SavedStateHandle())那么这个 ViewModel 里的状态永远不会和 Activity 的保存机制绑定配置变更后数据会丢。正确做法永远是使用 ViewModelProvider 或者 by viewModels() 委托来创建。SavedStateHandle 的意义恰恰在于“交给系统管理”你手动 new 就等于脱离了系统管理那就失去这个模块存在的价值了。3. 深入 APISavedStateHandle 能存什么、不能存什么3.1 支持的存储类型清单SavedStateHandle 虽然用起来像个 Map但它并不是一个无限包容的容器。因为它底层最终要走 Bundle 传递和存储所以它的数据类型上限和 Bundle 是一致的。我在实际项目中整理过一份清单大致如下类别支持的类型基本类型Boolean、Byte、Char、Double、Float、Int、Long、Short 以及它们对应的数组字符串String、CharSequence 以及对应的数组集合ArrayList 、ArrayList 、Bundle、SparseParcelableArray 等序列化对象所有实现 Parcelable 接口的对象、Serializable 对象系统封装Bundle、Size / SizeFAPI 21 以上换句话说如果你要保存一个普通数据类没问题但必须让它实现 Parcelable。如果你要保存一张 Bitmap虽然 Bitmap 本身就是 Parcelable但我不建议直接往 SavedStateHandle 里塞因为源码里动不动就可能触发 TransactionTooLargeException后面我会单独讲。3.2 常用方法 get / set / containsSavedStateHandle 提供的方法非常直观常用到的有// 写入 savedStateHandle[key] value savedStateHandle.set(key, 123) // 读取如果不存在则返回 null 或默认值 val name: String? savedStateHandle.get(name) val age: Int savedStateHandle.get(age) ?: 18 // 判断是否包含某个 key val hasData savedStateHandle.contains(userName) // 移除某个 key savedStateHandle.remove(tempData)在日常开发里我比较习惯用get(key)配合 Elvis 操作符因为返回可空类型可以灵活处理“第一次进入页面还没保存过数据”的情况。如果你确定这个 key 一定存在也可以用savedStateHandle[key]但要注意空安全。3.3 LiveData 和 StateFlow 适配响应式读取 SavedStateHandleSavedStateHandle 最让我喜欢的一点是它内置了 LiveData 和 StateFlow 的桥接方法能够让状态恢复和响应式 UI 完美衔接。先看 LiveData老项目里用得非常多val keyword savedStateHandle.getLiveData(keyword, ) // Activity 里 viewModel.keyword.observe(this) { keyword - searchEditText.setText(keyword) }这样页面旋转后EditText 里的内容会自动恢复不用你再写一套 onSaveInstanceState / onRestoreInstanceState。再看 Google 现在推荐的 StateFlow 写法用 getStateFlow 可以直接得到一个 StateFlow 对象class SearchViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { val keywordState: StateFlowString savedStateHandle.getStateFlow(keyword, ) fun updateKeyword(keyword: String) { savedStateHandle[keyword] keyword } }有一点要特别提醒getStateFlow 和 getLiveData 都只是单向桥接。也就是说当 SavedStateHandle 里的 key 被写入新值时LiveData / StateFlow 会发射新值但如果你直接去修改 LiveData / StateFlow 的值并不会自动回写到 SavedStateHandle。所以统一的做法是ViewModel 里只暴露读取方法所有写操作都通过savedStateHandle[key] xx来完成保证数据是从 SavedStateHandle 这个“唯一数据源”流出避免同步混乱。3.4 为什么数据类型被限制得这么严我知道有人刚开始用的时候会不满为什么不能像内存变量一样想存什么存什么这就要回到 SavedStateHandle 的工作原理了。前面说过SavedStateHandle 的数据最终会被打包进 Activity 的 onSaveInstanceState Bundle。这个 Bundle 有两个去向一个是跨进程传输到系统的 ActivityTaskManager另一个是进程被杀后系统可能把它持久化到磁盘。既然要走跨进程通道那所有数据就必须是能被系统底层序列化框架识别的类型不能是任意 Java 对象。Android 的 Binder 传输有事务大小限制数据如果太大太复杂轻则变慢重则直接抛出 TransactionTooLargeException 导致页面崩溃。所以记住一个准则SavedStateHandle 里只应该放“小而关键”的 UI 状态比如当前选中的 Tab、输入框内容、筛选条件、列表滚动位置这些。大文件、网络响应体、敏感凭证这些不应该塞进来。4. 实战一个新闻列表页的状态恢复写法4.1 场景拆解列表页要保存哪些状态只看计数器太简单我们直接上一个实际开发中更常见的页面新闻列表。假设需求是这样的首页是新闻列表顶部有一个搜索框用户输入关键词后请求接口列表展示结果底部有一个加载更多按钮。页面非常普通但它涉及的状态其实很杂搜索框里用户输入的文字。当前是搜索中状态还是加载完成状态。已经加载出来的新闻列表。当前点击了第几页。用户在列表里滚动到了哪个位置。如果没有 SavedStateHandle我通常会在 ViewModel 里用一堆 MutableStateFlow 来存但一旦进程被杀这些数据全没了。改造后的思路是把“能持久化的轻量状态”交给 SavedStateHandle把“重量级的列表数据”交给 ViewModel 内部缓存等状态恢复后重新触发加载。4.2 代码实现ViewModel 里保存 query、isLoading、list直接在 ViewModel 的构造参数里定义 SavedStateHandle然后定义对应的 StateFlowdata class NewsItem( val id: String, val title: String, val url: String ) class NewsViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { companion object { private const val KEY_QUERY query private const val KEY_PAGE page private const val KEY_IS_LOADING is_loading } val query: StateFlowString savedStateHandle.getStateFlow(KEY_QUERY, ) val page: StateFlowInt savedStateHandle.getStateFlow(KEY_PAGE, 1) val isLoading: StateFlowBoolean savedStateHandle.getStateFlow(KEY_IS_LOADING, false) private val _newsList MutableStateFlowListNewsItem(emptyList()) val newsList: StateFlowListNewsItem _newsList.asStateFlow() fun search(query: String) { savedStateHandle[KEY_QUERY] query savedStateHandle[KEY_PAGE] 1 loadNews() } fun loadMore() { val nextPage (savedStateHandle.get(KEY_PAGE) ?: 1) 1 savedStateHandle[KEY_PAGE] nextPage loadNews() } private fun loadNews() { savedStateHandle[KEY_IS_LOADING] true // 这里假装请求网络 val query savedStateHandle.get(KEY_QUERY) ?: val page savedStateHandle.get(KEY_PAGE) ?: 1 viewModelScope.launch { val result api.fetchNews(query, page) _newsList.value result savedStateHandle[KEY_IS_LOADING] false } } }Activity 里依然通过 by viewModels() 获取然后观察这些 StateFlow 就可以刷新 UI。这里有一个难点_newsList本身不是通过 SavedStateHandle 保存的那进程被杀后列表数据不就没了吗确实会没但 SavedStateHandle 帮我们把请求参数query 和 page留下来了所以页面恢复后ViewModel 只要在 init 里判断一下query ! 就重新调用 loadNews()这样列表就能很快加载回来。本质上 SavedStateHandle 扮演的是“便签”的角色帮你记住用户在看什么、搜了什么然后你按照便签重新把数据拉回来。这比把整个新闻列表塞进 Bundle 要稳妥得多。4.3 配合 repository 缓存SavedStateHandle 只负责轻量状态如果你觉得列表数据丢了很可惜推荐的做法是给数据再加一层 repository 缓存比如把最近一次请求的结果存到 DataStore 或者 Room 里。SavedStateHandle 负责保存“当前处于什么过滤条件下、翻到第几页”repository 缓存负责保存“最近一次列表长什么样”。两者结合页面恢复速度会非常快。我知道有人有强迫症想把newsList也放进 SavedStateHandle比如savedStateHandle[KEY_LIST] newsList如果 newsList 是一个很大的 List这种做法非常不理智。因为 SavedStateHandle 里的数据最终要进 BundleBundle 在进程被杀时要参与跨进程传输数据量一大轻则卡顿重则直接崩溃。正确的心态是SavedStateHandle 只存“轻量且必要”的字段业务数据老老实实走数据层缓存。4.4 一个容易忽略的细节init 恢复时机很多人用 SavedStateHandle 时会忽略一个细节SavedStateHandle 的初始值不一定来自你自己写入的数据。如果这是进程被杀后系统重新创建的 ActivitySavedStateHandle 已经被 Bundle 预先填上了上一次保存的值。因此你用getStateFlow(KEY_QUERY, )读取到的初始值可能就是进程被杀前的真实 query而不是空字符串。这意味着在 ViewModel 的 init 里不能想当然地认为“默认值一定就是页面初始状态”。我早期做项目时就踩过这个坑ViewModel 在 init 里无条件执行了一次loadNews()结果进程恢复后页面先按默认参数加载了一遍紧接着 SavedStateHandle 里的 query 又变成之前的搜索词导致接口被重复调了两次列表还闪动了一下。正确做法是在 init 里先判断 SavedStateHandle 里有没有已经保存的数据有就按保存的数据恢复没有才走默认加载逻辑。或者干脆把“是否已经初始化”也作为一个 key 存到 SavedStateHandle 里。5. 常见问题与避坑实录5.1 数据依然丢失多半是这三个地方没做对“我明明加了 SavedStateHandle为什么数据还是丢了”这是我在评论区被问得最多的问题。复盘起来基本逃不出这三个原因。第一个原因没有用 ViewModelProvider 创建 ViewModel。只要你是自己手动 new 的或者把 ViewModel 放在了 Application 的静态变量里SavedStateHandle 就不会被系统注册自然也没有保存效果。第二个原因Activity 没有正确配置 savedInstanceState。如果你在 manifest 里给 Activity 设置了android:configChangesorientation|screenSize屏幕旋转时 Activity 根本不会重建那你自然看不到 SavedStateHandle 的效果。但这不代表以后就安全了进程被杀死时它依然会走 onSaveInstanceState所以长期来看还是应该把状态放到 SavedStateHandle而不是依赖 configChanges。configChanges 只是拖延问题的爆发不能根治。第三个原因进程被杀死后恢复数据依赖系统给你的“机会”。如果用户是自己主动从最近任务里滑掉应用系统不会帮你恢复任务栈数据SavedStateHandle 也救不了。所以在验证功能时不要用手动清除后台任务的方式测试正确测试方法是开发者选项里打开“不保留活动”或者用 adb 命令杀进程再重新打开应用。5.2 自定义对象保存时崩了Serializable 和 Parcelable 怎么选SavedStateHandle 支持存 Serializable 对象所以在早期图省事的时候我会让数据类直接实现 Serializable。问题来了项目越往后数据类的嵌套越深序列化和反序列化耗时就越明显而且 Serializable 是反射机制偶发会触发一些字段序列化异常。用 Parcelable 性能更好但手动写 Parcelable 模板代码又很繁琐。我的建议是优先用 Kotlin 的Parcelizeimport android.os.Parcelable import kotlinx.parcelize.Parcelize Parcelize data class UserState( val userId: Long, val userName: String, val avatarUrl: String ) : Parcelable然后在 SavedStateHandle 里直接存savedStateHandle[user] UserState(1L, 张三, https://...)这里还有一个坑如果你把一个非 SavesStateHandleSupport 的自定义 class 直接塞进去比如只实现了 Serializable 但没有正确处理内部字段可能在运行时报 NotSerializableException。调试的时候要记得看堆栈第一行别被后面的工程代码干扰。5.3 SavedStateHandle 不是数据库这一点我必须在文章里反复强调SavedStateHandle 的数据依附于 Activity 的任务栈保存机制它不适合存长期业务数据。比如登录状态、用户配置、收藏列表这些东西应该放在 DataStore、数据库或服务端。SavedStateHandle 更适合做页面级、短生命周期的状态保存。如果你把用户登录态放在 SavedStateHandle 里一旦任务栈被回收用户就可能出现“明明登录过又变成未登录”的诡异 bug。判断标准很简单这个状态要是用户下次启动 App 后还需要那它就不属于 SavedStateHandle如果它只是在当前页面切换、屏幕旋转、临时退后台时需要那 SavedStateHandle 才是合适人选。5.4 版本与依赖冲突升级 lifecycle 后行为变化我在 2023 年维护一个老项目时遇到过一个问题项目里引入了 lifecycle 2.4.0 的 ViewModel又把 viewmodel-savedstate 升到了 2.6.1结果运行时 ViewModel 构造方法里的 SavedStateHandle 参数一直拿不到页面状态总是恢复不了。查了半天才发现是版本错位导致的。解决方案是把整个 lifecycle 系列的版本统一ViewModel、LiveData、SavedStateHandle 都对齐到同一个版本号。如果项目使用了 Hilt还要检查 hilt-android 和 hilt-navigation-compose 的版本是否匹配。依赖这东西版本统一是省心省力的最佳实践混用版本永远是大坑。5.5 依赖注入框架里的 SavedStateHandle 初始化现在很多项目会引入 Hilt 或者 Koin。拿 Hilt 举例你只需要在 ViewModel 的构造函数里声明 SavedStateHandle 参数Hilt 会自动帮你注入HiltViewModel class NewsViewModel Inject constructor( private val savedStateHandle: SavedStateHandle ) : ViewModel() { // ... }这里要提醒的是Hilt 注入的 SavedStateHandle 只能用于by viewModels()或AndroidEntryPoint创建的 ViewModel 里。如果你在普通类、自定义 View 里想用一样的方式拿 SavedStateHandle是拿不到的。SavedStateHandle 的生命周期绑定的是 ViewModelStore脱离这个场景去使用它也失去了意义。Koin 的用法也类似通常通过getSavedStateHandle()传入 ViewModel 构造参数。遇到问题先检查是不是 ViewModel 的创建方式绕过了框架或者 SavedStateHandle 是在 Application 级别创建的。6. SavedStateHandle、onSaveInstanceState、DataStore 怎么分工6.1 三种状态保存方式的对比很多初学者容易把 SavedStateHandle 和另外几个状态保存方案搞混。我把它们放在一张表里方便对照方案生命周期范围使用场景底层实现ViewModelActivity / Fragment 重建后保留页面内的中短期临时状态如网络返回结果内存中的 ViewModelStoreonSaveInstanceState / BundleActivity 重建或进程被杀后恢复少量轻量状态如当前选中项、输入框内容Bundle 序列化SavedStateHandle与 ViewModel 绑定支持进程被杀恢复ViewModel 内轻量 UI 状态的自动保存与恢复ViewModel Bundle 组合DataStore / RoomApp 重启后依然存在长期业务数据、用户配置、数据库文件 / 数据库持久化从这张表可以清晰看到SavedStateHandle 的位置其实很特殊它把 ViewModel 的“内存便利性”和 onSaveInstanceState 的“跨进程可靠性”粘合在一起。你不需要在 Activity 里写繁琐的字段保存也不用把状态全部降级为数据库存储在 ViewModel 层面就把事办了。6.2 选型建议什么场景用哪个实际项目里我的选型原则是这样的页面内部、配置变更时需要保留的状态优先放 ViewModel 普通变量。ViewModel 自己丢失的情况不需要考虑太多。需要进程被杀后还能恢复的轻量 UI 状态用 SavedStateHandle。比如输入框内容、Tab 选中位置、Filter 条件、分页页码。需要永久保存的业务数据用 DataStore 或 Room。比如用户 nickname、主题设置、离线缓存。需要跨页面共享的数据用 SharedViewModel 或数据仓库而不是直接往 SavedStateHandle 里塞。因为 SavedStateHandle 本身是按 ViewModel 实例隔离的不同页面、不同 ViewModel 实例各自持有一份状态共享容易出乱子。这里补充一个小技巧SavedStateHandle 是支持跨页面恢复的即使你从一个页面跳到另一个页面再切回前一个页面任务栈里的 Bundle 状态依然有效。但如果你想让两个 Fragment 之间共享同一个 SavedStateHandle就得让它们归属于同一个 Activity 的 ViewModelStore。实践中我经常用activityViewModels()做共享 ViewModel这样 SavedStateHandle 也自然共享了。7. 我的一些实际操作体会SavedStateHandle 看起来只是一个不起眼的参数但它在真实项目里帮过我很大的忙。记得有一次线上反馈说用户在详情页看文章看到一半切到微信回了个消息回来打开多任务重新进入 App页面直接回到首页详细信息全丢了。排查后发现详情页的数据是放在 ViewModel 里的进程被杀后 ViewModel 被回收Activity 虽然有 onSaveInstanceState但代码里根本没存文章 id。后来我用 SavedStateHandle 把 articleId 和 scrollPosition 存下来重启后先拿 id 重新拉详情再把滚动条恢复到之前的位置问题基本就解决了。最后分享一个小技巧我在工程里会做一个简单的 BaseViewModel 抽象类把 SavedStateHandle 封装进去统一提供setState(key, value)和getState(key)方法。这样团队里的新成员不会因为忘记传参数而踩坑整条数据流的读取写入规则也比较统一。你不需要让每个 ViewModel 都重写一套逻辑但“状态保存”这件事值得作为一个基础设施在设计阶段就规划好。