拓冰建站拓冰建站
首页 / 资讯中心 / 正文

DialogFragment从入门到实战:生命周期、状态恢复与避坑指南

如果你用AlertDialog直接弹一个删除确认框用户手指刚碰到“删除”按钮的同时屏幕旋转了你的App大概率会在那一刻直接闪退。这个场景我在真实项目里见过不下十次。后来把项目里所有弹窗统一改成DialogFragment这类崩溃基本绝迹。DialogFragment从Android 3.0就有了但它绝对不是一个该被遗忘的“老古董”直到今天它依然是承载Dialog最可靠、最值得按规范使用的容器。这篇文章适合两类人一是刚入门Android、准备把自定义弹窗写到生产环境的同学二是已经用DialogFragment写了不少页面但偶尔还是会被“旋转后闪退”“重复点击弹两个框”“深色模式主题错乱”折磨的开发。我会从为什么非它不可讲起把生命周期、状态恢复、常见业务场景和真实踩过的坑一次说透。1. 为什么DialogFragment至今仍是弹窗的最佳载体1.1 裸写Dialog的隐藏风险从一次线上闪退说起先看一段在很多老项目里还能见到的写法val dialog AlertDialog.Builder(this) .setMessage(确定删除这条记录吗) .setPositiveButton(删除) { _, _ - delete() } .show()这段代码在Activity正常展示时没有任何问题问题全藏在边界情况里Activity被旋转销毁重建旧的Dialog还会挂在WindowManager上新的Activity却没有对应处理于是出现“has leaked window”警告严重时会直接抛异常。异步接口回来后show()一个Dialog如果赶上Activity正在执行onSaveInstanceState就抛Can not perform this action after onSaveInstanceState。用户在页面A弹了一个Dialog然后快速返回Dialog却还停在屏幕上因为它根本不知道页面已经不可见了。裸Dialog的本质问题是它只是WindowManager里的一个窗口和生命周期没有任何绑定关系。你必须在每个合适的时机手动去dismiss()而“合适的时机”往往不是我们以为的那个时机。1.2 DialogFragment对Dialog的三层改造DialogFragment做的事情简单说就是把Dialog塞进Fragment的生命周期里让FragmentManager统一管理它的创建、展示、销毁、状态保存和恢复。对比维度裸DialogDialogFragment生命周期感知无需要手动管理跟随FragmentonStart自动展示onStop自动关闭旋转屏幕恢复不处理就崩溃或丢状态自动保存/恢复进程被系统回收状态完全丢失FragmentManager重建实例并重新弹出Window泄漏风险有需要细致调用dismiss无由系统兜底复用性低代码和所在页面耦合高任何页面都能show同一个实例这里最值钱的其实是最后一点复用性。当你把“弹窗”当成一个独立组件而不是页面里的临时代码块它能被测试、被复用、被统一维护这是代码质量的分水岭。DialogFragment提供了这个基础容器剩下的就是你往里填东西的水平。1.3 静态工厂方法从构造传参开始就规避风险早年间很多人写DialogFragment是直接MyDialogFragment()然后给成员变量赋值class MyDialogFragment : DialogFragment() { var title: String? null // 千万别这么干 }这样写的问题在于Fragment被系统重建时走的是无参构造函数成员变量在旋转、进程恢复后会全部丢失。你需要手动在onSaveInstanceState里存一遍然后再取出来麻烦且容易漏。正确姿势是把参数放进arguments并且用静态工厂方法创建实例class ConfirmDialogFragment : DialogFragment() { companion object { private const val ARG_MESSAGE arg_message fun newInstance(message: String): ConfirmDialogFragment ConfirmDialogFragment().apply { arguments Bundle().apply { putString(ARG_MESSAGE, message) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val message requireArguments().getString(ARG_MESSAGE) } }arguments是FragmentManager持久化保存的Bundle在进程被系统回收后也能恢复。所以从创建实例这一步开始就把它当成系统接管状态的一部分而不是普通的Java对象。这个习惯不仅适用于DialogFragment所有自定义Fragment都应该遵守。2. 从零写一个DialogFragment两条路线与窗口参数细节2.1 先想清楚走哪条路线onCreateDialog还是onCreateViewDialogFragment创建内容有两条路很多人一开始没想明白就乱混着用后面出问题都不知道去哪查onCreateDialog适合复用系统的AlertDialog、BottomSheetDialog等现成容器用AlertDialog.Builder去拼按钮和消息。你的控制权在Dialog内部但拿不到完整的视图生命周期。onCreateView适合完全自定义布局。XML布局由Fragment生命周期管理视图创建、销毁都走标准流程你还能在onViewCreated里做绑定和事件监听。我的建议是只要业务界面稍微复杂一点比如带输入框、带列表、带自定义样式就走onCreateView。理由很实际onCreateDialog模式下Dialog的窗口管理有一些历史包袱比如你在onCreateDialog里inflate的视图其实没走Fragment的onCreateView流程部分生命周期回调的顺序和预期不一样。自定义布局用onCreateView是标准路径文档多、坑少。2.2 完整示例一个带输入框的反馈对话框假设要做一个反馈弹窗包含一个多行输入框和“取消/提交”两个按钮!-- layout_dialog_feedback.xml -- LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:backgrounddrawable/bg_dialog_feedback TextView android:layout_widthmatch_parent android:layout_heightwrap_content android:padding20dp android:text反馈内容 android:textSize18sp android:textStylebold / EditText android:idid/etFeedback android:layout_widthmatch_parent android:layout_height120dp android:gravitytop|start android:hint请输入你的意见或建议 android:padding12dp android:backgrounddrawable/bg_edit_feedback / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:gravityend android:padding12dp Button android:idid/btnCancel android:layout_widthwrap_content android:layout_heightwrap_content android:text取消 / Button android:idid/btnSubmit android:layout_widthwrap_content android:layout_heightwrap_content android:text提交 / /LinearLayout /LinearLayout对应的Fragment代码class FeedbackDialogFragment : DialogFragment() { companion object { private const val ARG_EDIT_HINT arg_edit_hint fun newInstance(hint: String): FeedbackDialogFragment FeedbackDialogFragment().apply { arguments Bundle().apply { putString(ARG_EDIT_HINT, hint) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NO_TITLE, R.style.Theme_App_Dialog_Feedback) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.layout_dialog_feedback, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val etFeedback: EditText view.findViewById(R.id.etFeedback) etFeedback.hint requireArguments().getString(ARG_EDIT_HINT) view.findViewByIdButton(R.id.btnCancel).setOnClickListener { dismiss() } view.findViewByIdButton(R.id.btnSubmit).setOnClickListener { val content etFeedback.text.toString() // 把结果回传给页面 parentFragmentManager.setFragmentResult(KEY_FEEDBACK, bundleOf(KEY_CONTENT to content)) dismiss() } } }注意我在onCreate里调用了setStyle(STYLE_NO_TITLE, R.style.Theme_App_Dialog_Feedback)。STYLE_NO_TITLE会去掉默认标题栏自定义style用来统一圆角、背景、动画等窗口属性。很多人漏了这一步结果自定义布局外面套了一层默认白色背景加标题栏怎么调都不对。2.3 窗口装饰参数圆角、宽度、透明背景的设置顺序自定义DialogFragment最常见的三个问题背景不是圆角、宽度不是预期、背景框是灰白色而不是透明。圆角要在Drawable上做而不是直接在代码里给Window设置圆角!-- res/drawable/bg_dialog_feedback.xml -- shape xmlns:androidhttp://schemas.android.com/apk/res/android solid android:color?attr/colorSurface / corners android:radius16dp / /shape背景透明必须设置给Window否则布局外面的默认背景会兜底override fun onStart() { super.onStart() dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) }这里有一个细节setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))必须在onStart之后设置才稳定。因为在onCreateView阶段Window可能还没有真正挂载到WindowManager上部分属性设置会被忽略。onStart是DialogFragment的窗口准备好显示的时机大部分Window级配置都放这里。宽度控制同理override fun onStart() { super.onStart() dialog?.window?.apply { setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) setLayout( (resources.displayMetrics.widthPixels * 0.9f).toInt(), ViewGroup.LayoutParams.WRAP_CONTENT ) } }宽度取屏幕宽度的90%这几乎是自定义对话框最常用的配件参数。如果你的需求是左右留边距、底部弹层、或者全屏改后面的数字就行。3. 生命周期与跨配置变更旋转屏幕时DialogFragment到底做了什么3.1 show()和dismiss()背后的事务调度show()的完整逻辑可以拆成两层第一层调用fragmentManager.show(dialogFragment, tag)本质是提交一个Fragment事务把DialogFragmentadd到FragmentManager然后安排它走完整的Fragment生命周期。第二层当Fragment进入onStart时DialogFragment内部拿到之前创建的Dialog调用dialog.show()真正把窗口挂到WindowManager上。当Fragment进入onStop时自动调用dialog.hide()。dismiss()则是反向操作先把Dialog关闭再提交一个移除事务把这个Fragment从FragmentManager里移除随后走onDestroyView、onDestroy。这里有个隐藏的坑Fragment事务默认是异步的。如果你在同一个方法里先show()后立即dismiss()事务还没执行完FragmentManager的状态还不够稳定可能出现界面闪烁甚至崩溃。遇到这种场景要么用showNow()强制同步执行要么把dismiss()放到下一次消息循环里比如view.post { dismiss() }。3.2 setRetainInstance被废弃之后跨配置变更该靠谁老Android开发者都有印象以前处理Fragment跨旋转保留用的是一只setRetainInstance(true)。这个方法从API 28开始被标记废弃目前官方态度很明确不要再用请用ViewModel。废弃的直接原因是被保留的Fragment实例会持有旧Binding、旧View状态而且它的生命周期行为在非配置变更场景下很难预测测试也不好写。ViewModel能表达同样的诉求并且职责更单一。拿上面的反馈对话框举例如果用户在里面输了很长一段文字然后旋转了屏幕你肯定不希望输入内容丢光。在DialogFragment里页面级的ViewModel可以这样获取class FeedbackViewModel : ViewModel() { val feedbackContent MutableLiveData() } class FeedbackDialogFragment : DialogFragment() { private val viewModel: FeedbackViewModel by activityViewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.feedbackContent.observe(viewLifecycleOwner) { content - // 恢复输入框内容 if (::etFeedback.isInitialized) { etFeedback.setText(content) } } } }注意观察用的是viewLifecycleOwner而不是this。DialogFragment的onViewCreated和onDestroyView和普通Fragment一样会发生在旋转过程中如果不绑定viewLifecycleOwner会产生观察者泄漏或者回调时操作已销毁视图的问题。如果数据不需要在“页面A”和“页面B”之间共享只希望Dialog自己扛住旋转也可以把ViewModel作用域挂在Activity上用activityViewModels()无论如何别再碰setRetainInstance了。3.3 进程被系统回收后对话框能自动复活吗能前提是你按规范来。系统在onSaveInstanceState阶段会把DialogFragment的实例状态、arguments、FragmentManager中的任务栈一并写进Bundle。当用户从后台切回来、Activity被重建时FragmentManager会根据保存的状态重新创建DialogFragment实例走一遍完整生命周期并且自动重新弹出原来的Dialog。这看起来像魔法其实是“无参构造 arguments恢复 FragmentManager管理”三者配合的结果。正因为如此前面强调的“所有传参都走arguments”才会如此重要——这是进程复活时唯一可靠的数据来源。如果你的Dialog里还有网络请求、大列表等重度依赖请把数据放ViewModel让它和进程的生命周期解耦。DialogFragment负责界面ViewModel负责数据这是最省心的分工。4. 高频业务场景的完整解法确认框、底部弹窗与全屏弹层4.1 回调型确认框结果怎么安全地回传给页面经典做法是定义接口回调class ConfirmDialogFragment : DialogFragment() { var onConfirm: (() - Unit)? null override fun onDetach() { onConfirm null super.onDetach() } }简单场景够用但如果页面在对话框显示的过程中被系统回收lambda会丢失恢复后点击确认没有任何反应。更安全的方式是用官方推荐的FragmentManager.setFragmentResult返回结果系统会帮你保证目标Fragment存活时才能收到回调// DialogFragment 中点击确定 parentFragmentManager.setFragmentResult(request_delete, bundleOf(confirmed to true)) dismiss() // 页面Fragment 中onCreate时注册监听 parentFragmentManager.setFragmentResultListener(request_delete, this) { _, bundle - if (bundle.getBoolean(confirmed)) { delete() } }setFragmentResult这套API的本质是一个轻量级的结果总线不持有彼此的强引用不依赖View存活状态非常适合“弹窗→页面”的单次回传。比我过去用接口回调的老办法少了一大半状态恢复的心智负担。4.2 BottomSheetDialogFragment底部弹出列表的现代做法底部弹窗在产品里出现的频率很高分享菜单、筛选条件、更多操作。官方推荐直接继承BottomSheetDialogFragment它内部已经封装好了BottomSheetBehavior默认滑动手势和遮罩都有class ShareSheetFragment : BottomSheetDialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.layout_share_sheet, container, false) } override fun onStart() { super.onStart() (requireDialog() as BottomSheetDialog).behavior.apply { state BottomSheetBehavior.STATE_EXPANDED skipCollapsed true // 禁止收起为“半个高度”的状态 } } }skipCollapsed true是产品经理们最常提的“要么完全展开要么缩回消失”的需求。如果你不做这一步默认行为是往下拖到一半会停在中间高度视觉上很突兀。另外一个常见需求禁止用户下滑关闭。在普通Dialog里是setCancelable(false)在BottomSheet里除了这个还要禁用拖拽val behavior (requireDialog() as BottomSheetDialog).behavior behavior.isDraggable false behavior.state BottomSheetBehavior.STATE_EXPANDED否则用户直接下拉还是能关掉setCancelable(false)管不住拖拽关。4.3 三个高频控件细节点击外部不消失、全屏宽度、软键盘避让我在项目里总结出一套高频配置基本所有Dialog都会用到点击外部不消失dialog?.setCancelable(false) dialog?.setCanceledOnTouchOutside(false)注意setCancelable(false)要放在onCreate阶段因为它在Dialog创建时决定了返回键响应逻辑setCanceledOnTouchOutside放在onStart之前设置即可。全屏宽度常见于筛选、编辑弹层override fun onStart() { super.onStart() dialog?.window?.apply { setLayout( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL) } }配setGravity(Gravity.BOTTOM)的效果是底部全宽弹层很多“从底部滑出的编辑面板”就是这么做的。软键盘避让当Dialog里带了输入框键盘弹出来会压住输入区域。需要给Window设置自适应dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)写完之后记得测试重点不是键盘出来后能不能输入而是键盘收起后布局是否弹回原样。如果还不行检查布局根节点是否设置了android:windowSoftInputModeadjustResize以及Dialog布局最外层是否给了足够的可压缩空间。5. 项目中真正会踩的坑三个崩溃案例的完整排查链路5.1 Fragment already added重复show的隐蔽来源现象用户快速点击某个按钮两次或者异步回调返回后页面又执行了一次show控制台报IllegalStateException: Fragment already added: ConfirmDialogFragment{...}。排查链路第一步复现时用adb shell dumpsys activity top或者日志确认FragmentManager里确实已经存在同tag的实例。第二步检查show入口是否做了防抖。最常用的防御式写法是private fun showConfirm(message: String) { val tag confirm_dialog if (parentFragmentManager.findFragmentByTag(tag) ! null) { // 已经存在避免重复添加 return } ConfirmDialogFragment.newInstance(message) .show(parentFragmentManager, tag) }第三步逐个排查异步回调。比如网络请求成功后才show如果请求被触发了两次回调也会来两次。这里要查的是请求层的防重而不是只在show这里堵。经验教训永远给Dialog一个固定tag并且在show之前用findFragmentByTag判断一次。这招在绝大多数场景下能直接消灭“add twice”崩溃。5.2 Can not perform this action after onSaveInstanceState现象Activity切到后台系统准备保存状态此时一个异步回调触发show()App直接闪退异常信息是Can not perform this action after onSaveInstanceState。这个问题的本质是show()内部会执行FragmentTransaction.commit()而系统规定onSaveInstanceState之后不能再commit事务否则Activity恢复时的状态会乱套。排查链路第一步定位到show的调用时机。常见来源是网络回调、Handler延时、广播回调。第二步对这些异步来源加状态判断。我建议在show前做两个检查if (parentFragmentManager.isStateSaved) { // 状态正在保存等下一个适合的时机再弹 return }第三步如果你确定这个Dialog即使丢了也没关系比如“刷新成功”的提示可以用showAllowingStateLoss()。但我要说句实话showAllowingStateLoss()不是银弹它允许的是“丢失本次状态变更”如果用户正在等待这个Dialog的确认结果状态丢了会引发更隐蔽的业务问题。所以业务上重要的Dialog宁可延迟到onResume再弹也不要图省事。5.3 深浅色切换后Dialog主题没跟上现象App已经适配了深色模式普通的页面都正常但DialogFragment背景还是白色的或者按钮文字变成白底白字看不见。排查链路第一步检查项目主题里是否设置了dialogTheme或alertDialogTheme。如果设了一个固定浅色Theme深色模式自然失效。第二步确认自定义style是否正确继承了DayNight系列!-- values/themes.xml -- style nameTheme.App parentTheme.MaterialComponents.DayNight.NoActionBar item nameandroid:dialogThemestyle/Theme.App.Dialog/item item namealertDialogThemestyle/Theme.App.Dialog/item /style style nameTheme.App.Dialog parentTheme.MaterialComponents.DayNight.Dialog.Alert item nameandroid:windowBackgrounddrawable/bg_dialog/item item namecolorSurface?attr/colorSurface/item /style在这里bg_dialog这个drawable里的颜色也要用?attr/colorSurface这类主题属性而不是写死#FFFFFFshape xmlns:androidhttp://schemas.android.com/apk/res/android solid android:color?attr/colorSurface / corners android:radius16dp / /shape第三步如果项目走的是自定义样式、没有用Material主题属性那就在Fragment里根据当前模式手动切换val isDark resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK Configuration.UI_MODE_NIGHT_YES不过这种硬编码方式只适合临时救急长期维护还是建议把颜色收敛到主题属性里。根据我自己的经验Dialog阴影和背景最容易在深浅色切换时翻车。配置好后记得把系统切到深色模式用Dialog手动旋转一次、再杀进程恢复一次三个维度测完再合代码。我现在的习惯是把所有Dialog的主题配置独立成一个style和页面主题分开维护。这样做的好处是页面主题无论怎么演进弹窗样式都能稳定不炸。配合着DialogFragment的生命周期托管、arguments传参、ViewModel数据兜底一套弹窗代码面对旋转、杀进程、深浅色切换都能保持行为一致。写DialogFragment与其说是学一个组件不如说是把Android的状态管理思路在“弹窗”这个场景里彻底想明白了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门