定位与适用范围
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() 时发生。
本次场景包含两类异步边界:
- 倒计时或延迟任务到点后才执行
openStrongGuideDialog()。 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 != null 或 fragment.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 回调。
优先使用 lifecycleScope、repeatOnLifecycle() 等生命周期感知机制,让任务随宿主自动停止。取消任务既减少崩溃窗口,也避免退出后继续修改计数、播放状态或页面数据。
第三层:在最终框架边界做崩溃兜底
入口检查与任务取消仍可能存在极小时间窗。对于必须兜住的 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 等异步边界。
- 区分“任务提交时间”和“任务实际执行时间”。
- 在执行点记录
isFinishing、isDestroyed、Lifecycle、isStateSaved和 attached 状态。 - 提交前拦截失效宿主,并在退出时取消待执行任务。
- 在最终展示边界只捕获明确的
BadTokenException,同时上报。 - 展示计数、暂停播放等副作用只在展示成功后执行。
- 使用快速返回、切后台、旋转和重复触发验证生命周期边界。
- 确认修复后没有幽灵弹窗和业务状态错乱。