Dialog 这个东西,属于那种"看着简单、写着顺手、上线出事"的典型。我第一次给一个电商类应用做下单确认弹窗的时候,觉得这不就是继承一个 Dialog、setContentView、show 三步走的事?结果那版代码在主力测试机上一切正常,到了折叠屏和一台老机器的分屏模式上,弹窗宽度直接缩成了一条竖线,圆角变成直角外加一圈黑边,点外面也关不掉。后来翻了半天源码才搞明白,问题根本不在布局写错,而在于 Dialog 拥有的是独立于 Activity 的一套 Window,你在 Activity 里习惯的尺寸规则、主题继承、软键盘行为,在这块独立窗口里全部要重新捋一遍。自定义弹窗功能真正难的地方从来不是"弹出个框",而是弹出之后它在各种屏幕、各种状态下都还保持体面。这篇就按我实际做过的几版弹窗组件的顺序,把选型、窗口参数、尺寸计算、真机坑、动画交互、组件化这几块拆开讲,适合已经会写基本 Dialog、但被细节反复折磨过的 Android 开发者,也适合刚接手老项目要重构弹窗体系的人。
1. 先想清楚这个框该用哪个容器承载
很多人写自定义弹窗的第一反应是Dialog,写完之后发现要保存状态、要跟随生命周期、要在旋转后不丢失,于是开始打补丁,补到最后代码里一半逻辑在跟框架对抗。所以在动手写布局之前,先把容器选对,后面能省掉至少一半的返工。
1.1 Dialog、DialogFragment、PopupWindow 三者的真实边界
我的划分标准很简单:它是一个独立于页面内容的浮层,还是一个依附于某个 View 的附属物。凡是"确认删除""版本更新""权限说明"这类与页面结构无关、需要独立焦点、需要自己处理返回键的,都属于 Dialog 的领域;凡是"点击某个按钮下方弹出一个小气泡菜单""长按某条 item 弹出操作项"这类必须锚定在某一块屏幕坐标上的,交给PopupWindow。这两者的区别不是审美问题,而是窗口层级问题——Dialog 是 Window 级别,PopupWindow 只是挂在当前 Window 上的一个 View,它能拿到锚点 View 的位置,但也因此不能脱离宿主窗口独立存在。
至于DialogFragment,它不是第四种窗口,它是 Dialog 的宿主壳。真正显示的还是一个 Dialog,只不过这个 Dialog 由 FragmentManager 托管,因此获得了 Fragment 的生命周期回调、状态保存能力和旋转重建能力。这个区别在简单场景下感觉不出来,但一旦你的弹窗里有一个EditText、或者有一个需要保持的滚动位置,普通 Dialog 在配置变更时会直接把内容重置,而 DialogFragment 可以把关键状态塞进onSaveInstanceState里带回来。
1.2 我把绝大多数业务弹窗迁移到 DialogFragment 的原因
迁移的触发点是一次线上的偶发崩溃。某个页面在异步请求回调里show了一个普通 Dialog,用户在请求回来之前切走了页面,Activity已经destroy,此时show就抛出了WindowManager.BadTokenException。这个问题用普通 Dialog 只能靠各种isFinishing判断去堵,而 DialogFragment 天然能感知宿主状态,isAdded、isStateSaved这些判断是框架给的,不是自己瞎猜的。
还有一个更隐蔽的理由:普通 Dialog 持有一个 Activity 的 Context,如果它被一个静态变量或者一个生命周期更长的回调持有,就会把整个 Activity 连同它的 View 树一起拖住。DialogFragment 在onDestroyView之后可以主动把内部引用置空,碎片化地控制泄漏面。我在一个老项目里做过统计,把二十多个普通 Dialog 改成 DialogFragment 之后,LeakCanary报出的窗口相关泄漏从每周十几条降到了基本为零。
1.3 一张对照表把选型钉死
下面这张表是我给团队新人的速查表,实际写代码时遇到犹豫的弹窗,对着看一遍基本就有答案了:
| 容器 | 窗口层级 | 生命周期托管 | 状态保存 | 适合的场景 | 主要代价 |
|---|---|---|---|---|---|
| Dialog | 独立 Window | 无,靠调用方 | 不支持 | 极简提示、一次性浮层 | 易泄漏、易 BadToken |
| DialogFragment | 独立 Window | FragmentManager | 支持 | 业务弹窗、含输入框 | 代码稍多、需处理状态恢复 |
| PopupWindow | 宿主 Window 内 | 无 | 不支持 | 锚点菜单、气泡提示 | 位置计算繁琐、易被遮挡 |
| BottomSheet 系列 | 独立 Window | 视实现而定 | 视实现而定 | 列表选择、操作面板 | 依赖设计库、手势冲突 |
选型的核心其实就一句话:只要这个弹窗里可能有用户输入、可能停留超过几秒、可能跨配置变更存活,就走 DialogFragment。
2. 从零搭一个能复用的弹窗基类
直接在每个业务里写Dialog(this).apply { ... }是维护灾难的开始,因为窗口参数会散落在十几个文件里,改一个圆角要全项目搜一遍。我现在的做法是先抽一个基类,把窗口层面的东西一次性收口,业务只负责布局和逻辑。
2.1 布局文件里那三个必须设置的属性
自定义弹窗布局最常见的问题是"圆角不生效"。原因几乎都是根布局没有把背景画对。一个标准的弹窗根布局应该长这样:
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:background="@drawable/bg_dialog_round"> <!-- 内容 --> </LinearLayout>对应 drawable 用一个 shape,圆角半径写在里面:
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <solid android:color="?attr/colorSurface" /> <corners android:radius="16dp" /> </shape>这里有三点容易踩:第一,颜色不要硬编码#FFFFFF,用主题属性,否则暗黑模式下弹窗会变成一块刺眼的白板;第二,如果弹窗里有RecyclerView或者内部有背景色的子 View,它们会把这个圆角盖住,需要在父布局上加android:clipToOutline="true",或者给子 View 也做对应圆角;第三,根布局用wrap_content高度是常规做法,但如果内容里有列表,就得给一个最大高度,否则内容一多弹窗会顶到屏幕外面。
2.2 用 style 统一收口窗口参数,别在代码里硬塞
窗口背景透明、无标题、是否浮层、动画样式这些,全都可以写在 style 里,然后在onCreateDialog里把 theme 传进去。这样做的价值在于:同一个基类可以派生多套外观,比如普通弹窗、全屏弹窗、底部弹窗各自一个 style,代码层面完全不用改。
<style name="Theme.App.Dialog.Base" parent="Theme.AppCompat.Dialog"> <item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowIsFloating">true</item> <item name="android:windowNoTitle">true</item> <item name="android:backgroundDimEnabled">true</item> <item name="android:backgroundDimAmount">0.5</item> <item name="android:windowAnimationStyle">@style/Anim.App.Dialog</item> </style>windowBackground设为透明是关键一步。系统默认的 Dialog 主题自带一层不透明背景,你在布局里画的圆角外面会多出一圈方形底,切换成透明之后,圆角才能真正"透"出来。而backgroundDimEnabled控制的是背后那一层遮罩,backgroundDimAmount取值 0 到 1,0.5 是我用得最舒服的一个值,太深会让人有压迫感,太浅弹窗和背景糊在一起分不清层次。
2.3 基类里只做三件事
基类我严格控制职责,只做三件事:创建 Dialog 并挂主题、在 onStart 里调整窗口尺寸、暴露一个可覆写的布局 id。其他一律交给子类。
abstract class BaseDialogFragment : DialogFragment() { protected open val themeResId: Int = R.style.Theme_App_Dialog_Base protected open val widthRatio: Float = 0.82f protected open val maxWidthDp: Int = 400 abstract fun inflateContent(inflater: LayoutInflater, parent: ViewGroup?): View override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return Dialog(requireContext(), themeResId) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = inflateContent(inflater, container) override fun onStart() { super.onStart() val window = dialog?.window ?: return window.setLayout(resolveWidth(), ViewGroup.LayoutParams.WRAP_CONTENT) window.setGravity(Gravity.CENTER) } private fun resolveWidth(): Int { val metrics = resources.displayMetrics val byRatio = (metrics.widthPixels * widthRatio).toInt() val byMax = (maxWidthDp * metrics.density).toInt() return minOf(byRatio, byMax) } }这里有个细节值得说清楚:宽度计算放在onStart而不是onCreateDialog,是因为在onCreateDialog阶段窗口还没 attach 到 WindowManager,此时setLayout会被后续的布局流程覆盖掉。这个问题我第一次遇到时调了快两个小时,代码没报错、日志也没输出,就是宽度不生效,最后靠打断点看Window的 attach 时机才定位到。
3. 宽度、高度与被压缩的真实原因
弹窗"宽度不对"是我被问得最多的一类问题,而且提问的人往往已经把setLayout写了,还是会遇到宽度为一条线、宽度超出屏幕、宽度在横屏下变形。这些现象背后其实是同一套布局规则在起作用。
3.1 wrap_content 在 Dialog 里为什么会失效
在 Activity 里写wrap_content,父容器会按可用空间去测量子 View;但 Dialog 的根布局在WindowIsFloating为 true 时,系统的PhoneWindow会套一层DecorView,并在测量时给一个相当大的可用空间上限。结果就是你的wrap_content内容如果本身有横向拉伸的 View(比如一个match_parent的EditText,或者一段长文本),它就会把整个弹窗撑到接近屏幕宽度,看起来像是"没生效"。
反过来,如果根布局的子 View 里有一堆wrap_content嵌套,测量过程拿到的宽度又可能远小于预期,于是出现了"宽度只剩几十像素"的现象——这种情况在内层用了LinearLayout+weight的时候特别常见,因为父级宽度不确定,weight分配出来的结果就不确定。
我现在的原则是:根布局宽度永远写match_parent,实际宽度由代码里的setLayout决定。这样测量过程有一个明确的、和屏幕相关的基准,weight、margin这些才能算得准。业务布局内部的宽度该match_parent就match_parent,不需要为了"看起来自适应"去写wrap_content。
3.2 三种宽度算法的取舍
代码里算宽度,我试过三种写法,各有适用面:
| 算法 | 写法要点 | 优点 | 问题 |
|---|---|---|---|
| 屏幕比例 | 屏宽 × 0.8 | 视觉统一、适配简单 | 平板上会过宽 |
| 固定 dp 上限 | 直接写 320dp | 大屏表现稳定 | 小屏上可能贴边 |
| 比例 + 上限取小 | min(比例宽, 上限dp) | 兼顾两头 | 代码多一行 |
第三种是我最终采用的方案,也就是上面基类里resolveWidth的写法。minOf那一行看着不起眼,但它解决的是折叠屏展开态和大屏平板上的观感问题——2.5 倍缩放的那种宽度撑满全屏的弹窗,体验非常糟糕。
还有一个更稳的取宽度方式,是在WindowManager层面拿到当前窗口的边界,而不是拿displayMetrics:
private fun currentWindowWidthPx(): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val wm = requireContext().getSystemService(WindowManager::class.java) wm.currentWindowMetrics.bounds.width() } else { @Suppress("DEPRECATION") requireContext().resources.displayMetrics.widthPixels } }为什么要换?因为在分屏、自由窗口、折叠屏半展开这些形态下,displayMetrics.widthPixels给的是整块屏幕的宽度,而你的弹窗实际是挂在某一个窗口里的,两者可能差出一倍。分屏时弹窗超出半边屏幕、被系统裁掉,很多就是这么来的。
3.3 横屏与大屏下要单独想一遍
横屏时屏幕高度骤减,一个纵向排列的弹窗很容易上下顶出边界。我的处理是给内容区设一个最大高度,超出部分放进ScrollView或NestedScrollView:
val dm = resources.displayMetrics val maxHeight = (dm.heightPixels * 0.8f).toInt()注意这里的 0.8 是针对高度而不是宽度,而且必须在横竖屏切换后重新计算。我把这段逻辑放在onStart里做,因为每次onStart都会在配置变更重建后重新走一遍,相当于免费的刷新时机。如果放在onCreateView里,旋转之后拿到的是旧尺寸,会看到高度明显不对。
4. 只会在真机上复现的那几个坑
模拟器跑得通、单测跑得通、真机一测就出事,这类问题在弹窗上尤其多,因为它牵涉窗口系统、输入法、厂商定制 ROM 三方的交互,每一样都不是靠静态代码检查能发现的。
4.1 一次崩溃的完整排查链路
那次崩溃的现象是:从商品详情页点"加入购物车",弹出一个规格选择框,快速返回再快速进入,必崩。日志里是一行IllegalStateException: Can not perform this action after onSaveInstanceState。
排查我按这个顺序走:
- 确认崩溃栈指向哪里。栈顶是
FragmentManagerImpl.checkStateLoss,说明问题不是空指针,而是事务提交时机不对。 - 确认调用点。打日志发现
show()是在网络回调里调的,而回调回来时页面已经不可见。 - 确认框架内部行为。翻
DialogFragment.show的实现,它内部走的是commit(),不是commitAllowingStateLoss()。这意味着只要宿主 FragmentActivity 已经存过状态,这个调用就一定抛异常。 - 验证假设。在
show之前加一句Log.d("dialog", "isStateSaved=$isStateSaved, isAdded=$isAdded"),复现时果然打出了isStateSaved=true。 - 确定修复位置。不是简单换成
commitAllowingStateLoss——那只是把崩溃变成"弹窗不出现"的静默失败。真正的修复是调用方判断状态,在isStateSaved为真时放弃这次弹出,或者把待弹内容缓存下来,等页面恢复后再弹。
最终落在基类里的写法是这样:
fun showSafely(fm: FragmentManager, tag: String): Boolean { if (isAdded || fm.isStateSaved) return false show(fm, tag) return true }调用方拿到false就知道这次没弹成,可以进行后续的降级处理,比如用 Toast 提示或者把结果记下来等下次进入页面时补上。这个返回值设计看起来是小事,但它让"弹窗失败"从一个不可观测的异常变成了一个可编程的分支。
4.2 软键盘顶起弹窗时的冲突
弹窗里放输入框,点开键盘之后弹窗被顶到一半、或者被键盘完全盖住,这是另一个高频问题。根源在于WindowIsFloating为 true 的窗口,系统对adjustResize的支持并不完整,很多时候只会走adjustPan,而adjustPan是"把整个窗口往上平移",于是弹窗顶部直接移出屏幕。
我的做法是两条腿走路:
override fun onStart() { super.onStart() dialog?.window?.apply { setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN ) } }同时在布局层面把内容放进ScrollView,并借助ViewCompat.setOnApplyWindowInsetsListener监听 inset 变化,手动给内容底部加 padding:
ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) val nav = insets.getInsets(WindowInsetsCompat.Type.systemBars()) view.setPadding( view.paddingLeft, view.paddingTop, view.paddingRight, maxOf(ime.bottom, nav.bottom) ) insets }这套组合拳的意义在于:即使系统的adjustResize在某些定制 ROM 上不生效,我也能自己把键盘高度拿到手,让内容跟着往上走。这里必须承认一个现实——不同厂商的 ROM 对弹窗窗口的 inset 分发行为不完全一致,所以这套代码要在目标机型上实测过再上线,不能只在模拟器上看一眼。
4.3 持有 Context 的姿势与泄漏面
弹窗相关的泄漏,十次有八次是因为匿名内部类持有了外部 Activity。典型写法:
// 不推荐 val dialog = Dialog(this) dialog.setOnDismissListener { refreshList() } // lambda 捕获了 ActivityrefreshList如果是 Activity 的方法,这个 lambda 就持有了 Activity 的引用,而 Dialog 又被 WindowManager 持有,一旦 Activity 先销毁、Dialog 后销毁,中间这段时间就是一段泄漏。
处理办法有三层:第一层,能用DialogFragment就用,让框架管生命周期;第二层,回调里尽量只操作 View 或者 ViewModel,不直接碰 Activity 的方法;第三层,在onDestroyView里主动清空 View 引用和监听器:
override fun onDestroyView() { binding = null dismissListener = null super.onDestroyView() }用 ViewBinding 的时候,把 binding 置空这一步千万别省,它是 Fragment 场景下最常见的一处泄漏源。
4.4 快速点击导致的重复弹窗
用户手快是常态。一个按钮连点三下,如果弹窗创建逻辑里有异步操作(比如先去请求一下配置再弹),就会出现三个弹窗叠在一起。而且因为每个弹窗都有遮罩,用户只能看到最上面那个,点一下关掉一个,下面还藏着两个,体验极差。
我在基类里加了一个静态的 tag 判重:
companion object { private const val TAG_SPEC = "dialog_spec" fun showOnce(fm: FragmentManager): BaseDialogFragment? { if (fm.findFragmentByTag(TAG_SPEC) != null) return null val instance = SpecDialogFragment() if (!instance.showSafely(fm, TAG_SPEC)) return null return instance } }findFragmentByTag这一步是在 FragmentManager 里查,比在业务层维护一个布尔值可靠得多,因为它能覆盖"弹窗已经 dismiss 但 Fragment 事务还没执行完"这个极短的窗口期。
5. 动画、取消行为与那些容易被忽略的细节
前面几块解决了"能不能正常显示",这一块解决的是"显示得像不像一个成熟产品"。很多弹窗的技术实现没问题,但一点开就有种廉价感,问题往往出在动画和交互反馈上。
5.1 动画该放在窗口层还是视图层
两个位置都能做动画,但适用场景不同。窗口层动画通过 style 里的windowAnimationStyle指定:
<style name="Anim.App.Dialog" parent="@android:style/Animation.Dialog"> <item name="android:windowEnterAnimation">@anim/dialog_fade_scale_in</item> <item name="android:windowExitAnimation">@anim/dialog_fade_scale_out</item> </style>它的好处是动画作用在整个窗口上,进出场统一,且退出动画能完整播放(窗口层动画在dismiss时会等动画结束再移除窗口)。缺点是它对遮罩层也生效,有时候会看到遮罩跟着内容一起缩放,观感很怪。
视图层动画就是在onViewCreated里对根 View 做animate():
binding.root.apply { alpha = 0f scaleX = 0.92f scaleY = 0.92f animate() .alpha(1f) .scaleX(1f) .scaleY(1f) .setDuration(180) .setInterpolator(DecelerateInterpolator()) .start() }视图层的优势是可控精度高,只影响内容不影响遮罩,缺点是要自己管退出动画的时机——不能直接dismiss,得先播动画再 dismiss,否则会被打断成硬切。我的取舍是:弹窗进出场用窗口层,内部的元素入场(比如列表项逐个淡入)用视图层,各管一段,互不打架。
动画时长上,180 到 220 毫秒是我实测下来最舒服的区间,低于 150 会显得生硬,高于 250 又让人等得有点烦。
5.2 点外部取消、返回键与业务拦截
setCanceledOnTouchOutside默认在部分主题下是 false,需要显式打开。但打开之后还有一个问题:有些弹窗不允许点外部关闭,比如必填的协议确认框。这时候不能只设setCanceledOnTouchOutside(false)就完事,因为用户还能按返回键。
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) isCancelable = false // 同时关掉返回键和外部点击 }注意isCancelable是 DialogFragment 的属性,设置在onCreate里才有效;在onViewCreated里设是无效的,因为那时 Dialog 已经创建完成。这个时间点差异踩过的人不少。
还有一种需求是"点外部不关,但要给个提示",比如操作未完成时点外面提示"请先完成当前操作"。这就得自己在onTouchEvent层面处理,通过设置一个透明的拦截层实现,简单粗暴但有效:
dialog?.window?.decorView?.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_OUTSIDE) { showHint() true } else false }前提是isCancelable保持 false,否则事件还没到ACTION_OUTSIDE就被系统处理掉了。
5.3 暗黑模式与字体放大下的表现
暗黑模式这块,只要颜色全部走主题属性就没问题,关键在于会不会有人偷懒写成#FFFFFF。我在代码审查里会把硬编码颜色的弹窗布局直接打回。另外 shape 里的solid也要用?attr/colorSurface,不能用固定值。
字体缩放更麻烦。用户把系统字体调到最大档,一个原本刚好放下的弹窗可能文字溢出、按钮被挤没。处理办法是把按钮行放在LinearLayout里并设置layout_weight,同时给文字设maxLines和ellipsize,最关键的还是把内容整体放进 ScrollView,让它溢出的部分可以滚动,而不是被裁掉。
无障碍方面,contentDescription别漏,尤其是有图标的关闭按钮,读屏用户不知道那个叉是干嘛的。还有点击区域,48dp这个下限在设计稿上经常被画成 32dp 的小叉,实现时要用 padding 撑到 48dp,视觉上还是小的,但可点范围够了。
6. 把弹窗做成团队真正能用的组件
单篇弹窗代码写得再好,如果每个业务都复制一遍改改,三个月后就没人能说清项目里到底有多少种弹窗样式。组件化的目的不是"显得专业",而是把变化的边界固定下来。
6.1 参数传递:Bundle 还是 Builder
DialogFragment 有个硬性约束——进程被杀之后重建,只会调用无参构造,然后从arguments里恢复。所以业务参数必须走Bundle,不能存在成员变量里:
class ConfirmDialogFragment : BaseDialogFragment() { companion object { private const val KEY_TITLE = "title" private const val KEY_CONTENT = "content" private const val KEY_CONFIRM = "confirm" fun newInstance(title: String, content: String, confirm: String) = ConfirmDialogFragment().apply { arguments = bundleOf( KEY_TITLE to title, KEY_CONTENT to content, KEY_CONFIRM to confirm ) } } override fun onCreateView(...): View { val title = requireArguments().getString(KEY_TITLE).orEmpty() // ... } }那 Builder 有什么用?我的做法是Builder 只负责在调用侧收集参数、拼装 Bundle,最终产物仍是 Fragment 的 arguments。这样调用侧写着流畅(链式调用、可选参数默认值),同时不违反重建规则。如果直接把业务对象存到 Fragment 的成员变量里,测试时转个屏就会看到内容变空白,这个坑几乎人人踩过一次。
需要注意的是 Bundle 里只能放基本类型和可序列化类型,传大对象(比如一个完整的商品模型)会导致TransactionTooLargeException。正确做法是只传 id,在弹窗内部自己去取数据。
6.2 结果回传的三种写法横向比一遍
| 写法 | 实现要点 | 优点 | 缺点 |
|---|---|---|---|
| FragmentResultListener | setFragmentResult+ 监听 | 官方推荐、解耦 | 同一个 resultKey 多处监听需注意 |
| 接口回调 | 定义 interface 由宿主实现 | 直观、类型安全 | 重建后回调丢失 |
| SharedViewModel | 两个 Fragment 共享实例 | 状态天然保存 | 引入架构依赖 |
我现在的默认选择是FragmentResultListener,因为它在重建后仍然有效,而且不需要宿主和弹窗互相持有引用。写法上:
// 弹窗内确认时 setFragmentResult(RESULT_KEY, bundleOf("confirmed" to true)) dismissAllowingStateLoss()// 宿主里 parentFragmentManager.setFragmentResultListener(RESULT_KEY, viewLifecycleOwner) { _, bundle -> if (bundle.getBoolean("confirmed")) doSomething() }第二个参数传viewLifecycleOwner而不是this,是为了让监听随 View 销毁而自动解绑,避免 View 释放后还收到回调去操作空 binding。
6.3 复用之外的测试与回归清单
组件做完之后,我会维护一份手工回归清单,每次改动基类都过一遍。清单不长但都是真金白银换来的:
- 竖屏下宽度是否为屏幕的 0.82,且不超过 400dp。
- 横屏下内容是否可滚动,底部按钮是否仍可点。
- 分屏模式下弹窗是否被裁切。
- 旋转屏幕后,输入框里的文字是否还在。
- 连续快速点击按钮,是否只弹出一个。
- 弹窗打开时按返回键,是否符合预期(关闭或拦截)。
- 系统字体调到最大,文字是否溢出。
- 暗黑模式下背景色、文字色是否正确。
- 打开键盘后,弹窗内容是否仍然可见。
- 退出页面时弹窗是否跟随消失,有无泄漏告警。
这份清单我把它贴在项目的流程文档里,新同学改弹窗代码之前先看一眼,比事后解释"为什么这个不能改"省事得多。
我在实际项目里踩得最深的一次,是一个看起来完全无关的改动:为了优化性能,把弹窗的创建从onCreateView挪到了缓存里预创建。上线后收到零星反馈说弹窗偶尔是空白的,排查了两天才发现,预创建的 Dialog 在 attach 之前被拿去做宽度测量,测出来是一个无效值,等到真正 show 时窗口已经用了错误尺寸。结论就是弹窗这个东西的尺寸和状态跟窗口 attach 时机强绑定,任何"提前准备"的优化都要留一条回退路径。后来我在基类里加了一句:允许传入期望宽度,但每次onStart都重新计算并覆盖,宁可多算一次,也不信任缓存值。这个习惯一直保留到现在,几乎没再因为尺寸问题返工过。