第 8 周进入弹窗和提示组件。这个主题看起来比 RecyclerView 简单:不就是 Toast、Dialog、PopupWindow、BottomSheet 吗?但真实项目里,弹窗最容易从"提示用户"变成"打扰用户",从"确认一下"变成"窗口泄漏"。
一、相关资料
| 资料 | 本文采用的查证点 |
|---|---|
| Android Developers:Toasts | Toast 是短时反馈,不适合承载关键操作 |
| Android Developers:Dialogs | Dialog 适合需要用户决策的强打断场景 |
Android Developers:PopupWindow API |
PopupWindow 依附锚点展示,需处理外部点击、焦点和窗口释放 |
Android Developers:DialogFragment API |
DialogFragment 由 FragmentManager 托管,更适合生命周期管理 |
| Material Design:Snackbar | Snackbar 是和当前页面动作相关的短提示,可带一个 action |
| Material Design:Bottom sheets | 底部弹窗适合承载一组弱打断操作 |
Material Components:Snackbar / BottomSheetDialogFragment API |
Demo 中的 Material 组件使用边界 |
二、先分清楚:Toast、Snackbar、Dialog 不是同一种提示
弹窗优化的第一步不是写工具类,而是分清场景。
| 组件 | 打扰强度 | 适合场景 | 不适合场景 |
|---|---|---|---|
Toast |
很低 | "已复制""已保存"这类短反馈 | 需要确认、撤销、重试的操作 |
Snackbar |
低到中 | 当前页面操作后的反馈,可带一个 action | 强制用户决策、复杂表单 |
Dialog |
高 | 删除确认、权限解释、关键业务决策 | 频繁营销、低优先级提示 |
PopupWindow |
中 | 锚点菜单、局部浮层、更多操作 | 全局强提醒、复杂生命周期场景 |
BottomSheet |
中 | 一组操作、筛选、分享、更多菜单 | 必须立即阻断流程的风险决策 |
Demo 里最轻的提示是 Toast:
kotlin
private fun showToastPrompt() {
Toast.makeText(this, "Toast:轻量、短时、不可承载关键决策", Toast.LENGTH_SHORT).show()
updateState("Toast 已展示:适合低优先级反馈,不适合需要用户确认的业务。")
}
Toast 不依赖当前布局里的某个 View,也不提供用户操作入口。它的价值是"告诉你一下"。如果一个操作失败后需要用户重试,Toast 就不够了。

Snackbar 则更适合和页面操作绑定:
kotlin
private fun showSnackbarPrompt() {
Snackbar.make(binding.root, "Snackbar:底部短提示,可带一个 Action", Snackbar.LENGTH_LONG)
.setAnchorView(binding.btnSnackbar)
.setAction("撤销") {
updateState("Snackbar Action 被点击:适合撤销、重试这类低打扰操作。")
}
.show()
}

setAction() 让它能承载一个轻操作,比如撤销、重试、查看。setAnchorView() 可以避免它挡住底部按钮、导航栏或悬浮按钮。
实践
内容社区、电商、本地生活类 App 通常会把提示分级:低优先级成功反馈用 Toast 或静默状态;可撤销操作用 Snackbar;涉及钱、隐私、删除、退出登录才用 Dialog。否则用户会被弹窗训练到"看到就关"。
相关技术
Toast.makeText()、Snackbar.make()、setAction()、setAnchorView()、提示优先级、打扰控制。
三、AlertDialog:强打断必须防重复,也必须看 Activity 状态
裸 AlertDialog 最大的问题不是不会弹,而是"什么时候不该弹"。比如网络回调回来时 Activity 已经销毁,你还拿旧 Activity 去 show(),就可能触发 BadTokenException;Activity 退出后 Dialog 还没 dismiss,也可能产生 WindowLeaked。
Demo 里先做基础保护:
kotlin
private fun showPlainDialog() {
if (!canShowWindow()) {
updateState("Activity 已结束或正在结束:禁止继续 show Dialog,避免 BadTokenException。")
return
}
if (plainDialog?.isShowing == true) {
showDuplicateSnackbar("裸 Dialog 已经展示,重复点击被拦截。")
return
}
plainDialog = AlertDialog.Builder(this)
.setTitle("AlertDialog:强打断确认")
.setMessage("裸 Dialog 适合短生命周期内的确认操作,但页面销毁、旋转或异步回调后继续 show,容易产生窗口泄漏或 token 异常。")
.setPositiveButton("确认") { _, _ -> updateState("AlertDialog 确认:关键决策已被用户处理。") }
.setNegativeButton("取消") { _, _ -> updateState("AlertDialog 取消:不修改业务状态。") }
.create()
plainDialog?.setOnDismissListener { plainDialog = null }
plainDialog?.show()
}
canShowWindow() 很简单:
kotlin
private fun canShowWindow(): Boolean {
return !isFinishing && !isDestroyed
}
这不是万能生命周期方案,但至少能防掉最典型的"页面结束后继续弹窗"。更稳定的方案是把弹窗放进 DialogFragment,让 FragmentManager 参与管理。
实践
金融支付、订单取消、地址删除这类操作需要强确认,但成熟团队也不会让多个确认框叠在一起。常见做法是按业务 key 防重复,同一时刻只展示一个同类型强弹窗,异步回调回来前先检查页面状态。
相关技术
AlertDialog、WindowLeaked、BadTokenException、isFinishing、isDestroyed、防重复弹窗、setOnDismissListener()。
四、DialogFragment:不是更高级的 Dialog,而是生命周期更稳的 Dialog
DialogFragment 的价值不是"写起来更酷",而是它把弹窗交给 FragmentManager 托管。配置变化、状态保存、重复 show、结果回传,都比直接拿一个 Dialog 引用更容易管理。

