AndroidX Fragment 配置变更下实例保留与 Dialog 回调排查记录

背景

在一个 Android 应用中,某个“账号解绑确认弹窗”大致是下面这种写法,确认动作依赖 Fragment 的一个成员变量:

class ConfirmDialogFragment : DialogFragment() {

    private var onConfirm: (() -> Unit)? = null

    fun setOnConfirmListener(listener: () -> Unit): ConfirmDialogFragment {
        onConfirm = listener
        return this
    }

    private fun onConfirmClick() {
        onConfirm?.invoke()
    }
}

也就是说,真正触发解绑逻辑的不是 argumentssavedInstanceState,而是 Fragment 实例里的字段 onConfirm

最初做 MR review 时,提出了一个潜在风险:

如果设备在弹出确认弹窗后发生横竖屏切换,宿主 Activity 重建,那么弹窗里的 onConfirm 由于不在 Bundle 中,理论上应该丢失。此时点击“确认解绑”,应该只 dismiss,不应继续执行解绑逻辑。

但实际手测发现:

  1. 竖屏打开解绑确认弹窗
  2. 切换到横屏
  3. 点击“解绑”
  4. 解绑依然生效

这和最初的静态分析结论矛盾,因此需要做一次完整排查。


发生场景

问题发生时,确认解绑弹窗是挂在应用主界面 Activity 的 supportFragmentManager 下的。

实际链路如下:

主界面 Activity
  └─ 首页 Fragment
       └─ 内容 Fragment
            └─ 打开账号管理 Dialog
                 └─ 点击解绑
                      └─ 打开确认解绑 Dialog

这意味着:

  • 宿主 FragmentManager 来自主界面 Activity
  • 确认解绑 Dialog 是挂在主界面 Activity 的 supportFragmentManager 下的

而这个主界面 Activity 在 manifest 中并没有声明 configChanges

<activity
    android:name="..."
    android:launchMode="singleInstance"
    android:theme="..." />

因此,按标准 Android 语义,横竖屏切换时它应该走 destroy + recreate


日志方案

为了直接回答这个问题,只加了最小必要的一组日志,统一 tag 为:AccountRotateDbg

  • 主界面 ActivityonCreateonDestroy
  • 确认解绑 DialogsetOnConfirmListeneronCreateViewonDestroyViewonDestroy、确认点击时 onConfirm 是否存在
  • 解绑逻辑入口unbindAccountlogout

目标只有三个:

  1. Activity 是否真的重建
  2. 确认解绑 Dialog 是否还是同一个实例
  3. 点击“解绑”时是否真的执行了解绑逻辑

实测日志

用户给出的关键日志如下:

2026-08-19 16:02:08.035  AccountRotateDbg  onCreate instance=45440906 savedState=false orientation=1
2026-08-19 16:02:59.865  AccountRotateDbg  unbindAccount provider=googleCalendar accountId=95800185641911bec36e572a057a9383
2026-08-19 16:02:59.867  AccountRotateDbg  setOnConfirmListener instance=103017373 onConfirmSet=true
2026-08-19 16:02:59.870  AccountRotateDbg  onCreate instance=103017373 savedState=false onConfirmSet=true
2026-08-19 16:02:59.871  AccountRotateDbg  onCreateView instance=103017373 savedState=false onConfirmSet=true
2026-08-19 16:03:05.812  AccountRotateDbg  onDestroy instance=45440906 isFinishing=false
2026-08-19 16:03:05.830  AccountRotateDbg  onDestroyView instance=103017373 onConfirmSet=true
2026-08-19 16:03:05.925  AccountRotateDbg  onCreate instance=197475998 savedState=true orientation=2
2026-08-19 16:03:06.365  AccountRotateDbg  onCreateView instance=103017373 savedState=true onConfirmSet=true
2026-08-19 16:03:09.977  AccountRotateDbg  confirmClick instance=103017373 onConfirmSet=true -> invoke logout
2026-08-19 16:03:09.991  AccountRotateDbg  logout provider=googleCalendar accountId=95800185641911bec36e572a057a9383
2026-08-19 16:03:10.008  AccountRotateDbg  onDestroyView instance=103017373 onConfirmSet=true
2026-08-19 16:03:10.009  AccountRotateDbg  onDestroy instance=103017373 onConfirmSet=true

结论

1. 主界面 Activity 确实被销毁重建了

这一点由下面两行直接证明:

onDestroy instance=45440906 isFinishing=false
onCreate  instance=197475998 savedState=true orientation=2

说明旋转时确实不是简单改方向,而是宿主 Activity 走了标准 recreate 流程。

2. 确认解绑 Dialog 实例没有变

确认解绑 Dialog 从显示到旋转后再到点击确认,一直是同一个实例:instance=103017373

日志里的关键模式是:

  • 旋转前有 onCreateView
  • 旋转时只看到 onDestroyView
  • 旋转后又是同一个 instance 的 onCreateView
  • 中间没有出现 onDestroy

这说明旋转时发生的是:Fragment 实例被保留,只是 View 被销毁并重新创建。

3. onConfirm 没丢,所以解绑逻辑会继续执行

日志明确显示:

confirmClick instance=103017373 onConfirmSet=true -> invoke logout
logout ...

为什么会这样

这次排查的核心收获是:

在配置变更时,被销毁重建的是宿主 Activity,但 FragmentManager 可能会保留已经加入管理器的 Fragment 实例,只重建它们的 View。

这正是 AndroidX FragmentManager 在配置变更时的典型行为:

  1. 旧 Activity 销毁
  2. FragmentManager 通过非配置实例机制保留 Fragment 对象
  3. 新 Activity 创建
  4. 同一个 Fragment 实例重新 attach 到新宿主
  5. 走新的 onCreateView()

所以在这次场景里:

  • 确认解绑 Dialog 对象本身没有换
  • 它的普通成员字段 onConfirm 也就还在

这是否说明当前实现完全安全

不能这么下结论。

这次只能说明:

在“宿主 Activity 因旋转重建,但确认解绑 Dialog 实例被 FragmentManager 保留”的这条路径里,onConfirm 不会丢。

但下面这些场景仍然可能出问题:

  1. 进程被系统杀掉后恢复
    – lambda 不会被序列化
    onConfirm 仍然会丢
  2. 开发者选项开启“不保留活动”
    – 更容易触发真正的实例重建
  3. 某些恢复路径导致 Fragment 不是保留旧实例,而是重新 new
    – 此时 onConfirm 仍然不会回来

因此,“这次横竖屏没问题”不代表“这种写法在所有恢复场景都没问题”。


工程判断

当前这个问题不需要因为“旋转必现异常”而阻塞,因为日志已经证明:在这条实际复现路径里,横竖屏后解绑仍会正常执行。

但从设计上,仍然有改进空间。

更稳妥的写法仍然是:

  • 不依赖确认弹窗内存中的 lambda
  • 改为通过 arguments 传入必要参数,例如:
  • providerName
  • accountId
  • authFeature
  • 在 Dialog 内直接执行 logout action

或者:

  • 使用 FragmentResult 回传“用户确认解绑”
  • 宿主根据可恢复数据自行处理

这样才能同时覆盖:

  • 配置变更
  • 进程重建
  • 更严格的系统恢复场景

这次排查的经验

1. 生命周期问题不能只靠静态推断,必要时一定要打日志

这个问题如果只看代码,很容易得到一个“理论上应该丢 listener”的答案。
但日志证明:

  • Activity 的确重建了
  • Fragment 实例却被保留了

这类问题不用日志,很容易误判。

2. onDestroyView() 和 onDestroy() 不是一回事

这次正好是一个非常好的案例:

  • onDestroyView() 发生了
  • onDestroy() 没有立即发生

说明:

  • View 被销毁了
  • Fragment 实例还活着

在分析“成员字段是否会丢失”时,这个区别非常关键。


总结

这次关于“确认解绑 Dialog 在横竖屏切换下的行为”的排查,最终结论是:

  1. 宿主主界面 Activity 在旋转时确实重建
  2. 确认解绑 Dialog 实例被 FragmentManager 保留
  3. 旋转时只重建了 Dialog 的 View
  4. 因为实例没换,内存中的 onConfirm 仍然存在
  5. 所以横屏后点击“解绑”,logout 依然会执行

也就是说:

“横竖屏后解绑仍然生效”不是偶然,也不是另一个新 Dialog 被重新设置了 listener,而是 AndroidX FragmentManager 在配置变更时保留了原 Fragment 实例。

不过,这个结论只覆盖当前这条恢复路径;若要从工程设计上完全稳妥,仍建议把解绑动作改为可恢复的参数驱动,而不是依赖临时 lambda。


评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注