Android 从零到一 Fragment 事务与状态丢失:从崩溃现场到稳定治理
Fragment 的添加、替换和返回栈操作看起来只是几行代码,但线上常见的 Can not perform this action after onSaveInstanceState、页面重复叠加、返回后状态错乱,往往都与事务提交时机和状态恢复机制有关。本文从事务基础讲起,逐步拆解状态丢失的根因,并给出可落地的治理方式。
FragmentTransaction 到底做了什么
对 Fragment 的 add、replace、remove、show、hide 操作不会立即修改界面。这些操作先被记录在 FragmentTransaction 中,调用 commit() 后,事务才会被放入主线程消息队列,等待 FragmentManager 执行。
kotlin
supportFragmentManager.beginTransaction()
.replace(R.id.container, DetailFragment.newInstance(itemId))
.addToBackStack("detail")
.commit()
这里有两个容易忽略的事实:
commit()是异步提交,方法返回时事务不一定已经执行。- 一次事务中的操作会作为一个整体进入执行队列,但多个事务之间仍受提交顺序和主线程调度影响。
因此,刚提交事务就通过 findFragmentByTag() 查询,可能仍然拿不到目标 Fragment:
kotlin
supportFragmentManager.beginTransaction()
.add(R.id.container, ProfileFragment(), "profile")
.commit()
// 此时事务可能尚未执行,结果可能为 null
val fragment = supportFragmentManager.findFragmentByTag("profile")
如果后续逻辑必须依赖事务已经完成,可以重新设计为事件回调,或者在确实需要同步完成且调用链可控时使用 commitNow()。
commit、commitNow 与允许状态丢失的提交
FragmentManager 提供了几种提交方式,它们的差异不能只理解为"异步"和"同步"。
commit
commit() 将事务加入主线程待执行队列,是最常用的选择。它允许使用 addToBackStack(),也更适合普通页面切换。
kotlin
parentFragmentManager.beginTransaction()
.setReorderingAllowed(true)
.replace(R.id.container, ResultFragment())
.addToBackStack(null)
.commit()
commitNow
commitNow() 会在当前调用中同步执行事务。它不能与 addToBackStack() 同时使用,否则会抛出异常。同步执行还可能放大主线程耗时,所以不应把它当成解决所有时序问题的快捷方式。
适合它的场景通常很窄,例如宿主初始化过程中必须立刻得到已创建的 Fragment,且事务不进入返回栈:
kotlin
if (supportFragmentManager.findFragmentByTag("root") == null) {
supportFragmentManager.beginTransaction()
.add(R.id.container, RootFragment(), "root")
.commitNow()
}
commitAllowingStateLoss
commitAllowingStateLoss() 在状态已经保存后仍允许提交。它不会让事务更可靠,只是接受"这次界面变化可能无法恢复"的结果。
例如,一个非关键加载提示在页面即将进入后台时消失,即使进程重建后恢复不到这个变化,业务数据也不会受损,这类操作才可能考虑允许状态丢失。用户确认、支付结果、表单提交等关键状态绝不能依赖它。
commitNowAllowingStateLoss() 同样接受状态丢失,只是同步执行,风险边界并没有改变。
状态为什么会丢失
Activity 进入后台、配置变化或可能被系统回收时,会调用 onSaveInstanceState() 保存可恢复状态。FragmentManager 也会记录当前有哪些 Fragment、返回栈结构以及必要的状态。
如果在保存完成后又提交普通事务,新事务不在已经生成的快照里。进程被杀后,系统只能根据旧快照恢复,于是刚才的页面变化消失。为了避免应用悄悄进入不一致状态,FragmentManager 直接抛出异常:
text
IllegalStateException: Can not perform this action after onSaveInstanceState
典型触发链路如下:
- 页面发起网络请求。
- 用户按 Home 键,Activity 保存状态并进入后台。
- 网络回调到达,代码尝试打开结果 Fragment。
- 普通
commit()发现状态已经保存,抛出异常。
问题不在于网络回调"太慢",而在于回调直接驱动了一个当前生命周期不允许执行的界面动作。
不要用 try-catch 掩盖事务异常
下面的处理只能避免闪退,却会吞掉导航行为,用户回来后看到什么完全取决于时序:
kotlin
try {
parentFragmentManager.beginTransaction()
.replace(R.id.container, ResultFragment())
.commit()
} catch (error: IllegalStateException) {
// 不推荐:异常消失了,状态一致性问题仍然存在
}
直接换成 commitAllowingStateLoss() 也只是把显式崩溃变成隐式状态丢失。正确方向是将业务状态与一次性的界面操作分开,让页面在合适的生命周期阶段消费状态。
用可恢复状态驱动页面
假设请求完成后需要展示结果页,可以让 ViewModel 保存"结果已就绪"的业务状态,由处于前台的界面决定是否导航。
kotlin
data class UiState(
val loading: Boolean = false,
val resultId: String? = null,
val errorMessage: String? = null
)
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun search(keyword: String) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, errorMessage = null) }
runCatching { repository.search(keyword) }
.onSuccess { result ->
_uiState.update {
it.copy(loading = false, resultId = result.id)
}
}
.onFailure { error ->
_uiState.update {
it.copy(loading = false, errorMessage = error.message)
}
}
}
}
fun consumeResult() {
_uiState.update { it.copy(resultId = null) }
}
}
Fragment 只在生命周期达到 STARTED 后收集状态:
kotlin
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
binding.progress.isVisible = state.loading
val resultId = state.resultId ?: return@collect
if (!parentFragmentManager.isStateSaved) {
parentFragmentManager.beginTransaction()
.setReorderingAllowed(true)
.replace(
R.id.container,
ResultFragment.newInstance(resultId)
)
.addToBackStack("result")
.commit()
viewModel.consumeResult()
}
}
}
}
repeatOnLifecycle 会在页面进入后台时取消内部收集,在回到前台后重新收集。业务结果仍保存在 ViewModel 中,不需要冒险在后台提交事务。
这里的 isStateSaved 是额外防线,不应成为唯一机制。只检查它仍可能出现检查之后、提交之前状态发生变化的竞态;生命周期感知的收集方式才是主干设计。
避免恢复后重复添加 Fragment
屏幕旋转或进程重建时,FragmentManager 会自动恢复此前的 Fragment。如果 Activity 每次创建都无条件添加根 Fragment,就会产生重叠实例:
kotlin
// 错误示例:重建后可能重复添加
supportFragmentManager.beginTransaction()
.add(R.id.container, HomeFragment())
.commit()
初始化时应判断 savedInstanceState,或者按稳定 tag 查询:
kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.setReorderingAllowed(true)
.add(R.id.container, HomeFragment(), "home")
.commit()
}
}
不要通过字段长期持有手动创建的 Fragment 实例。恢复后真正处于 FragmentManager 管理中的对象可能已经换成系统重建的实例,应通过 tag、容器 ID 或 Navigation 组件获取当前对象。
setReorderingAllowed 为什么值得开启
setReorderingAllowed(true) 允许 FragmentManager 优化同一事务中的状态变化,并确保过渡和生命周期回调更符合最终状态。AndroidX Fragment 的现代用法通常建议开启它,尤其是事务进入返回栈时。
假设同一事务先添加 A 又替换为 B,关闭重排序可能让 A 经历不必要的完整生命周期;开启后,FragmentManager 可以围绕事务最终结果安排操作。它不能修复错误的提交时机,但能减少中间状态和生命周期抖动。
返回栈与业务返回不是一回事
addToBackStack() 保存的是 Fragment 事务操作,按返回键时 FragmentManager 会反向执行事务。它并不自动撤销网络请求、数据库写入或 ViewModel 中的业务状态。
因此需要明确两类状态:
- 导航状态:当前显示哪个 Fragment、返回栈有哪些目的地。
- 业务状态:搜索结果、订单内容、输入草稿、提交进度。
把业务数据只放进 Fragment 字段,会在重建后丢失;把一次性导航事件永久保留在 StateFlow 中,又可能在重新收集时重复跳转。可恢复业务状态应放入 ViewModel 或 SavedStateHandle,一次性动作则要有明确的消费协议。
kotlin
class EditorViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
var draft: String
get() = savedStateHandle["draft"].orEmpty()
set(value) {
savedStateHandle["draft"] = value
}
}
DialogFragment 也受相同规则约束
弹窗经常由异步回调触发,因此同样容易在状态保存后调用 show():
kotlin
ConfirmDialog().show(parentFragmentManager, "confirm")
show() 内部仍然是 Fragment 事务。更稳妥的做法是把"需要确认"表示为页面状态,在前台恢复后展示,并先按 tag 判断是否已经存在:
kotlin
if (!parentFragmentManager.isStateSaved &&
parentFragmentManager.findFragmentByTag("confirm") == null
) {
ConfirmDialog().show(parentFragmentManager, "confirm")
}
这还能避免配置变化、重复回调或快速点击造成多个弹窗叠加。
线上问题如何排查
遇到 Fragment 事务异常,不要只看崩溃行。建议同时记录宿主和 Fragment 的生命周期、状态是否已经保存、触发来源以及事务目标。
kotlin
fun FragmentManager.commitSafely(
source: String,
block: FragmentTransaction.() -> Unit
) {
if (isStateSaved) {
Log.w("FragmentTxn", "skip transaction: source=$source, stateSaved=true")
return
}
beginTransaction()
.setReorderingAllowed(true)
.apply(block)
.commit()
}
这个扩展适合"允许稍后由状态重新驱动"的操作,但不能直接用于必须完成的关键流程,否则一次跳过就可能永远丢失。线上日志至少应帮助回答:
- 谁触发了事务,是点击、网络回调还是消息通知?
- 宿主当时处于什么生命周期?
isStateSaved是否为 true?- 是否存在同 tag 或同容器的 Fragment?
- 事务是否进入返回栈,是否可能被重复消费?
还可以在开发阶段开启 FragmentManager 调试日志:
kotlin
FragmentManager.enableDebugLogging(BuildConfig.DEBUG)
结合"开发者选项"中的"不保留活动"测试后台恢复,并执行旋转屏幕、切换深色模式、快速前后台切换等场景,很多只在重建时出现的问题会更早暴露。
一套可执行的治理清单
- 默认使用
commit(),只有明确需要同步结果且不进入返回栈时才使用commitNow()。 - 不用允许状态丢失的提交承载关键业务动作。
- 异步结果先进入 ViewModel,再由处于前台的界面消费。
- 使用
repeatOnLifecycle绑定收集范围,并把isStateSaved作为辅助保护。 - Activity 重建时依赖 FragmentManager 自动恢复,不重复创建根 Fragment。
- 为需要去重的 Fragment 和 DialogFragment 设置稳定 tag。
- 开启
setReorderingAllowed(true),减少无意义的中间生命周期变化。 - 将导航状态和业务状态分开建模,明确一次性动作的消费时机。
- 测试配置变化、进程重建、快速点击和前后台切换,而不只测试正常路径。
总结
Fragment 状态丢失不是某个 API 的偶然缺陷,而是"已经保存的界面快照"和"之后发生的事务"之间产生了冲突。稳定的解决方案也不是机械替换提交方法,而是让业务结果可恢复、让界面动作生命周期感知、让 FragmentManager 负责实例恢复。
理解事务异步执行、状态保存边界和返回栈职责后,很多看似随机的 Fragment 崩溃都能还原成确定的时序问题。把这些约束落实到状态建模、导航入口和重建测试中,页面切换才能在复杂生命周期下保持一致。