本周 Demo 的展示入口是:
kotlin
private fun showDialogFragment() {
val shown = Week8ConfirmDialogFragment.showIfAllowed(supportFragmentManager)
if (shown) {
updateState("DialogFragment 已展示:由 FragmentManager 托管,适合更稳定地处理生命周期。")
} else {
showDuplicateSnackbar("DialogFragment 已存在或状态已保存,本次 show 被拦截。")
}
}
showIfAllowed() 做两件事:
kotlin
fun showIfAllowed(fragmentManager: FragmentManager): Boolean {
if (fragmentManager.isStateSaved || fragmentManager.findFragmentByTag(TAG) != null) {
return false
}
Week8ConfirmDialogFragment().show(fragmentManager, TAG)
return true
}
isStateSaved 表示 FragmentManager 已经保存状态,此时强行提交 show 操作容易出现状态丢失。findFragmentByTag(TAG) 用来防重复。
结果通过 Fragment Result 回传:
less
parentFragmentManager.setFragmentResult(
REQUEST_KEY,
bundleOf(RESULT_KEY to "用户确认:DialogFragment 回传结果成功")
)
页面层监听结果:
scss
supportFragmentManager.setFragmentResultListener(
Week8ConfirmDialogFragment.REQUEST_KEY,
this
) { _, bundle ->
updateState(bundle.getString(Week8ConfirmDialogFragment.RESULT_KEY).orEmpty())
}
实践
登录过期、实名认证、权限解释、风险确认这类弹窗通常需要稳定的生命周期和结果回传。成熟团队更倾向让页面只接收"用户确认 / 取消 / 关闭"的结果,而不是让 Dialog 自己直接修改复杂业务状态。
相关技术
DialogFragment、FragmentManager、isStateSaved、findFragmentByTag()、Fragment Result API、配置变化、状态保存。
五、PopupWindow:适合锚点菜单,但要主动释放
PopupWindow 常用于锚点弹出:更多菜单、筛选项、局部操作提示。它的问题是:它不是 Fragment,也不是 Activity 的一部分;你要自己处理焦点、外部点击、背景和释放。

Demo 里每次展示前先关闭旧 Popup:
kotlin
private fun showPopupWindow(anchor: View) {
popupWindow?.dismiss()
val popupBinding = PopupWeek8AnchorMenuBinding.inflate(LayoutInflater.from(this))
val popup = PopupWindow(
popupBinding.root,
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
true
)
popup.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
popup.isOutsideTouchable = true
popup.setOnDismissListener {
popupWindow = null
updateState("PopupWindow 已关闭:锚点菜单不再持有页面窗口。")
}
popupWindow = popup
popup.showAsDropDown(anchor, 0, dp(8))
}
focusable = true 加上背景和 isOutsideTouchable = true,才能更稳定地处理外部点击关闭。onDestroy() 里也要兜底:
kotlin
override fun onDestroy() {
popupWindow?.dismiss()
popupWindow = null
plainDialog?.dismiss()
plainDialog = null
dialogQueue.clear()
super.onDestroy()
}
实践
商品筛选、评论更多、消息会话操作常用局部浮层。成熟团队会把它当成"依附页面的临时窗口",不让它跨页面存活,也不会把复杂业务流程塞进 PopupWindow。
相关技术
PopupWindow、showAsDropDown()、isOutsideTouchable、focusable、setBackgroundDrawable()、setOnDismissListener()、锚点菜单。
六、BottomSheetDialogFragment:弱打断的一组操作
底部弹窗适合承载一组相关操作,比如分享、筛选、更多菜单、协议说明。它比居中 Dialog 打扰弱,但比 Snackbar 承载能力强。

