资讯动态

Fragment状态保存与恢复:转屏/进程回收不再崩溃

发布时间:2026/10/16 11:26:25 来源:尧图企业网站定制
好几天没更新这个系列了今天继续聊Fragment。前五篇把Fragment基本用法、生命周期、动态加载和通信都过了一遍算下来入门算是打通了。但有个非常现实的问题书里讲得很浅实战里却天天遇到——转屏、切后台、进程被系统回收之后Fragment的状态一片混乱列表滑到一半重启回顶部、刚填的表单白填了、甚至直接抛异常闪退。这次我把这块内容系统整理一遍当作系列第六篇也当作一份可以直接抄作业的状态保存与恢复手册。这篇文章适合两类人一是刚看完《第一行代码》第三版准备上真项目的读者书里讲的Fragment基础足够但状态恢复这块确实需要额外补课二是已经被线上闪退逼疯的Android开发兄弟本篇覆盖的异常和解决方案基本都能对应到你单测里复现不出来的那类问题。1. 状态保存背后的机制为什么Fragment需要“自己管自己”1.1 转屏和进程回收是两种完全不同的“销毁”要搞懂Fragment状态保存先得分清两个场景99%的混乱都源于把这两件事混在一起谈。第一种是配置变更比如转屏、切换深色模式。系统销毁当前Activity和Fragment然后用新的Configuration重新创建。这个过程你写的代码全程可见控件的id还在变量值也还在唯一的问题是内存里那些临时数据——列表滚动位置、输入框内容、选中的Tab——全被清空了。第二种是进程被系统回收。用户在后台待了太久系统内存吃紧把我们的进程“杀掉”了。这时候onDestroy可能都来不及走完什么变量全没了但Android会保留一个Bundle对象里面装着之前onSaveInstanceState存的数据等用户切回来时用这个Bundle重新拉起进程。这两者有一个共同点都需要你在“销毁前”把关键数据备份出去然后在“重建后”恢复回来。“备份”这个动作对应的是onSaveInstanceState回调恢复就藏在onCreate / onCreateView / onViewCreated的参数里。Fragment也一样只是多了个setArguments和ViewModel的选择题后面我会仔细说。1.2 Activity替Fragment保存了什么没保存什么先说Activity这边。系统在销毁Activity之前会要求每个已注册的Fragment把自己的状态塞进Bundle。FragmentManager会遍历所有添加过的Fragment逐个调用它们的onSaveInstanceState并把得到的数据一起放进Activity的savedInstanceState里。但这里面藏着两个前提。第一Fragment必须有一个能唯一标识自己的“身份”。要么你有id要么你有tag两者都没有的话重建之后房子还在人没了——空指针等着你。很多教程喜欢用add(R.id.container, fragment)不传tag这个操作能跑但转屏后恢复时FragmentManager找不到对应存储记录轻则状态丢失重则崩溃。第二FragmentManager本身也是靠一套“所有Fragment的记录列表”来恢复的。你动态添加的每一个Fragment它都会按顺序重建而不是你的Activity重建时只恢复“当前可见”的Fragment。这意味着什么如果你用hide/show切换多个Fragment转屏后它们全部都会重新走一遍生命周期只是被隐藏的那个不显示而已。所以不要指望“我只add了当前页面其他页面应该不会重建”——Manager的胃口很大它会全都要。1.3 ViewModel它不是万能的但配合好的话很能打《第一行代码》第三版里面用了不少篇幅讲ViewModel书里强调的是“配置变更后数据不丢”。这句话本身没错但很多读者把它理解成了“ViewModel能搞定一切状态恢复”这就是坑了。真实情况是配置变更时ViewModel实例确实活下来了Activity和Fragment重建后拿到的还是同一个ViewModel对象。但如果进程被系统杀了ViewModel也跟着没了因为ViewModel本身活在内存里不是持久化存储。进程回收之后Fragment和Activity全部重建即使你还是调用viewModelProvider.get()拿到的也是一个全新的空ViewModel。所以标准做法是综合使用进程回收以内的“临时状态”放ViewModel直接解决转屏问题跨进程回收的“关键业务状态”走SavedStateHandle由系统在进程回收后帮我们恢复。2. 实操里最容易踩的三个坑我全踩过2.1 onSaveInstanceState的时机和Bundle的“体重限制”先背一个小知识点Fragment的onSaveInstanceState一定会发生在onDestroyView之后、onDestroy之前而Activity的会发生在onPause之后、onStop之前。这个顺序意味着什么Activity正准备被销毁时Fragment还活着Manager会先收集Fragment的状态再让Activity统一打包。所以你在Activity的onSaveInstanceState里往Bundle放了数据Fragment的onSaveInstanceState一般也已经执行过了双方不会互相覆盖但也别指望在Activity的onSaveInstanceState里去改Fragment的数据——太晚了。接下来是Bundle的“体重限制”。每个Bundle最终都要通过Binder跨进程传输Binder事务缓冲区有大小限制大概1MB上下。超过这个大小系统直接抛TransactionTooLargeException这是一个非常文明异常——它不急不躁它反正就是要闪退。我见过有人把一张几MB的Bitmap塞进savedInstanceState结果转屏必崩排查了一下午才发现是Bitmap的问题。注意所有放进savedInstanceState的数据都会在系统需要时被序列化别存大对象、别存自定义复杂类型除非你把它们完整实现了Parcelable/Serializable。实际项目中我建议只存int、String、boolean这类基础值复杂状态要么用ViewModel要么重建时再重新加载一遍别嫌麻烦。2.2 嵌套Fragment闪退commit时机不对神仙难救这是我在多个项目里反复看到、也反反复复趟过的坑。如果在onSaveInstanceState之后继续执行FragmentTransaction的commit()系统会非常诚实地抛出一个IllegalStateException: Can not perform this action after onSaveInstanceState为什么因为commit()是异步的只是把一个事务排进队列真正执行可能发生在系统的下一个消息循环里。当onSaveInstanceState已经执行、系统已经保存了Activity和Fragment的状态快照之后如果队列又冒出来一个事务Manager肯定没法把这个新事务也塞进刚才保存的Bundle里——你让系统怎么在恢复现场时解释一个“事后才出现的Fragment”解决方法多数时候不是换API而是改时机尽量别在异步回调网络、按钮、onResume后期里无条件调commit尤其别把commit写在onActivityResult或什么回调的末尾等Activity回到resumed状态再提交。但如果确实非要在这类时机提交比如一个网络回调晚到了几秒还有一种办法是commitAllowingStateLoss()它把“状态丢失”的后果吞掉不抛异常。可我的建议是这玩意能不用就不用因为它只是不报错该丢的状态照样丢而且丢的方式还特别隐蔽——比如A页面跳B页面的过程tap一下就变成了没反应。2.3 DialogFragment转屏后“再次弹窗”结果崩了DialogFragment本身也是一个Fragment它的恢复机制理论上和普通Fragment一样。但很多人写DialogFragment喜欢在Activity里这样操作if (!dialog.isAdded) { dialog.show(supportFragmentManager, tag) }这个判断看起来很稳健但isAdded只在“已添加未分离”时返回true如果转屏刚重建、Manager正在恢复旧DialogFragment还没走到resumed这时你立刻show一个新的两个相同tag的Fragment叠加在一起轻则状态错乱重则IllegalArgumentException: Fragment already added推荐的稳妥姿势是这样的// 恢复阶段不要立即show让Manager先去恢复它自己记录的Fragment // 如果你确实要在重建后主动展示一个DialogFragment val tag my_dialog if (supportFragmentManager.findFragmentByTag(tag) null) { val dialog MyDialogFragment() dialog.show(supportFragmentManager, tag) }先findFragmentByTag查一下确认Manager的记录里还没有这个Dialog才创建新的。这就避免了你和FragmentManager互相抢人导致的重复添加。3. 一套能直接塞进项目里的状态保存方案3.1 先分清UI状态和业务状态动手写代码之前先做个分类练习。状态大致分两类一类是“页面长什么样”的状态——滚动位置、EditText内容、当前选中Tab位置、折叠面板是否展开、列表是否在加载中。这类状态的特点是和用户当前操作强相关换一个进入方式就得重置进程杀死之后其实恢复价值没那么高但转屏时最好别丢。二类是“业务逻辑”的状态——网络接口返回的数据、用户登录信息、点赞是否成功的标记、当前的分页游标。这类状态的特点是和底层数据绑定换个页面再进来通常还得拉取恢复时优先级很高。我的习惯是业务状态放Repository或ViewModelUI状态能放ViewModel也放ViewModel只有真正轻量级的UI现场比如记住选中哪一页才放进savedInstanceState。这样好处是转屏恢复基本交给ViewModel自动完成我只需要在进程回收时再依赖SavedStateHandle兜底。3.2 SavedStateHandle才是“进程回收也能恢复”的关键如果你用AndroidX的ViewModel那么它会自带你传入的SavedStateHandle。很多新人对这个东西一脸懵其实就是系统替ViewModel保存的一个Bundle进程存活时它活在内存进程被回收时它的值会被写进savedInstanceState设备里。等用户回来Fragment重建ViewModel被重新创建SavedStateHandle自动恢复页面就能回到上次离开的状态。用法很简单在ViewModel里声明class DetailViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { var selectedId: String get() savedStateHandle.get(selected_id) ?: set(value) { savedStateHandle.set(selected_id, value) } // 还有一个更优雅的用getLiveData包装Activity/Fragment端observe即可 val selectState: LiveDataString savedStateHandle.getLiveData(selected_id, ) }关键在于获取ViewModel时别手搓构造函数。你得用AndroidViewModelFactory。建议直接依赖注入框架帮你创建或者用ViewModelProvider(this)[DetailViewModel::class.java]AndroidX会从默认工厂里自动把SavedStateHandle填好。你唯一要做的是在ViewModel构造器里接收一下它没有其他魔法。3.3 完整示例主从Fragment转屏后还停在刚才选的Item写一个最贴近实战的例子左边列表Fragment右边详情Fragment点击某一条详情就展示详情。转一圈屏幕再回来列表还停在刚才那条的位置详情Fragment还在显示刚才那条内容。先定义ViewModelclass SharedViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { // 列表滚动位置 var listScrollPosition: Int get() savedStateHandle.get(list_scroll_position) ?: 0 set(value) { savedStateHandle.set(list_scroll_position, value) } // 当前选中记录的id val selectedId: LiveDataString savedStateHandle.getLiveData(selected_id, ) fun selectRecord(id: String) { savedStateHandle.set(selected_id, id) } }列表Fragment里这样恢复滚动位置class ListFragment : Fragment() { private val viewModel: SharedViewModel by viewModels({ requireParentFragment() }) private lateinit var recyclerView: RecyclerView private val layoutManager LinearLayoutManager(requireContext()) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) recyclerView view.findViewById(R.id.recycler_view) recyclerView.layoutManager layoutManager // 恢复滚动位置转屏或进程重建后都能用 if (viewModel.listScrollPosition ! 0) { layoutManager.scrollToPosition(viewModel.listScrollPosition) } recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) viewModel.listScrollPosition layoutManager.findFirstVisibleItemPosition() } }) } }详情Fragment里用LiveData驱动刷新class DetailFragment : Fragment() { private val viewModel: SharedViewModel by viewModels({ requireParentFragment() }) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.selectedId.observe(viewLifecycleOwner) { id - // 拉数据刷新UI loadDetail(id) } } }这套方案有个额外优点列表滚动位置和选中项都放ViewModel里转屏后Activity重建FragmentManager同时恢复两个FragmentViewModel还是同一个位置和选中状态根本不丢连SavedStateHandle都用得上——只在进程回收时它才出来兜底。4. 进程回收模拟与崩溃现场排查技巧4.1 模拟进程被系统杀掉的方法代码写得再漂亮不验证等于白写。验证进程回收后的恢复逻辑核心手段是强制杀进程。开发者选项里有个“不保留活动”Dont keep activities选项打开它每次切后台Activity就被系统销毁基本能模拟转屏。但它模拟的是“Activity被立即销毁”进程本体往往还活着ViewModel不一定丢。要模拟“进程被杀”先在Android Studio的Logcat面板旁边找到Terminal输入adb shell am kill com.your.package这个命令会把目标应用的后台进程直接杀掉但保留它的任务记录。然后你从最近任务列表点回来系统会拿着保存下来的savedInstanceState重新拉起进程。这时候你才能验证SavedStateHandle那一层有没有起作用。另一招是adb shell am kill-all把当前设备所有后台进程全杀一遍适合想模拟极端低内存的场景但注意它会杀掉调试里的应用需要从Launcher重新点开效果一样。4.2 崩溃Log行为什么你找不到错误堆栈状态恢复类崩溃有个特点崩溃发生在“恢复的瞬间”不是发生在“保存的瞬间”。很多人转屏后闪退立马开Logcat看转屏那一刻的日志结果只有正常销毁重建的信息没有异常。正确的思路是往回看——崩溃日志一般出现在“重建”“onViewCreated”“onActivityCreated”这一段。搜索关键字FragmentManager、IllegalStateException、ClassNotFoundException。ClassNotFoundException是一个特别容易忽略的恢复崩溃。如果你的Fragment、ViewModel或自定义类型没有默认构造函数也没有实例化工厂系统在恢复时反射创建它找不到构造器直接抛ClassNotFoundException。任何需要恢复的自定义类都必须保证存在一个无参构造器哪怕它内部从SavedStateHandle取数据反射也是先调无参构造器再赋值。4.3 常见问题速查表现象可能的根因排查方法推荐做法转屏后Fragment重复添加抛出Fragment already added重建时手动add了Manager已经在恢复的Fragment检查恢复路径是否同时存在add操作恢复前用findFragmentByTag查询不要无脑addCan not perform this action after onSaveInstanceState在onSaveInstanceState之后调用了commit查看异步回调的时机把事务提交改到resumed状态或用commitAllowingStateLoss作为临时方案转屏后DialogFragment闪退没给DialogFragment唯一tag或重复show查看show时传入的tag检查恢复逻辑统一走findFragmentByTag show的组合转屏后列表滚回到顶部没有保存滚动位置检查RecyclerView滚动监听和ViewModel滚动位置存SavedStateHandle恢复时scrollToPosition进程回收回来后数据全空只用了普通ViewModel没接SavedStateHandle检查ViewModel构造器给ViewModel加SavedStateHandle参数Activity持有Fragment的null引用Fragment无条件身份标识无id无tag查看add时的参数每次add都传唯一tag还有一个排查流程建议如果转屏闪退在Android Studio里开Dont keep activities开发者选项→不保留活动然后不停切后台再切回来复现概率大大提高再配合adb shell am kill模拟进程回收基本能把两类崩溃都试出来。我之前调试Fragment嵌套的时候就是靠这两种手段发现了一个只在“进程被回收后返回”才触发的崩溃单测怎么造都造不出来。5. 再分享几个日常容易被忽略的小细节5.1 避免使用tag拼接错误很多人给动态Fragment起tag时很随意比如fragment_ System.currentTimeMillis()。这标签的唯一性你确认过吗同一时间两次点击time戳相同的概率虽然低但也不是零。一旦两个Fragment的tag重复恢复时直接冲突。我的建议很简单定义一个常量池把每个Fragment的tag写到常量里。比如object FragmentTags { const val HOME home_fragment const val DETAIL detail_fragment const val DIALOG dialog_fragment }有id用id有tag用tag没有就加一个别自己拿字符拼。5.2 fragment 的 arguments 和 savedInstanceState 千万别混用有读者问过我“setArguments是不是和savedInstanceState差不多”差别很大。arguments是每次创建Fragment时通过Bundle传进来的初始化参数它告诉Fragment“你是干什么的”savedInstanceState是Fragment“上次活到一半被杀了”的现场记录。arguments在Fragment的新实例里是拿得到的savedInstanceState只在“恢复旧实例”时才非空。所以项目里有个特别常见的错法把arguments当成运行时状态来更新一会儿set一下一会儿get一下。这完全走歪了arguments设计上是只读的初始化参数你改它不会持久化也不会通知UI更新。运行时状态就该走ViewModel或SavedStateHandle。另外强调一个点Fragment的arguments设置必须在add()或show()之前完成否则后续getArguments()拿到的可能是null或旧数据。5.3 使用ViewBinding时恢复焦点的小坑如果用了ViewBinding恢复焦点也是件容易被忽略的事。转屏后EditText的内容可能靠ViewModel恢复了但焦点默认不在EditText上用户还得点一下才继续输入。这种体验问题虽然不致命但在表单页特别影响手感。恢复焦点可以这么处理在onViewCreated里如果arguments或者SavedStateHandle标明了“上次处于输入状态”就主动请求焦点并让键盘弹出来editText.requestFocus() editText.setSelection(editText.text?.length ?: 0) // 配合InputMethodManager弹出键盘视场景决定要不要主动弹状态保存其实是一个系统工程不只是某个API会用了就行核心是把“保存的时机”“恢复的时机”“保存的范围”想清楚。工具层面记住三个关键词就够了SavedStateHandle管跨进程、ViewModel管配置变更、savedInstanceState是兜底的皮。把这三层理顺了绝大多数界面状态问题都能稳。我个人写项目这么多年的体会是别等崩了再补方案新写一个Fragment之前先问一句“这个页面转屏该恢复什么、进程被杀该恢复什么”答得上来再动手写代码。养成这个习惯之后转屏闪退和恢复错乱这两类问题基本就跟你没什么缘分了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