定位与适用范围

WindowManager.BadTokenException 表示应用尝试添加一个 Window 时,系统判定它使用的 Window token 无效。它常见于 Dialog、DialogFragment、PopupWindow 等延迟展示场景,尤其容易出现在倒计时、Handler、协程或网络回调与 Activity 退出同时发生时。

本文来自一次 NadRewardConversionGuideDialog.onStart() 崩溃排查。现有证据能确认“异步弹窗事务与 Activity 生命周期发生时序竞态”,但仅凭单份堆栈不能确定是首次倒计时还是二次延迟任务触发。

异常含义与判断边界

典型堆栈如下:

android.view.WindowManager$BadTokenException:
Unable to add window -- token android.os.BinderProxy@... is not valid;
is your activity running?
 
at android.view.ViewRootImpl.setView(...)
at android.view.WindowManagerGlobal.addView(...)
at android.app.Dialog.show(...)
at androidx.fragment.app.DialogFragment.onStart(...)
at NadRewardConversionGuideDialog.onStart(...)
at androidx.fragment.app.FragmentManager.execPendingActions(...)
at android.os.Handler.dispatchMessage(...)

Android 中的 Dialog 不是普通 View,而是一个需要通过 WindowManager.addView() 添加的独立 Window。系统要求它携带一个仍然有效的宿主 Window token,用来证明这个窗口可以挂到当前 Activity 上。

这里需要分清四个概念:

概念表示什么能否证明可以显示 Dialog
Activity 对象非空Java/Kotlin 对象和引用仍存在不能
Fragment 仍被引用Fragment 或待执行事务仍在内存中不能
Activity 生命周期有效Activity 仍处于适合展示 UI 的状态部分能,需要继续检查 Window
Window token 有效系统仍允许向宿主 Window 添加内容

Activity 调用 finish() 后,对象不会立刻被 GC。它可能仍在内存中并继续执行部分生命周期与队列任务,但对应 Window 已经开始退出,token 可能先于对象回收失效。因此:

对象仍在内存中,不等于它代表的 Android 组件仍然可以接收 UI 操作。

WeakReference<Context> 只能减少长期持有 Context 的风险,不能证明 Context 对应的 Window token 有效。

本次崩溃的时序链路

NadRewardConversionGuideDialog.onStart() 中首先执行:

override fun onStart() {
    super.onStart()
    // 设置窗口宽高和动画
}

堆栈虽然落在业务类的 onStart(),但真正失败的是 super.onStart() 内部的 Dialog.show()。异常发生在后续 WindowManager.addView(),与业务代码设置宽高无关。

业务入口调用:

strongGuidDialog?.show(supportFragmentManager, null)
strongGuideDialogShowCount++
pauseVideoPlayAndCountDown()
pauseLottie()

DialogFragment.show() 默认通过 Fragment 事务提交展示任务。提交完成时,Dialog 不一定已经执行 show();真正展示可能在主线程稍后执行 FragmentManager.execPendingActions() 时发生。

本次场景包含两类异步边界:

  1. 倒计时或延迟任务到点后才执行 openStrongGuideDialog()
  2. DialogFragment.show() 提交事务后,FragmentManager 稍后才驱动 onStart()Dialog.show()

即使所有代码都在主线程,这仍然属于时序竞态。它竞争的不是两个线程对同一变量的读写,而是“弹窗事务执行”和“Activity 退出”两个事件的先后顺序:

  • Dialog 先完成 addView():正常显示。
  • Activity 先让 Window token 失效:抛出 BadTokenException

这也解释了为什么此类崩溃通常概率较低,并集中在倒计时临界点快速返回、切后台、页面跳转或旋转屏幕时。

同类型问题的排查流程

排查目标不是简单找到 Dialog.show(),而是还原:谁在什么时机,使用哪个宿主,尝试添加什么 Window。

1. 从 WindowManager 向上定位展示对象

先根据堆栈识别需要 token 的对象:

常见堆栈优先反查
Dialog.show()Dialog、DialogFragment
PopupWindow.invokePopup()PopupWindow
WindowManager.addView()悬浮窗、自定义 Window、系统级 View
自定义 Toast/View是否直接使用 WindowManager

不要因为堆栈出现业务 onStart() 就直接修改该方法后半段。先找到最靠近 WindowManager.addView() 的实际展示调用。

2. 找到业务 show 入口及其副作用

搜索直接调用:

