ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android嵌套Fragment多Tab完整方案:从FragmentManager到状态恢复

Android嵌套Fragment多Tab完整方案:从FragmentManager到状态恢复 简介面向Android开发者的片段嵌套实战项目聚焦多标签页中父片段内再嵌子片段的典型场景完整演示了标签栏与滑动页面联动、页面适配器两种选择、子片段管理器用法、子片段与主片段通信等核心实现。压缩包共330个文件整体大小10.42MB包含25个Java源文件、40个XML布局与配置、175张PNG图片资源以及编译生成的class字节码、jar依赖库、so动态库、dex可执行文件、apk安装包等完整产物既可导入Android Studio直接运行也可通过APK快速安装体验。已有1201人学习下载。通过此资源可深入理解片段嵌套下的生命周期差异、事件回调与消息传递机制并掌握滑动状态适配器等性能优化手段同时还能揣摩作者对界面拆解与复用结构的权衡。适合有一定Android基础、希望系统构建高扩展性多标签页应用的开发者对照学习。 做 Android 开发的这几年我遇到过太多页面里套页面的需求App 底部主 tab 切到首页首页顶上一排子 tab比如推荐和关注或者一个订单中心上半部分是状态筛选下半部分是对应列表各自又是独立页面。要用 Fragment 来实现这种多 tab 结构几乎每个项目都躲不开嵌套 Fragment 的写法。但很多人写嵌套 Fragment 时都吃过亏要么切换 tab 闪白屏要么 App 退到后台一会儿再回来页面叠成一摞要么干脆直接崩掉。实际上大部分问题不是 Fragment 本身难用而是没搞清楚内层 Fragment 到底该归谁管。这篇我把嵌套 Fragment 做多 tab 的完整方案和踩坑记录一次讲清楚从 FragmentManager 的选型、动态切换实现到状态恢复、ViewPager2 方案再到懒加载和通信都会给出可复现的代码和解释。1. 嵌套 Fragment 不是你想嵌就能嵌先搞懂 FragmentManager 的两张桌子说一个最典型的错误在 Fragment A 里要动态添加子 Fragment B代码顺手就写成getSupportFragmentManager().beginTransaction().add(R.id.container, b).commit()然后在横竖屏切换、App 退后台被回收、快速点击 tab 切换时会偶发崩溃报错内容是IllegalArgumentException: No view found for id 0x... for fragment B这个报错刚出现时我也有点懵id 明明写在布局里怎么会找不到 view排查之后才意识到根因——你把 B 的事务交给了 Activity 的 SupportFragmentManager但它管辖的是 Activity 级别的视图层级根本不知道 A 的内部布局长什么样。你让一个管家去另一个套房里找家具它自然找不到。1.1 每个 Fragment 都自带一个子管理器Fragment 内部其实持有一个独立的 FragmentManager通过getChildFragmentManager()获取。你可以把 Activity 的 SupportFragmentManager 理解成整个 App 页面的大抽屉里面放着 Activity 直接管理的 Fragment而每个 Fragment 又有自己独立的小抽屉专门管理它内部嵌套的 Fragment。嵌套 Fragment 的所有事务必须提交到宿主 Fragment 的 childFragmentManager 上。// FragmentA 内部添加子 Fragment必须用 childFragmentManager childFragmentManager.beginTransaction() .add(R.id.fragment_container, FragmentB.newInstance()) .commit()1.2 三个 Manager 到底怎么选我把这三个方法整理成了一张对比表方便你对照自己的场景方法管辖范围嵌套场景怎么用getSupportFragmentManager()Activity 层级的 Fragment管理 Activity 直接子 Fragment不能用于 Fragment 嵌套场景getChildFragmentManager()当前 Fragment 层级的 Fragment管理当前 Fragment 内部的 Fragment嵌套时必选getFragmentManager()Activity 层级旧 API已废弃新项目直接用 AndroidX别再用它还有一个容易忽略的点getChildFragmentManager()拿到的对象在宿主 Fragment 不同生命周期阶段可用性是不一样的。如果你在 onCreateView 里就急着 commit 子事务可能因为宿主 Fragment 还没有创建完视图导致事务执行时机不正确。我习惯把子 Fragment 的初始化放在onViewCreated里做再往后放一些也没问题但不要再往前了。1.3 用错 Manager 的另一个坑生命周期失控用错 Manager 还有一个隐蔽问题即使当时没崩溃子 Fragment 的生命周期也不会跟着宿主走。比如宿主 Fragment 被addToBackStack压到后台如果你错误地把子 Fragment 交给了 Activity 的 manager子 Fragment 可能完全不感知宿主的不可见状态还在保持 resumed后台持续持有资源、定时器照跑、某些回调照样执行。这种 bug 看 log 很难定位你会以为是内存泄漏或者事件分发问题。正确做法归纳起来就一句话谁的布局里放子 Fragment就用谁的 childFragmentManager。这句话是我踩了两次坑之后总结出来的第一次是闪退第二次是页面状态错乱本质都是 Manager 选错导致的。2. 构建多 tab 页面从静态嵌套到动态切换我推荐 show/hide 完整方案搞定了 Manager 选择接下来就是怎么把一个带多个 tab 的页面搭起来。这里有两种主流做法静态嵌套和动态切换适用场景不一样实现成本也不一样。2.1 静态嵌套只适合固定卡槽的轻量场景静态嵌套是指直接在宿主 Fragment 的布局 XML 里写fragment标签或者用 FragmentContainerView。LinearLayout android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical com.google.android.material.tabs.TabLayout android:idid/tab_layout android:layout_widthmatch_parent android:layout_height48dp / FragmentContainerView android:idid/tab_recommend android:namecom.example.RecommendFragment android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / FragmentContainerView android:idid/tab_follow android:namecom.example.FollowFragment android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / /LinearLayout这种方式一进来所有子 Fragment 就会一次性创建适合 tab 数量少、内容轻、切换无需重建页面的场景。但注意FragmentContainerView 的name属性对应的 Fragment 必须有公开的无参构造器不然实例化阶段直接抛异常。而且所有子 Fragment 同时创建如果每个子 Fragment 都有复杂布局启动速度会明显变慢。2.2 动态切换的核心封装add show hide 组合真正做多 tab我更推荐用 FragmentTransaction 的 add、show、hide 组合。核心思路是首次切到某个 tab 时才 add 进去之后切换只做 hide 当前页、show 目标页。我封装了一个 TabFragmentHelper避免在每个页面里重复写切换逻辑object TabFragmentHelper { fun switch( childFragmentManager: FragmentManager, containerId: Int, tag: String, newInstance: () - Fragment ) { val transaction childFragmentManager.beginTransaction() // 先将当前所有可见的 Fragment 隐藏 childFragmentManager.fragments.forEach { fragment - if (fragment ! null !fragment.isHidden) { transaction.hide(fragment) } } // 目标 Fragment 未创建则 add已创建则 show val target childFragmentManager.findFragmentByTag(tag) if (target null) { transaction.add(containerId, newInstance(), tag) } else { transaction.show(target) } transaction.commit() } }使用方式很简单// 在宿主 Fragment 内切换 tab binding.tabRecommend.setOnClickListener { TabFragmentHelper.switch( childFragmentManager, R.id.fragment_container, TAB_RECOMMEND ) { RecommendFragment.newInstance() } currentTag TAB_RECOMMEND }这里有三个关键细节值得多说几句。第一hide 的逻辑我采用全隐藏再 show 目标的方式保证任意时刻只有一个 Fragment 可见不会出现两个 tab 内容叠在一起。如果你的页面里还有一个常驻的头部 Fragment 不需要隐藏记得在遍历时把它排除掉。第二每个 tab 通过 tag 来区分 Fragment 实例。tag 是 FragmentManager 查找 Fragment 的稳定标识后续不管是状态恢复还是手动移除都靠它。第三add 进去的 Fragment 不要调用addToBackStack()。多 tab 场景里如果点了 tab 还会往返回栈里压一页用户按返回键时整个页面栈都是混乱的tab 切换会变得特别难理解。我的经验是tab 内容切换和返回栈要彻底解耦返回栈只留给页面跳转这种导航行为。2.3 为什么推荐 show/hide 而不是 replace有些同学图省事用 replace功能上确实能跑通但 replace 每次切换都会把旧的 Fragment 销毁、重建新的。这意味着用户切到 tab1 看完内容再切到 tab2 再切回来tab1 的列表滑动位置丢了、表单输入内容清空了网络数据还得重新拉一遍。对多 tab 场景来说这种体验基本等于不合格。show/hide 不会有这个问题。hide 只是把 Fragment 的视图从窗口管理里移除实例和视图状态都还保留着重新 show 出来时不会重新走完整的生命周期流程onCreateView 不会再次调用之前的滑动位置、EditText 内容都还在。代价是内存里会保存所有创建过的子 Fragment 实例如果 tab 数量太多比如超过五六个内存和视图树复杂度会明显上升。但大多数 App 的 tab 数量就三到五个这个代价完全可以接受。3. 进程杀死后的恢复与 Fragment 重叠嵌套场景最隐蔽的崩溃源头如果你只是做简单的 tab 切换上面那段代码已经能跑了。但放到真实 App 里问题会接踵而至。最让人头疼的就是 Fragment 重叠和恢复崩溃。3.1 最常见的崩溃场景进程被系统回收后恢复Android 的低内存回收机制加上用户切到后台待一会儿极有可能把 App 进程杀死。等用户再回到 App系统会根据任务栈恢复 Activity同时尝试恢复之前记录的所有 Fragment 状态。此时如果你在宿主 Fragment 的 onCreateView 里无条件 new 一堆子 Fragment add 进去就会发生FragmentManager 已经自动恢复了旧 Fragment你又手动 add 了新 Fragment的情况最终页面上所有 tab 内容全部叠加显示交互完全错乱。这种 bug 在开发阶段很难发现因为模拟器和真机的内存回收时机不一样往往要等线上反馈。3.2 重叠的本质系统自动恢复加你手动 add 的重复创建要理解怎么处理先明确一点FragmentManager 是有状态自动恢复机制的。Activity 因配置变更旋转屏幕或进程重建时FragmentManager 会尝试重新创建它之前管理过的所有 Fragment并附加到原来的位置。很多人也写了判空逻辑比如if (childFragmentManager.findFragmentByTag(TAB_RECOMMEND) null) { childFragmentManager.beginTransaction() .add(R.id.container, RecommendFragment.newInstance(), TAB_RECOMMEND) .commit() }这看起来没问题但有一个非常隐蔽的时机问题如果在宿主 Fragment 的 onViewCreated 或更早时机提交事务而宿主 Fragment 本身又是在 Activity 恢复过程中被重建的此时 FragmentManager 可能还没有执行完内部的恢复事务。你在判空时findFragmentByTag返回 null于是提交了一个 add 操作但 FragmentManager 内部其实已经持有该 Fragment 的恢复记录了等事务真正执行时旧的恢复 Fragment 和新的 add Fragment 同时存在就叠起来了。3.3 我使用的重建策略用 savedInstanceState 判断首次还是恢复针对这个问题我目前最稳妥的做法是不再依赖 tag 判空来防重叠而是用 savedInstanceState 区分首次创建和进程恢复两个场景。class HostFragment : Fragment() { private var currentTag: String TAB_RECOMMEND override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (savedInstanceState null) { // 首次创建正常 add 第一批子 Fragment TabFragmentHelper.switch( childFragmentManager, R.id.fragment_container, TAB_RECOMMEND ) { RecommendFragment.newInstance() } currentTag TAB_RECOMMEND } else { // 进程被系统恢复FragmentManager 已经自动恢复了所有子 Fragment // 只需要根据保存的当前 tab 校正 show/hide 状态 currentTag savedInstanceState.getString(KEY_CURRENT_TAB) ?: TAB_RECOMMEND setCurrentTab(currentTag) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_CURRENT_TAB, currentTag) } private fun setCurrentTab(tag: String) { // 隐藏所有show 出目标 tab childFragmentManager.fragments.forEach { fragment - if (fragment ! null) { childFragmentManager.beginTransaction() .hide(fragment) .show(fragment) .commitNow() } } } }这里有个知识点要展开进程被系统杀死后宿主 Fragment 的savedInstanceState不是 null而内部 childFragmentManager 管理的子 Fragment会由系统在宿主 Fragment 恢复时一起重建。所以你不需要在恢复路径里重新 add只需要根据之前保存的 currentTag 把正确的 tab 显示出来。commitNow 在这个场景下也比 commit 更可靠因为它是同步执行的能保证在你去做其他逻辑之前show/hide 已经生效。3.4 setRetainInstance 的常见误区还有一个高频误区为了防止 Fragment 重建在新项目里写setRetainInstance(true)。这个 API 本质上只对配置变更比如旋转屏幕生效进程被杀恢复时它救不了你。而且它对增加了返回栈的 Fragment 行为表现很不稳定现在官方也不鼓励依赖它做数据保存。嵌套 Fragment 场景下你只需要记住数据层面的恢复交给 ViewModel页面结构层面的恢复交给 FragmentManager 自动机制配合 savedInstanceState 里的标记位做校正这套组合比 setRetainInstance 可靠得多。4. 可滑动多 tab 用 ViewPager2与手写方案的取舍和嵌套陷阱前面讲的 show/hide 方案适合点 tab 切页面的交互。但需求是内容区可以左右滑动tab 跟着联动那就得换 ViewPager2 了。我见过很多项目在这两个方案之间反复横跳这里把我自己的选择标准说清楚。4.1 什么时候该用 ViewPager2如果产品明确要求内容区可以左右滑那别手写了直接用 ViewPager2 TabLayoutMediator。这是官方配套组合几乎零额外心智负担。val adapter object : FragmentStateAdapter(thisHostFragment) { override fun getItemCount(): Int 3 override fun createFragment(position: Int): Fragment { return when (position) { 0 - RecommendFragment.newInstance() 1 - FollowFragment.newInstance() else - LiveFragment.newInstance() } } } binding.viewPager2.adapter adapter TabLayoutMediator(binding.tabLayout, binding.viewPager2) { tab, position - tab.text listOf(推荐, 关注, 直播)[position] }.attach()这里有一个绝大多数人都会踩的坑FragmentStateAdapter 构造函数传入的this在嵌套场景里必须是宿主 Fragment而不是 Activity。如果你不小心传了 ActivityViewPager2 的页面就会脱离宿主 Fragment 的生命周期管理宿主 Fragment 销毁后页面还活着各种状态错乱接踵而来。4.2 预加载与懒加载的别扭之处ViewPager2 默认会预加载相邻页面。也就是说你停在第一个 tab第二个 tab 的 Fragment 可能已经创建onResume 也跑过了。如果你的 tab 页里有接口请求会出现默认页面同时发两个请求的情况。要解决这个问题还是得配合页面可见性判断来做真正的懒加载。setOffscreenPageLimit这个 APIViewPager2 默认值不再是 ViewPager 时代的 1而是 2意味着左右最多各保留两页。实测下来如果子 Fragment 布局比较复杂预加载两页对低端机内存不友好。可以手动设成 1binding.viewPager2.offscreenPageLimit 1但注意这个值不能设成 0ViewPager2 会直接忽略非法值不报错也不生效调试时会让人摸不着头脑。4.3 ViewPager2 嵌套 ViewPager2 的双层横滑陷阱如果你最外层的主 tab 也是 ViewPager2 实现子 tab 又是 ViewPager2那就是双重嵌套横滑。这种情况下手势冲突非常明显内容区横滑时内层 ViewPager2 通常会先消费掉事件导致外层主 tab 切不过去。处理方式一般是在内层 ViewPager2 重写onInterceptTouchEvent根据当前子页面的滚动位置决定是否拦截内层页面已经滑到最左或最右边界时把事件交还给外层没有到边界自己消费。这个事件分发逻辑复杂且易错我通常会给产品一个明确建议交互设计上尽量避免双层横滑实在避免不了再处理手势冲突别在开发阶段给自己挖坑。5. 项目实战里反复踩过的坑掉帧、懒加载、通信和 Tag 丢失前面讲的是架构方案这一部分是我在真实项目里踩过、也帮别人排查过的高频问题每个都花了不少时间。5.1 tab 切换掉帧问题往往不在 Fragment而在布局复杂度有段时间 tab 切换卡顿严重每次点击 tab 中间都有 200 到 300 毫秒的白屏感。我一开始怀疑是 FragmentTransaction 的事务开销后来用 Profile GPU Rendering 一看掉帧几乎全部集中在 layout 阶段。原因很简单子 Fragment 的根布局嵌套层级太深LinearLayout 套了三层里面还有 ConstraintLayout 的复杂约束切换时重新 measure 的时间肉眼可见。处理完布局层级把 12 层嵌套降到 6 层切换就顺滑了。所以如果你遇到 tab 切换掉帧先别急着优化 Fragment 逻辑打开布局检查器看看子 Fragment 的布局树深度往往收益更快。5.2 onHiddenChanged 与 setUserVisibleHint 的正确用法在 show/hide 方案里很多人用onHiddenChanged()感知页面可见性并触发数据加载。这里有个坑onHiddenChanged只在 Fragment 已经添加到 FragmentManager 且可见性状态真正发生变化时才会回调。第一次 add 时它不会回调这意味着首次展示的逻辑要在 onResume 或 onViewCreated 里单独处理后续切换才靠 onHiddenChanged。至于setUserVisibleHint这是 ViewPager 时代的产物ViewPager2 与 FragmentStateAdapter 配合时它已被标记为废弃行为优先级不稳定。现在做 ViewPager2 的懒加载更可靠的方式是配合registerOnPageChangeCallback判断当前页 position或者在子 Fragment 的onResume里用isVisible判断。我现在的习惯是在基类 Fragment 里维护首次可见标志位onResume 里判断 isVisible 为 true 时才触发加载方法后续不再重复触发。这套逻辑简单直接两个方案都能复用。5.3 子 Fragment 与宿主 Fragment 通信接口回调和共享 ViewModel多 tab 场景下子 Fragment 肯定要和宿主通信。比如 tab 页里点击了列表的某条数据宿主页面的标题或角标要跟着变化。传统做法是接口回调宿主 Fragment 实现子 Fragment 定义的回调接口在子 Fragment 的onAttach里拿引用。但嵌套配合 ViewPager2 时有个细节坑FragmentStateAdapter 预加载机制可能会在宿主 Fragment 尚未完全 attach 时创建子 Fragment子 Fragment 在 onAttach 里强转 parentFragment 的类型一旦时机不对就崩溃。我现在更推荐共享 ViewModel 的方案。在宿主 Fragment 里创建一个 ViewModel子 Fragment 通过requireParentFragment()拿到共享的 ViewModelStoreOwner然后观察数据变化class SharedViewModel : ViewModel() { private val _selectedData MutableLiveDataString() val selectedData: LiveDataString _selectedData fun selectData(id: String) { _selectedData.value id } }// 宿主和子 Fragment 里共用同一个 owner val sharedViewModel ViewModelProvider( requireParentFragment() )[SharedViewModel::class.java]ViewModel 天然兼容配置变更和进程重建数据不会因为 Fragment 重建而丢失接口回调里那些赋值时机不对的问题也彻底消失了代码量更少出错的概率也小很多。5.4 ViewPager2 的 Fragment 用 tag 找不到在 ViewPager2 FragmentStateAdapter 场景里子 Fragment 的 tag 由系统自动生成格式类似f position不是你手工设置的 tag。如果你在宿主 Fragment 里用findFragmentByTag(recommend)查找页面会返回 null很多人会卡在这个问题上一两个小时。正确做法是不要试图用 tag 去定位 ViewPager2 管理的 Fragment。需要传状态给当前页走共享 ViewModel或者在创建 Fragment 时用 arguments 传参。这个坑几乎每个转 ViewPager2 的人都会踩一次属于搜半天才能找到答案的典型。写在最后我在多个项目里反复用过手写 show/hide 和 ViewPager2 两种方式。总体来说如果产品没有滑动切换的需求我优先选手写 show/hide它更可控状态恢复逻辑也相对直观一旦设计稿里明确要求左右滑动切 tab就切到 ViewPager2 并配合 TabLayoutMediator把预加载和懒加载的边界处理好。嵌套 Fragment 本身不复杂复杂的是写代码之前有没有把 FragmentManager 的层级关系、状态恢复时机想清楚。最后再分享一个习惯每次写完嵌套 Fragment 相关的逻辑我都会做一轮退后台到开发者选项里勾选不保留活动再回前台的完整测试。这是最容易暴露 Fragment 状态问题的手段比翻半天源码快得多。你把这套思路理顺多 tab 页面就只是一个常规需求而已。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进