
简介在Android开发中对话框是实现交互反馈与信息收集的常用组件AlertDialog.Builder则以链式调用简化了对话框的创建与定制。这份PDF面向初、中级Android开发者系统梳理了基于Builder构建各类对话框的完整用法包括基础消息框、带确认/取消按钮、文本输入、单选与多选、列表展示以及图标、可取消性和自定义标题等扩展设置。内容以可运行的Java代码片段配合说明便于直接应用。资源为1个PDF文件压缩包大小约159KB当前已有2061人学习下载。通过学习读者既能理解Builder链式API的调用逻辑也能掌握EditText、setSingleChoiceItems、setMultiChoiceItems等控件在对话框中的搭配方式减少重复创建自定义类所带来的冗余代码提升日常开发效率。适合查阅或作为快速上手资料。1. AlertDialog.Builder 不是什么黑魔法先看使用场景和边界初次接触 Android 开发的人多半是在“弹窗怎么实现”这一步遇到AlertDialog.Builder。它几乎能覆盖应用里 80% 的对话框场景——确认删除、填写昵称、单选列表、底部提示甚至自定义一张完整的表单页。但很多人用了一两年依然只会在 MainActivity 里复制粘贴官方示例换到 Fragment、ViewModel 或后台线程就翻车。这篇文章把一个真实问题讲清楚AlertDialog.Builder到底是怎样一套机制哪些参数必须调哪些写法是坑以及为什么 dialog.show() 之后偶尔会闪退。我不打算把官方 API 文档重新抄一遍而是按我交付项目时常用的顺序从构造、布局、监听器到生命周期串一遍并给出可直接落地的代码。适合刚入门想搞懂原型的初级开发也适合被 Dialog 内存泄漏和状态丢失折磨过的中级工程师——读完你能在 10 分钟内定位绝大多数对话框问题的根源。2. Builder 模式与对话框的三个核心部件为什么非用 Builder 不可2.1 链式调用背后的设计意图AlertDialog.Builder是典型的 Builder 模式Java 和 Kotlin 里都极常见。它解决的核心矛盾是AlertDialog的构造参数太多而且一半以上是可选的。如果直接用构造函数调用方被迫面对十几个重载如果用 setter 一个个设置那必须先 new 出对象、再逐个 set对象在预热阶段就可能被误用。Builder 把“配置”和“创建”两件事拆开。你在 builder 上连续调用 setTitle、setMessage、setPositiveButton这期间对话框对象还没真正构造内存分配被推迟到show()或create()。这种设计的好处是显而易见的你可以在同一段代码里根据业务条件跳过某些配置而不必担心拿到半成品对象同时 Builder 内部默认值已经处理好了你做的是覆盖而不是从零赋值。从实际开发看这种模式还有一个隐性好处可读性。链式调用的代码从上到下读一遍就是对话框的最终外观——标题、内容、按钮顺序一目了然。这比在 XML 里堆一个 Dialog 布局再 findViewById 要直观得多也是 Gson、OkHttp、Retrofit 都采用 Builder 或类似风格的原因。2.2 对话框的三个核心部件与职责边界一个AlertDialog在界面上无非三块标题区、内容区、按钮区。AlertDialog.Builder的 API 设计也恰好围绕这三块展开理解你的需求落在哪一块代码就能写得有条理。标题区对应setTitle()/setCustomTitle()。大多数场景用setTitle(CharSequence)就够了它的底层会把标题渲染进一个 TextView。注意setCustomTitle(View)适合要把标题做成品牌化样式比如左边加个 logo的场景但自定义 title 后默认的标题样式会被完全替换间距、字体都要自己负责。内容区有三种典型形态纯文本setMessage、单选/多选列表setItems、setSingleChoiceItems、setMultiChoiceItems、任意自定义 ViewsetView。这三者是互斥的——你设置了setMessage又调setView后者会覆盖前者。我见过不少同事在这上面踩坑调试半天才发现自己之前在某条分支里顺手调过 setMessage。按钮区是最容易被误解的。setPositiveButton的含义不是“确定”而是“右数第一个按钮”setNegativeButton是“左数第二个”setNeutralButton会变成“左数第一个”。这跟很多网页端 UI 习惯不同如果按“确定在右取消在左”的直觉排恰好是吻合的但如果你把 Neutral 当第三按钮用视觉顺序会跟你预期相反。2.3 直接 new AlertDialog 行不行两个反面案例为什么非要用 Builder直接new AlertDialog(context)在语法上完全合法但它不是最佳选择。第一个问题是构造函数没有提供 title、message 或 button 的初始化入口你被迫 setter 满天飞代码会很快变成一坨无法维护的赋值序列。比如这样AlertDialog dialog new AlertDialog(context); dialog.setTitle(警告); dialog.setMessage(这是一条很长很长的提示……); dialog.setButton(AlertDialog.BUTTON_POSITIVE, 确定, (dialogInterface, which) - {}); dialog.setButton(AlertDialog.BUTTON_NEGATIVE, 取消, null); dialog.show();这段代码逻辑上没有错业务也能跑通但可读性很差而且setButton的常量参数容易写错。另一个坑是new AlertDialog并不会预置默认的背景、圆角、内边距等资源在部分国产 Rom 上可能出现样式错乱而 Builder 在show()时会走完整的AlertController初始化流程把系统主题里的控件统一装配好。从长期维护角度我建议统一用 Builder除非你在写一个要求极致底层的 UI 控件库。2.4 Activity 与 Dialog 的生命周期耦合选型必须知道的定性结论AlertDialog是一个 window 级别的浮层视图但它不持有独立的生命周期。它的“生命周期”完全依附于创建它的 Context。如果你在 Activity A 里弹了一个对话框用户旋转屏幕导致 Activity 重建那么旧的 dialog 会被框架尝试 dismiss而如果回调里还在操作旧 Activity 的 View就可能带来崩溃或内存泄漏。基于这个事实选型时有两条明确结论。第一能用DialogFragment承载的尽量避免直接在 Activity 中展示AlertDialog因为 DialogFragment 能通过onSaveInstanceState自动维护对话框的显示状态。第二在 ViewModel 或后台任务里千万别直接传 Activity 引用去创建 Builder应该用MutableLiveData或LiveData通知界面层展示。这两条结论是多年血泪经验换来的后面会展开讲状态恢复问题。3. 原生对话框的最小可运行代码模板与参数说明3.1 最简触发代码一个带“确定/取消”的确认框任何项目入手的标准模板就是下面这段。Kotlin 版本可以直接使用context作为构造参数只要它在当前界面生命周期内有效——通常就是thisActivity或requireContext()Fragment。在按钮回调里接口返回的dialogInterface可以用来做 dismiss 等操作但要注意别在回调里重复调用同一个 dialog 的 show()。val builder AlertDialog.Builder(this) builder.setTitle(提示) builder.setMessage(确定要删除这条记录吗删除后不可恢复。) builder.setPositiveButton(删除) { dialog, which - // 做删除操作用 which 判断按钮类型BUTTON_POSITIVE doDelete() } builder.setNegativeButton(取消) { _, _ - // 不做事仅关闭对话框 } builder.show()setPositiveButton和setNegativeButton的第二个参数是DialogInterface.OnClickListener。其中which的取值对应三个常量BUTTON_POSITIVE、BUTTON_NEGATIVE、BUTTON_NEUTRAL。在确认框场景里几乎不会判断这个值因为按钮本身就对应不同的回调但在多个按钮共用一个监听器时就需要用which分流处理。这段代码有一个小细节builder.show()的返回值是一个AlertDialog它可以赋值给成员变量dialog方便后续代码里调用dialog.dismiss()。常见做法是直接用局部变量接收或者完全不接收因为按钮点击后对话框会自动关闭。3.2 单选列表与多选列表setItems 系列的三参数套路把setMessage换成setItems就可以得到一个点击项即关闭的列表对话框。这种方式适合“选择性别”“切换城市”这种单项选择。代码如下val items arrayOf(男, 女, 保密) AlertDialog.Builder(this) .setTitle(选择性别) .setItems(items) { _, which - // which 就是 items 数组的下标 Toast.makeText(this, 选择了${items[which]}, Toast.LENGTH_SHORT).show() } .show()注意这里没有“确定”和“取消”点击列表项后对话框立即消失。如果你希望点击某选项后不关闭而是让用户再确认一次那就得用setSingleChoiceItems它在列表右侧显示一个单选框并且默认不会点击即消失。典型用法是配合确定按钮val items arrayOf(周一, 周二, 周三) var selectedIndex 0 AlertDialog.Builder(this) .setTitle(设置重复日) .setSingleChoiceItems(items, selectedIndex) { _, which - selectedIndex which } .setPositiveButton(确定) { _, _ - Toast.makeText(this, 选中了${items[selectedIndex]}, Toast.LENGTH_SHORT).show() } .setNegativeButton(取消, null) .show()setSingleChoiceItems第一个参数是数据源第二个参数是默认选中项的下标传 -1 表示不选中。这里有个很关键的使用原则selectedIndex必须被定义为成员变量或外层局部变量因为回调触发时你需要读取它。很多人在 Lambda 里试图通过items[which]拿到数据这在单选场景是对的但在多选场景下标就不再等同于选中状态了。多选列表用setMultiChoiceItems它接收一个boolean[] checkedItems作为初始勾选状态。由于选中状态由系统维护你在确定按钮里需要遍历这个数组val items arrayOf(苹果, 香蕉, 橙子) val checked booleanArrayOf(true, false, true) val dialog AlertDialog.Builder(this) .setTitle(选择水果) .setMultiChoiceItems(items, checked) { _, which, isChecked - checked[which] isChecked } .setPositiveButton(确定) { _, _ - val result items.filterIndexed { index, _ - checked[index] } Toast.makeText(this, 选择了$result, Toast.LENGTH_SHORT).show() } .create() dialog.show()setMultiChoiceItems的回调里第三个参数isChecked表示更新后的勾选状态但这只在用户点击时触发如果是代码里预先设置checked数组系统并不会主动回调。所以官方建议是在确定或取消按钮里统一读取checked数组作最终结果不要依赖回调里的累积值。3.3 按钮的三种类型与 “取消按钮设为 null” 的边界setNegativeButton(取消, null)在 API 里是合法的表示展示按钮但点击后只关闭对话框。不过有一个边界要厘清当用户按系统返回键时setCancelable(true)默认值会让对话框直接关闭此时不会触发任何按钮点击事件。如果你在取消按钮回调里写了清理逻辑返回键触发时这段逻辑不会执行。解决办法有两个。第一个是在创建处覆盖setOnCancelListener它在用户按返回键或点击对话框外部区域前提是 setCanceledOnTouchOutside(true)时触发。第二个是干脆把对话框设为setCancelable(false)强制用户必须在按钮之间做选择这适用于强制更新、重要隐私授权等场景val dialog AlertDialog.Builder(this) .setMessage(系统需要更新才能继续使用) .setPositiveButton(立即更新) { _, _ - startUpdate() } .setNegativeButton(退出应用, null) .setCancelable(false) .create() dialog.show()setCancelable(false)同时会影响返回键和触摸外部区域这两个关闭行为但对按钮点击无效。这里还有一个容易误用的细节setCanceledOnTouchOutside(true)只在setCancelable(true)时生效如果你设了setCancelable(false)再怎么调 setCanceledOnTouchOutside 也没用。3.4 代码在 Fragment 中如何写requireContext 与 onDestroy 检查Fragment 里展示对话框第一反应是直接用requireContext()构造 Builder这没问题但有一个生命周期陷阱show()之后如果 Fragment 已经 detachrequireContext()本身就能抛异常。因此安全的写法是先判断isAdded或者更稳妥地使用childFragmentManager配合 DialogFragment 封装。if (isAdded) { AlertDialog.Builder(requireContext()) .setTitle(重试) .setMessage(网络请求失败是否重试) .setPositiveButton(重试) { _, _ - retry() } .setNegativeButton(取消, null) .show() }isAdded检查看起来多此一举但在 ViewPager 快速滑动、Fragment 入栈出栈频繁切换时这是最常见的闪退入口。不加这个判断极少数设备上会出现IllegalStateException: Fragment already added或not attached to Activity的崩溃。这不是玄学是 Fragment 状态生命周期与 Dialog 显示时机不一致造成的。4. 自定义布局与监听器setView 的核心玩法与回调链4.1 setView 与 setMessage 互斥布局里放输入框的标准做法很多场景需要用户在对话框里填表单输入昵称、填写备注、登录密码。默认的setMessage只能展示静态文本不支持输入。正确做法是调用setView(View)把一个预加载的布局塞进内容区。val inflater LayoutInflater.from(this) val view inflater.inflate(R.layout.dialog_input, null) val editText view.findViewByIdEditText(R.id.et_input) AlertDialog.Builder(this) .setTitle(修改昵称) .setView(view) .setPositiveButton(保存) { _, _ - val nickname editText.text.toString() // 这里直接读取 editText 的内容注意判空 if (nickname.isNotBlank()) doSave(nickname) } .setNegativeButton(取消, null) .show()这里的关键点是setView会把整个布局宽高按 XML 属性拉伸。如果你没有指定根布局宽高默认可能会形成一个“内容包裹”的对话框。想要它撑满底部或全屏不能只依赖setView需要在 XML 里给根布局设置android:layout_widthmatch_parent并在需要时配合window属性调整。另一个极其容易踩的坑是如果你在 activity 里先调用了setMessage后面又调用了setViewsetMessage会被覆盖。这是AlertController内部的 mMessage 和 mView 互斥决定的不是你代码顺序写错。反过来如果你先 setView 后 setMessage对话框最终会显示纯文本View 被丢弃。4.2 用 setView 接收自定义 View 时的 onAttachedToWindow 时机setView传入的 View 不是在show()之前就完成布局的。show()之后AlertController才会把 View 挂到 Dialog 的 DecorView 上。因此如果你在构造阶段就试图调用view.measure()读取宽高得到的值是 0。这在实现“对话框弹出后根据输入内容动态调整高度”的需求时非常痛苦。可靠的方案是给这个 View 设置OnGlobalLayoutListener等布局完成后再取宽高。例如要做一个底部弹出的输入框并让软键盘弹出时对话框随之上移val view inflater.inflate(R.layout.dialog_input, null) view.viewTreeObserver.addOnGlobalLayoutListener { val height view.height if (height 0 view.viewTreeObserver.isAlive) { view.viewTreeObserver.removeOnGlobalLayoutListener(this) // 此时拿到真实高度可做后续偏移逻辑 } }谨慎使用view.post {}来拿尺寸也不一定可靠因为对话框的内容是在一个独立的 window 上绘制post 的执行时间可能早于 Dialog 的布局完成。我一般会优先用OnGlobalLayoutListener并在回调里做一次性移除避免每次布局变化都触发无意义的计算。4.3 Dialog 上使用 EditText软键盘弹出时的二次适配对话框里的 EditText 有一个老大难问题键盘弹出后 Dialog 被顶上去或遮住而且不同 Rom 表现不一致。最省事的做法是在show()之后显式设置软键盘模式val dialog AlertDialog.Builder(this) .setView(view) .create() dialog.show() dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)SOFT_INPUT_ADJUST_RESIZE能让 window 自适应键盘在多数原生 Android 设备上表现良好。但在小米、ColorOS 等部分 Rom 上还需要配合在 AndroidManifest 的 Activity 上声明windowSoftInputModeadjustResize才有稳定效果。这不是AlertDialog.Builder本身的问题而是 window 焦点和软键盘的交互机制在各 Rom 上实现不一致。如果你追求稳定且不想被这些问题纠缠更理想的做法是放弃 Builder setView改用DialogFragment自定义布局并把软键盘模式写在onActivityCreated里。但那已经是另一个话题了这里知道setSoftInputMode是兜底手段即可。4.4 监听器回调里读取控件内容空指针的三类常见源头在 PositiveButton 回调里读取 EditText 内容空指针一般出现在三个地方。第一EditText 是局部变量View 还没被 dialog 真正持有但你直接访问了它的方法在极端时序下可能拿到 null。第二editText.text.toString()在用户输入为空时返回空字符串不是 null但业务逻辑里没做 trim 处理把空格当成了有效输入。第三你在回调里使用了text.length 0而不是isNotBlank导致长度为 1 的空格通过校验。最稳定的做法是把 EditText 提升为类成员变量或者在回调里再次findViewByIdval dialog AlertDialog.Builder(this) .setView(view) .setPositiveButton(保存) { _, _ - val et view.findViewByIdEditText(R.id.et_input) val content et.text.toString().trim() if (content.isNotEmpty()) doSave(content) else Toast.makeText(this, 内容不能为空, Toast.LENGTH_SHORT).show() } .create() dialog.show()这里重新 findViewById 的代价很小但能规避因外部作用域引用混乱导致的隐蔽空指针。回调里做非空校验是习惯问题也是把错误留在客户端而不是服务端最后一道防线。5. AlertDialog.Builder 排坑指南常见问题与排查5.1 坑一对话框弹一次再弹第二次闪退现象第一次点击弹出对话框正常关闭后再点同一按钮程序闪退Logcat 报WindowLeaked或BadTokenException。原因show()之前没有检查isFinishing()。当 Activity 已经处于退出流程或正在重建旋转屏幕window 已经 detach向它添加 Dialog window 就会抛异常。解决在show()前统一加一道检查条件满足才弹窗。fun showSafeDialog(activity: Activity, builderBlock: AlertDialog.Builder.() - Unit) { if (activity.isFinishing || activity.isDestroyed) return AlertDialog.Builder(activity) .apply(builderBlock) .show() }这是我在多个项目里最终沉淀下来的一个工具方法。所有对话框入口都走这层保护彻底杜绝BadTokenException不必每次弹窗前都手动写判断。5.2 坑二旋转屏幕之后按钮回调失效或双击触发现象对话框弹出后旋转屏幕再点击“确定”有时回调不执行有时执行了两次还有时候按钮文字还在但点击无响应。原因AlertDialog在屏幕旋转时 Activity 重建旧对话框被系统 dismiss但某些深层回调仍引用旧实例。双击则常见于回调里执行了耗时操作用户等不及又点了一次Dialog 在第一次点击时已经关闭第二次点击理论上无效果但如果异步结果回来再次 show就会重复提交。解决按钮点击后立即增加保护变量或在回调入口消费事件且仅执行一次var isHandled false builder.setPositiveButton(确认) { _, _ - if (isHandled) returnsetPositiveButton isHandled true doAction() }如果对状态恢复有硬性要求直接切换到DialogFragment并使用setRetainInstance(true)配合 ViewModel 保存数据。这不是绕路而是唯一能同时保证“旋转不丢对话框”和“回调不泄漏旧引用”的方案。5.3 坑三对话框背景黑了一块或圆角失效现象content 区域正常但对话框四角有黑色方块或自定义背景没生效。原因AlertDialog默认不透明背景来自当前主题。部分国产 Rom 上android:windowBackground被替换成不透明黑色导致圆角失效。这跟 Builder 代码无关是主题资源兼容问题。解决在构造处强制设置透明背景并让根布局自己画出带圆角的背景。具体做法是在 dialog 的 window 上设置val dialog builder.create() dialog.show() dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))注意设置背景要在 show() 之后不然 window 属性可能被重置。5.4 坑四列表项太多导致对话框高度溢出屏幕现象setItems 或 setSingleChoiceItems 传入 50 个以上的数据对话框直接超出屏幕下边缘。原因AlertDialog 内部对列表高度有约束但承载列表的控件在某些 Rom 上尤其 FullHD 及以上可以无限拉伸导致溢出。解决竖向内容超过一屏时不使用setItems改为自定义布局内部放一个RecyclerView或ScrollView并固定最大高度。例如为列表布局设置LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:maxHeight400dp android:orientationvertical /LinearLayoutmaxHeight在 LinearLayout 中生效但如果你直接给 ScrollView 设置 maxHeight部分 Rom 也会遵守。这是我的常用兜底方案能覆盖绝大多数列表变量过长的场景。5.5 坑五Builder 里 setMessage 后添加 WebView 或图片显示尺寸不对现象用 setView 塞进一个 WebView 或 ImageView期望 WebView 显示一张地图或大图结果高度只有几十像素或直接不可见。原因setView 传入的 View 如果不自带尺寸约束AlertController 会按wrap_content计算高度而WebView的 wrap_content 高度默认是 0。图片同理如果 drawable 是 BitmapImageView 的 wrap_content 宽高需要onMeasure才能确定但 Dialog 可能在 measure 完成前就截断了布局。解决给 WebView 和 ImageView 设置固定 dp 宽高或在根布局上强制layout_height300dp之类的明确尺寸。例如WebView android:idid/webView android:layout_widthmatch_parent android:layout_height300dp /这比在代码里 measure 更稳因为 Dialog 的布局测量只发生在 show 之后的窗口建立阶段没有第二次补充 measure 的机会。6. 收尾技巧用同一份布局做加载框和确认框最后分享一个我在项目里用了很久的设计模式用AlertDialog.Builder 自定义 View 做一套“双模式对话框”。加载框和确认框通常是两套 UI但业务逻辑上都是“一个浮层显示信息用户在某个时机点击或等待完成”。如果分两套实现代码会有大量的相似 setup。我的做法是定义一份底部圆角布局内部预留三个区域——标题 TextView、内容区 FrameLayout、按钮区默认隐藏。构造一个通用类BaseDialog对外暴露两类方法showConfirm(title, message, confirmText, cancelText, callback)和showLoading(title, message)。内部统一走AlertDialog.BuildersetView状态切换时只需要替换内容区的子 Viewval inflater LayoutInflater.from(context) val root inflater.inflate(R.layout.dialog_base, null) val contentContainer root.findViewByIdFrameLayout(R.id.fl_content) val btnArea root.findViewByIdLinearLayout(R.id.ll_buttons) fun showLoading(message: String) { val loadingView inflater.inflate(R.layout.view_loading, null) contentContainer.removeAllViews() contentContainer.addView(loadingView) btnArea.visibility View.GONE AlertDialog.Builder(context) .setView(root) .setCancelable(false) .show() } fun showConfirm(title: String, message: String, onConfirm: () - Unit) { btnArea.visibility View.VISIBLE AlertDialog.Builder(context) .setView(root) .setCancelable(true) .show() }这套封装的价值在于添加新对话框时不需要新写 Builder 代码只用替换 contentContainer 里的子 View关闭、取消、返回键行为都在一个入口里管理。用久了你会发现AlertDialog.Builder真正的边界不是 API 不够用而是大家不习惯先定布局骨架再往里填内容。把对话框当作一个可复用的 View 容器来看Builder 只不过是你往容器里装东西的入口。这套模式我连续用了三个项目唯一还需要手动处理的是主题弹窗特效和冷启动时的异步 show 时序。但那些已经不属于 Builder 的职责范畴了。如果你现在正被各种对话框代码反复折磨不妨从下一个需求开始先画布局再写 Builder这是我能给的最实际的建议。希望帮到你。本文还有配套的精品资源点击获取