.show(
.showNow(
showAtLocation(
showAsDropDown(
WindowManager.addView(

同时检查调用后是否无条件修改业务状态。本次代码即使 show() 因状态检查提前返回,仍会增加展示次数并暂停播放,这可能把一次“未展示”记录成“已展示”。更稳妥的 API 应返回展示是否真正成功,业务副作用只在成功后执行。

3. 标出展示前的所有异步边界

常见异步来源包括:

  • Handler.post()postDelayed()View.post()
  • CountDownTimer、自定义倒计时回调;
  • 协程 delay()、Flow 收集;
  • RxJava 定时器或订阅回调;
  • 网络、下载、播放器和 EventBus 回调;
  • Fragment 事务的 commit() / DialogFragment.show()

关键问题不是“安排任务时 Activity 是否有效”,而是“任务真正执行和添加 Window 时 Activity 是否仍有效”。

4. 在执行点记录完整宿主状态

建议在低概率问题中加入一次结构化日志:

private fun logDialogHostState(reason: String) {
    val manager = supportFragmentManager
    Log.w(
        "DialogLifecycle",
        "reason=$reason, " +
            "finishing=$isFinishing, " +
            "destroyed=$isDestroyed, " +
            "lifecycle=${lifecycle.currentState}, " +
            "stateSaved=${manager.isStateSaved}, " +
            "attached=${window.decorView.isAttachedToWindow}, " +
            "hasFocus=${window.decorView.hasWindowFocus()}"
    )
}

日志至少要同时记录:触发来源、Activity 生命周期、FragmentManager 状态、View 是否 attached,以及本次展示的唯一标识。只有 context != nullfragment.isAdded 不能完成判断。

5. 构造生命周期边界进行复现

围绕弹窗触发时间主动执行:

  • 连续快速返回;
  • Home 键切后台后马上返回;
  • 旋转屏幕或触发配置变化;
  • 弹窗到点前启动另一个 Activity;
  • 同时触发页面关闭与异步回调;
  • 开启“开发者选项 -> 不保留活动”扩大生命周期问题。

验证目标不只是不崩溃,还包括:没有退出后弹出的幽灵 Dialog、没有错误增加展示次数、播放器和倒计时没有停在错误状态。

分层修复策略

稳妥处理通常需要三层,而不是只在一个地方堆 try/catch

第一层:提交前验证宿主状态

private fun canShowDialog(): Boolean {
    if (isFinishing || isDestroyed) return false
    if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return false
    if (supportFragmentManager.isStateSaved) return false
    return window.decorView.isAttachedToWindow
}

检查放在真正调用 show() 的位置,不能只在创建倒计时或注册回调时检查。RESUMED 是较保守的展示条件;业务确实允许在 STARTED 展示时,需要结合遮挡、后台弹窗和宿主规范单独评估。

第二层:退出页面时取消待执行任务

在与业务语义匹配的生命周期中移除:

  • Handler 的 Runnable;
  • 倒计时监听;
  • 协程 Job 或 Flow 收集;
  • RxJava Disposable;
  • 网络、下载、播放器和 EventBus 回调。

优先使用 lifecycleScoperepeatOnLifecycle() 等生命周期感知机制,让任务随宿主自动停止。取消任务既减少崩溃窗口,也避免退出后继续修改计数、播放状态或页面数据。

第三层:在最终框架边界做崩溃兜底

入口检查与任务取消仍可能存在极小时间窗。对于必须兜住的 DialogFragment,可以在实际调用父类展示逻辑的位置捕获明确异常并上报:

override fun onStart() {
    try {
        super.onStart()
    } catch (error: WindowManager.BadTokenException) {
        reportDialogShowFailure(error)
        runCatching { dismissAllowingStateLoss() }
        return
    }
 
    // 只有真正显示成功后,才继续设置 Window 和启动动画
}

这层是防崩溃兜底,不是根因修复。只捕获预期的 BadTokenException,同时保留监控,避免把其他初始化异常一起吞掉。

另一种方式是在宿主状态检查后使用 showNow(),让 Fragment 事务同步执行,使异常留在当前可捕获调用栈内。但 showNow() 不能在 FragmentManager 正在执行其他事务时调用,也不能加入返回栈,不能不评估约束就全局替换。

让展示结果控制业务副作用

建议把展示封装为显式结果:

private fun showGuideDialogSafely(dialog: DialogFragment): Boolean {
    if (!canShowDialog()) return false
 
    return try {
        dialog.showNow(supportFragmentManager, "reward_guide")
        true
    } catch (error: WindowManager.BadTokenException) {
        reportDialogShowFailure(error)
        false
    } catch (error: IllegalStateException) {
        reportDialogShowFailure(error)
        false
    }
}
 
if (showGuideDialogSafely(dialog)) {
    strongGuideDialogShowCount++
    pauseVideoPlayAndCountDown()
    pauseLottie()
}

具体项目如果不能使用 showNow(),仍应让封装层返回“已提交”“已显示”或“失败”的明确状态,避免调用方假定 show() 一定成功。

容易误判的修复

处理方式为什么不充分
context != null只能证明对象存在,不能证明 Window token 有效
使用 WeakReference<Context>解决持有关系,不解决窗口生命周期
只检查 fragment.isAdded描述 FragmentManager 关系,不代表宿主 Window 可用
只检查 manager.isStateSaved防止状态保存后的事务异常,不覆盖 token 失效
使用 commitAllowingStateLoss()解决 state loss,不解决 BadTokenException
在异步 show() 外层写 try/catch只能捕获提交阶段异常,捕获不到稍后 onStart() 的异常
捕获所有 Exception 后忽略会隐藏布局、资源、状态机等其他真实问题

排查与处理 Checklist

遇到 BadTokenException 时按顺序确认:

  • WindowManager.addView() 向上确认是 Dialog、PopupWindow 还是其他 Window。
  • 找到业务 show() 入口和调用后的计数、暂停、状态修改。
  • 标出 Handler、倒计时、协程、Rx、网络回调和 Fragment commit 等异步边界。
  • 区分“任务提交时间”和“任务实际执行时间”。
  • 在执行点记录 isFinishingisDestroyed、Lifecycle、isStateSaved 和 attached 状态。
  • 提交前拦截失效宿主,并在退出时取消待执行任务。
  • 在最终展示边界只捕获明确的 BadTokenException,同时上报。
  • 展示计数、暂停播放等副作用只在展示成功后执行。
  • 使用快速返回、切后台、旋转和重复触发验证生命周期边界。
  • 确认修复后没有幽灵弹窗和业务状态错乱。

相关笔记