Demo 用 BottomSheetDialogFragment:
kotlin
private fun showBottomSheet() {
val shown = Week8ActionBottomSheetFragment.showIfAllowed(supportFragmentManager)
if (shown) {
updateState("BottomSheetDialogFragment 已展示:适合一组弱打断操作。")
} else {
showDuplicateSnackbar("BottomSheet 已存在或状态已保存,本次 show 被拦截。")
}
}
它内部也清理 ViewBinding:
kotlin
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
这点很重要。DialogFragment / BottomSheetDialogFragment 的 View 生命周期和 Fragment 对象生命周期不是一回事。View 销毁后继续持有 binding,就容易让旧 View 泄漏。
实践
电商商品详情里的规格选择、外卖筛选、内容分享面板都很适合 BottomSheet。但如果是支付确认、隐私授权、风险提示,底部弹窗的弱打断可能不够,需要升级为明确确认 Dialog。
相关技术清单
BottomSheetDialogFragment、底部弹窗、onCreateView()、onDestroyView()、ViewBinding 清理、弱打断操作组。
七、全局弹窗队列:真正难的是"谁先弹,谁不弹"
真实 App 不会只有一个弹窗来源。版本升级、登录过期、优惠券、隐私授权、活动弹窗、满意度调查可能同时到达。如果没有统一管理,就会出现弹窗叠弹、重复弹、低优先级打断高优先级。
本周 Demo 做了一个最小队列:
kotlin
data class Week8Prompt(
val key: String,
val priority: Int,
val title: String,
val message: String,
val positiveText: String = "知道了"
)
class Week8DialogQueue {
private val pending = mutableListOf<Week8Prompt>()
private var activeKey: String? = null
fun enqueue(prompt: Week8Prompt): Boolean {
if (activeKey == prompt.key || pending.any { it.key == prompt.key }) {
return false
}
pending.add(prompt)
pending.sortWith(compareByDescending<Week8Prompt> { it.priority }.thenBy { it.key })
return true
}
}
它只做三件事:
- 同 key 去重。
- 按优先级排序。
- 同一时间只展示一个。
展示完一个再弹下一个:
scss
dialog.setOnDismissListener {
dialogQueue.finish(prompt)
binding.tvQueueState.text = dialogQueue.snapshot()
binding.root.post { showNextQueuedDialog() }
}
这只是最小 Demo。真实项目还会继续加:页面白名单、频控、用户分群、曝光埋点、服务端配置、冷启动保护、后台前台判断。

实践
大型 App 常见的"全局弹窗治理"不是一个 Dialog 工具类,而是一套策略:强制升级 > 登录失效 > 权限解释 > 交易确认 > 运营活动 > 调研问卷。越靠后的弹窗越应该能延后、合并、降级或取消。
相关技术清单
弹窗队列、弹窗优先级、同 key 去重、串行展示、频控、曝光埋点、打扰控制。
八、相关技术
| 技术 | 它是什么 | Demo 落点 | 真实项目价值 | 常见坑 |
|---|---|---|---|---|
Toast |
系统短提示 | showToastPrompt() |
低成本反馈"已复制/已保存" | 用它承载关键错误或确认 |
Snackbar |
Material 底部提示,可带 action | showSnackbarPrompt() |
撤销、重试、轻操作反馈 | action 太复杂,或遮挡底部按钮 |
AlertDialog |
强打断确认弹窗 | showPlainDialog() |
删除、退出、风险确认 | Activity 销毁后继续 show,重复弹 |
DialogFragment |
Fragment 托管的 Dialog | Week8ConfirmDialogFragment |
生命周期更稳、结果回传更清晰 | isStateSaved 后强行 show |
PopupWindow |
依附锚点的浮动窗口 | showPopupWindow() |
局部菜单、更多操作 | 不设置背景/焦点导致外部点击异常,不释放导致泄漏 |
BottomSheetDialogFragment |
Material 底部弹窗 | Week8ActionBottomSheetFragment |
分享、筛选、操作组 | ViewBinding 不清理,或用弱打断承载强确认 |
FragmentResult |
Fragment 间结果回传机制 | confirm / bottom sheet result listener | 降低 Dialog 直接操作页面状态的耦合 | key 混乱、结果无人消费 |
WindowLeaked |
窗口未随 Activity 销毁释放 | onDestroy() dismiss 兜底 |
防止退出页面后窗口泄漏 | 长生命周期对象持有 Activity Dialog |
BadTokenException |
使用无效窗口 token show 弹窗 | canShowWindow() |
避免异步回调晚到后崩溃 | 不检查 Activity 状态 |
| 防重复弹窗 | 同一业务弹窗只允许一个实例 | plainDialog?.isShowing、fragment tag、queue key |
防止叠弹和重复打扰 | 只防 UI,不防业务 key |
| 弹窗队列 | 多个弹窗按策略串行展示 | Week8DialogQueue |
统一治理升级、登录、营销、调研 | 没有优先级、频控和页面白名单 |
isStateSaved |
FragmentManager 状态已保存标记 | showIfAllowed() |
避免状态保存后提交弹窗事务 | 为了省事改用 allowStateLoss 滥弹 |