简介:面向Android开发者的Fragment嵌套实现多Tab页面的可运行示例工程,适合具备一定Fragment基础、想掌握TabLayout+ViewPager与子Fragment管理的中级学习者。资源包共330个文件,大小10.42MB,包含175张界面截图、65个编译后的class、40个XML布局与配置、25个Java源码、9个Jar依赖及7个so库等,另有可直接安装的VideoProject.apk,便于对照工程运行与拆解学习。项目覆盖FragmentPagerAdapter绑定、TabLayout联动、通过getChildFragmentManager完成嵌套Fragment事务、事件传递与生命周期处理等关键点;附带的资源与源码能帮助理清多层级Fragment的构建顺序和性能优化思路。已有1203人学习下载,适合用于快速搭建多Tab框架、排查嵌套Fragment常见问题,并作为自定义Tab样式与通信机制的参考实现。
1. 项目定位:Fragment 嵌套 Fragment 才是多 Tab 页面的完整姿势
在 Android 开发里,多 Tab 页面几乎是每个 App 的标配。很多开发者第一反应是 BottomNavigationView 加 ViewPager2,一套下来很顺畅。但当你遇到一个 Tab 页内部还要再分区块、或者需要完全控制切换行为时,ViewPager 的预加载和缓存策略会让人抓狂。这个项目演示的正是另一种更底层的做法:用一个外层 Fragment 作为容器,在它内部再嵌套管理一组子 Fragment,从而在单个页面上实现多 Tab 切换。它不依赖 ViewPager,完全通过 FragmentManager 控制,真正做到所有 Tab 的状态自主可控。适合已经用过 ViewPager、但想在复杂页面上获得更多掌控力的开发者,也适合想彻底搞懂 Fragment 嵌套原理的人。
2. 技术选型与架构设计:为什么嵌套 Fragment 而不是 ViewPager
2.1 多 Tab 页面的主流实现方案对比
在动手之前,先梳理一下常见的几种多 Tab 方案,看清楚各自的边界。很多项目跑着跑着就出问题,根源往往不是代码 bug,而是方案选错了。
| 方案 | 核心机制 | 优点 | 缺点 |
|---|---|---|---|
| Activity + 多个 Fragment | 切换时 replace 或 show/hide,由 Activity 管理 | 逻辑简单,状态可控 | 每个 Tab 都是独立页面,无法在页内再分小 Tab |
| ViewPager2 + FragmentStateAdapter | 左右滑动,预加载相邻页面 | 滑动流畅,支持手势 | 预加载机制导致生命周期不受控,无法精确指定某个 Tab 存活 |
| 外层 Fragment 嵌套子 Fragment(本项目) | 外层 Fragment 通过 getChildFragmentManager() 管理一组子 Fragment | 层级清晰,每个 Tab 都可再嵌套,状态完全自主 | 需要自己处理切换逻辑和生命周期,踩坑较多 |
从对比可以看出来,如果只是普通的一级 Tab,ViewPager2 其实够用。但一旦你的 Tab 页内部还有二级 Tab,或者有些 Tab 需要懒加载、需要长时间保留状态,嵌套 Fragment 的灵活性就体现出来了。这个项目选的就是第三种:外层一个 Fragment 充当容器,子 Fragment 全部由它自己管理,不依赖 Activity 的 FragmentManager。
这里要重点理解一个思维转变:ViewPager2 的方案里,页面切换是靠适配器生成 Fragment,Fragment 的存活由 ViewPager 的 RecyclerView 决定,你很难精确控制某个 Fragment 什么时候创建、什么时候销毁。而嵌套 Fragment 的方案里,外层 Fragment 掌握了全部控制权,你可以自由决定 add、remove、hide、show,相当于把页签切换的调度权收回自己手里。代价是自己要处理的事务边界、生命周期耦合、返回栈问题都变多了,这正是这个项目想展示的核心内容。
2.2 项目目录结构与依赖说明
这个项目的源码包结构不复杂,核心目录大概是这样:
app/src/main/java/com/example/demo/ ├── MainActivity.kt ├── outer/ │ ├── OuterTabFragment.kt │ └── TabMessageListener.kt └── inner/ ├── TabOneFragment.kt ├── TabTwoFragment.kt └── TabThreeFragment.kt app/src/main/res/layout/ ├── activity_main.xml ├── layout_outer_tab.xml ├── layout_tab_one.xml ├── layout_tab_two.xml └── layout_tab_three.xmlMainActivity 只做一件事:把 OuterTabFragment 添加进 Activity 的内容区域。真正的 Tab 逻辑全在 OuterTabFragment 里。它持有三个子 Fragment 的实例,并通过一个switchTab(index)方法来控制显示和隐藏。要注意这里用的 FragmentManager 不是supportFragmentManager,而是childFragmentManager——这个区别是整个嵌套方案的地基。用错了,子 Fragment 会串到 Activity 层级上,页面直接崩溃。
依赖方面,这个项目没有添加任何第三方 Fragment 或 Tab 库,只用了 AndroidX 的 Fragment 和 AppCompat。这是有意为之:嵌套 Fragment 本身就是系统能力,不需要额外依赖。如果你在自己的项目里引入这个方案,也不用担心和现有依赖冲突。Tab 的样式是用 TextView 做的,切换时改变文字颜色和背景,这样做的目的是把核心逻辑和 UI 库解耦,方便你换任何 Tab 组件。
2.3 生命周期与状态的传递思路
嵌套 Fragment 最让人头大的就是生命周期。外层 Fragment 和子 Fragment 的生命周期是强耦合的:外层 onStart,子 Fragment 跟着 onStart;外层 onStop,子 Fragment 跟着 onStop;外层 onDestroyView,所有子 Fragment 的视图也会被销毁。这种耦合决定了你不能把子 Fragment 当成完全独立的页面来对待。
所以当外层 Fragment 的视图因为某种原因被销毁(比如屏幕旋转),子 Fragment 的视图也会销毁,但子 Fragment 实例会被 FragmentManager 自动保存。旋转过后,子 Fragment 的onCreateView会再次被调用,重新创建视图。如果你在子 Fragment 里持有了一些重量级数据(比如 Bitmap),一定要注意在onDestroyView里释放,否则容易内存泄漏。
项目里有一个很关键的设计:三个子 Fragment 首次创建后就用 show/hide 控制,而不是 replace。replace 会销毁旧 Fragment 的视图并创建新 Fragment,状态丢失;而 show/hide 只是改变视图可见性,Fragment 实例、成员变量、View 状态都还在。配合FragmentManager自动保存恢复,可以在旋转屏幕后恢复到切换之前的 Tab 位置。下面把这套切换逻辑拆开来讲。
3. 核心模块拆解:从点击 Tab 到子 Fragment 切换的完整链路
3.1 外层容器的布局与初始化
先看外层 Fragment 的布局文件,其实就是一个空壳子:
<!-- layout_outer_tab.xml --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <LinearLayout android:id="@+id/tab_container" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <TextView android:id="@+id/tab_one" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:padding="16dp" android:gravity="center" android:text="Tab 1" /> <TextView android:id="@+id/tab_two" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:padding="16dp" android:gravity="center" android:text="Tab 2" /> <TextView android:id="@+id/tab_three" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:padding="16dp" android:gravity="center" android:text="Tab 3" /> </LinearLayout> <FrameLayout android:id="@+id/content_container" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> </LinearLayout>上面三个 TextView 作为 Tab 入口,下面一个 FrameLayout 作为子 Fragment 的容器。注意FrameLayout的高度用0dp + weight占满剩余空间,这样子 Fragment 内容不会受挤压。如果你要自定义 Tab 样式,把上面的 LinearLayout 替换成 TabLayout 或者任意组件都可以,关键在于content_container必须在整个布局中唯一。
Activity 的布局同样简单,就一个 FrameLayout 用来放外层 Fragment:
<!-- activity_main.xml --> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/activity_content" android:layout_width="match_parent" android:layout_height="match_parent" />MainActivity 中加载外层 Fragment 的方式也和普通 Fragment 一样:
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) if (savedInstanceState == null) { supportFragmentManager.beginTransaction() .replace(R.id.activity_content, OuterTabFragment()) .commit() } } }这里用了supportFragmentManager,因为是 Activity 管理外层 Fragment。Activity 的容器 id 是activity_content,和外层 Fragment 内部的content_container是两个完全不同的 id,千万别混。运行时如果子 Fragment 没有显示,最常见的原因就是 add 时的容器 id 写到了activity_content上,结果子 Fragment 跑到 Activity 层级去了。
3.2 子 Fragment 的切换与管理
接下来是核心的switchTab方法。我在原项目基础上稍微整理,加上注释方便理解:
class OuterTabFragment : Fragment() { private val tabOneFragment by lazy { TabOneFragment() } private val tabTwoFragment by lazy { TabTwoFragment() } private val tabThreeFragment by lazy { TabThreeFragment() } private var currentIndex = 0 override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return inflater.inflate(R.layout.layout_outer_tab, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 恢复上次保存的选中 Tab,处理旋转屏幕的情况 if (savedInstanceState != null) { @Suppress("DEPRECATION") currentIndex = savedInstanceState.getInt("current_index", 0) } // 初始化:把第一个子 Fragment add 进容器 switchTab(currentIndex) view.findViewById<TextView>(R.id.tab_one).setOnClickListener { switchTab(0) } view.findViewById<TextView>(R.id.tab_two).setOnClickListener { switchTab(1) } view.findViewById<TextView>(R.id.tab_three).setOnClickListener { switchTab(2) } } private fun switchTab(index: Int) { val fragmentManager = childFragmentManager val targetFragment = when (index) { 0 -> tabOneFragment 1 -> tabTwoFragment 2 -> tabThreeFragment else -> tabOneFragment } val transaction = fragmentManager.beginTransaction() // 先隐藏当前所有子 Fragment(除目标外) fragmentManager.fragments.forEach { fragment -> if (fragment !== targetFragment) { transaction.hide(fragment) } } // 如果目标 Fragment 还没被 add 过,则 add;否则 show if (!targetFragment.isAdded) { transaction.add(R.id.content_container, targetFragment) } else { transaction.show(targetFragment) } transaction.commitNow() currentIndex = index updateTabStyle(index) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt("current_index", currentIndex) } }这段代码里藏了好几个容易翻车的细节,逐个说。
childFragmentManager是当前 Fragment 自己的 FragmentManager,专门管理自己内部的子 Fragment。fragments属性返回该 manager 管理的所有 Fragment 列表。注意这个列表里可能包含已经 hidden 或 detached 的 Fragment,但不会包含已经 remove 掉的。所以遍历时能拿到所有活跃的子 Fragment,用hide把非目标的藏起来。
hide和show只是改变 Fragment 视图的可见性,不会销毁 Fragment 的视图和状态。add才是第一次把 Fragment 的视图加到容器里。isAdded判断的是 Fragment 是否已关联到 FragmentManager,而不是视图是否可见。当你第一次调用switchTab(0)时,三个子 Fragment 都还没 add,所以遍历隐藏时没有效果,接着把 TabOneFragment add 进去,视图自然显示。
commitNow()是同步提交事务。在点击事件这种非异常状态下的 UI 操作,同步提交能保证事务立即执行。如果使用commit(),Android 会把事务放到主线程的排队队列里,等你当前代码跑完才执行。在频繁切换 Tab 时,commit()可能导致上一次的事务还没执行,下一次又开始,从而抛出Fragment already added之类的异常。我一般在 show/hide 这种即时切换需求里都用commitNow(),性能和安全都更好。
还有一点值得注意:遍历fragmentManager.fragments隐藏 Fragment 时,如果某个 Fragment 处于hidden状态,调用hide不会重复触发。但如果某个 Fragment 已经被detach,它不在列表里,你没法用hide隐藏它,得用show先 add 回来。这个项目没有用 detach,所以不涉及。
3.3 Tab 间的通信与数据共享
Tab 之间经常需要通信,比如某个 Tab 内修改了用户头像,切换到另一个 Tab 时要同步更新。项目里设计了一个简单的接口方案:
interface TabMessageListener { fun onMessageFromTab(sender: String, message: String) }外层 Fragment 实现这个接口,并且在子 Fragment 创建后把监听器传进去:
// 在 OuterTabFragment 中 class OuterTabFragment : Fragment(), TabMessageListener { override fun onMessageFromTab(sender: String, message: String) { // 根据 sender 和 message 分发给其他子 Fragment when (sender) { "tab_one" -> tabTwoFragment.updateMessage(message) "tab_two" -> tabThreeFragment.updateMessage(message) } } private fun connectChild(fragment: Fragment) { if (fragment is TabMessageListenerHost) { fragment.setOnMessageListener(this) } } }接口方案的优点是直接、没有额外依赖,但缺点也很明显:一旦外层 Fragment 重建(比如旋转屏幕),监听器可能会丢失。所以我在二次开发时更推荐用 ViewModel 方案:
class SharedTabViewModel : ViewModel() { private val _message = MutableLiveData<String>() val message: LiveData<String> = _message fun postMessage(msg: String) { _message.value = msg } }每个子 Fragment 通过同一个ViewModelProvider(requireActivity())拿到同一个实例,消息用 LiveData 传递。这样就算 Fragment 重建,ViewModel 也不会丢,数据自然还在。实际项目里,用 ViewModel 做通信会更稳,代码维护起来也更容易。下面的避坑指南里,我会把嵌套 Fragment 最常见的几个问题列出来,都是我自己踩过或者帮别人排查过的真实案例。
4. 避坑指南:嵌套 Fragment 的常见问题与排查
4.1 子 Fragment 视图重叠,多次 add 导致崩溃
现象:切到某个 Tab 后,页面上出现了多个 Fragment 的内容叠在一起,或者直接抛IllegalStateException: Fragment already added。
原因:在switchTab里每次都对同一个 Fragment 执行add,而没有判断isAdded,导致同一个 Fragment 被添加了多次。还有一种情况是:你用了replace(),但 replace 不会清空之前 add 的 Fragment,如果反复用 replace 同一个容器,旧 Fragment 可能没有完全移除。
解决:在 add 之前必须判断targetFragment.isAdded,如果已经添加过,就改用 show。上面的核心代码里已经处理了这一点。另外,不要在同一个事务里对同一个 Fragment 同时 show 和 hide,否则最终状态不可预期,可能会出现视图隐藏了但还占着容器位置的问题。
4.2 误用 Activity 的 FragmentManager 操作子 Fragment
现象:在外层 Fragment 里写了fragmentManager?.beginTransaction()?.add(...),结果子 Fragment 被加到 Activity 的层级中,和 ViewPager 的页面混在一起,返回键行为诡异。
原因:外层 Fragment 不是 Activity,它没有资格直接向 Activity 的 FragmentManager 添加另一个 Fragment。管理子 Fragment 必须用childFragmentManager。用getParentFragmentManager()或getFragmentManager()会导致子 Fragment 的父 Fragment 不是外层 Fragment,生命周期与预期完全不符。
解决:把所有嵌套操作统一改成childFragmentManager,并且子 Fragment 内如果还要再嵌套,就继续用它的childFragmentManager。一句话:谁管理,谁调用自己的 childFragmentManager。排查时可以在子 Fragment 的onCreateView里打印parentFragment,看看是不是外层 Fragment 的实例。
4.3 切换后页面空白,子 Fragment 没有显示
现象:点击某个 Tab,容器区域空白,但 Logcat 没有报错。
原因:很可能是hide/show的时候,目标 Fragment 的视图还没被添加到容器里,或者容器 id 写错了。还有一个常见原因:在switchTab中先执行了transaction.hide再执行add,但由于事务中先 hide 了还没有 added 的 Fragment,导致异常或状态丢失。
解决:先判断目标 Fragment 是否已添加,再决定 add 还是 show,并且不要 hide 尚未 add 的 Fragment。检查R.id.content_container是否确实是父布局里的 FrameLayout。调试时可以打印每个 Fragment 的view是否为 null。另外,如果目标 Fragment 的根布局高度或宽度为 0,也会看不见,回布局里检查一下。
4.4 返回键直接把整个 Activity 退出了
现象:在子 Tab 页面中按返回键,期望的是退出当前 Tab 或回到上一页,结果应用直接退到桌面。
原因:Tab 切换时的 add/hide 操作没有使用addToBackStack,所以系统只把外层 Fragment 放在了返回栈里,子 Fragment 的切换不计入返回栈。用户在外层 Fragment 内操作子 Tab,按返回键会默认交给 Activity 处理,直接退出。
解决:如果确实需要返回键逐层回退,可以在switchTab时对目标 Fragment 的 add 操作加上addToBackStack(null)。但注意加了之后,hide 的 Fragment 不会自动 show 回来,因为返回栈弹出的是整个事务,可能把当前 Tab 的状态也恢复了。通常做法是在 MainActivity 里重写onBackPressed,判断当前是哪个 Tab,决定是退到上一级还是退出应用。这个项目最初为了简化,没有做回退栈,所以返回键行为是直接退出,二次开发时要特别注意。
5. 运行与调试:从导入到跑通
5.1 环境要求与导入步骤
这个项目是基于 Kotlin 和标准 Android 构建体系写的,没有第三方 Fragment 库,所以导入非常简单。
- 解压源码包,得到一个完整的 Android Studio 工程目录,包括
settings.gradle、build.gradle、app/等。 - 用 Android Studio 打开,选择 Open 找到解压后的目录,等待 Gradle Sync 完成。建议使用较新的稳定版 Android Studio,确保 JDK 与 SDK 配置能跟上。
- 确认本地 SDK Platform 已安装到项目要求的 level。项目里默认配置了常见的
compileSdk和minSdk,如果你本地缺了对应的平台,Android Studio 会提示你安装,点确定即可。 - 连接模拟器或真机,点击运行按钮。项目入口是 MainActivity,它会把 OuterTabFragment 加载进来,然后你就能看到三个 Tab 点击切换的效果。
如果你打开时遇到 Gradle 下载依赖超时,先检查网络,然后换一个镜像源,或者手动下载对应版本 Gradle 包,重新 Sync。Android Studio 的 Sync 窗口会显示具体缺什么,照着提示补就行。如果提示 Kotlin 版本和 Android Gradle Plugin 版本不匹配,把 Kotlin 插件版本调整到和 AGP 兼容的区间,一般都能解决。
运行后,你会在屏幕上看到三个 tab,点击时下方内容区会切换,但不会有任何动画。如果你感觉切换生硬,可以给事务添加过渡动画,稍后在改造部分会讲。
5.2 核心调试技巧与日志定位
跑起来之后,想确认子 Fragment 是否真的被缓存了,建议在子 Fragment 的生命周期方法里加上日志:
class TabOneFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { Log.d("TabOneFragment", "onCreateView called") return inflater.inflate(R.layout.layout_tab_one, container, false) } override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) Log.d("TabOneFragment", "onHiddenChanged hidden=$hidden") } }疯狂点击各个 Tab,观察日志。如果每次切换都打onCreateView,说明你在用 replace 而不是 hide/show,你的 Fragment 每次都在重建。正常情况应该是只有第一次 add 时会打日志,之后切换只走onHiddenChanged。注意onHiddenChanged里 hidden 为 true 表示被隐藏,false 表示重新显示。
Android Studio 的 Layout Inspector 也能直观看到当前视图树:连接模拟器后,点击 Layout Inspector,选择当前界面,如果切换 Tab 后 FrameLayout 下面只有一个可见的 Fragment 视图,说明隐藏逻辑没问题;如果出现多个视图重叠或残留,就要回头检查 hide 的调用时机。
还有一个玄学问题:有时候子 Fragment 没有显示,但视图树里明明有,检查一下是不是子 Fragment 的根布局背景是透明的,内容刚好是空白。这种情况最容易让人误判成逻辑问题,其实只是渲染问题。建议在子 Fragment 根布局上设置一个不透明背景色,方便视觉定位。
6. 二次开发:把嵌套 Fragment 改造成自己的复杂页签架构
跑通这个项目只是第一步,实际业务里往往更复杂。我在二次开发时通常会做三件事:把外部容器从 Fragment 改成 Activity、把 Tab 切换改成 ViewPager2 兼容模式、把通信改成 ViewModel。
先说第一件事。当前架构是 MainActivity 加载 OuterTabFragment,然后 OuterTabFragment 再管理子 Tab。如果你确定自己永远不会在别的地方复用这个多 Tab 容器,完全可以把 OuterTabFragment 的逻辑直接挪到 MainActivity 里,用 supportFragmentManager 管理子 Fragment,省去一层嵌套。但如果你需要把这个容器嵌进一个更大的页面(比如主页是个 DrawerLayout,某个抽屉里有多 Tab 页面),保留 Fragment 外层才值得。
第二件事,有些产品设计需要左右滑动切换 Tab。你可以把switchTab逻辑换成 ViewPager2 的FragmentStateAdapter,但保留 hide/show 的思想比较难,因为 ViewPager2 会自动处理 Fragment 的 create 和 destroy。一个折中方案是:外层还是嵌套 Fragment,但内部每个 Tab 用 ViewPager2 再嵌套一层。这时候要格外小心,内层 ViewPager2 会使用它所在 Fragment 的 childFragmentManager,与外层的 childFragmentManager 会形成嵌套事务,做转场动画时要避免冲突。
第三件事,通信方案我强烈建议换成 ViewModel。在OuterTabFragment所在的 Activity 作用域里创建一个SharedTabViewModel,然后在子 Fragment 里用ViewModelProvider(requireActivity()).get()获取。配合 LiveData,你可以在任何 Tab 里修改共享数据,另一个 Tab 自动收到回调。这比接口回调稳定得多,而且不会因为 Fragment 重建而断掉。
如果你希望切换时有动画效果,可以在事务上设置setCustomAnimations():
transaction.setCustomAnimations( R.anim.slide_in, R.anim.slide_out, R.anim.slide_in, R.anim.slide_out )但这种动画遇到 hide/show 时要小心,特别是多个 Fragment 同时隐藏时,动画可能会有残留。我一般只会对 add 设置动画,对 hide 不设置。
最后说一个血泪经验:我以前做嵌套 Fragment 时,喜欢在onResume里做数据刷新,结果发现每次切换 Tab 都会触发多次刷新,后来才搞明白是 hide/show 也会导致 onResume 被调用。从那以后我每次做 Tab 页面,都会强制检查一遍子 Fragment 的可见性回调,把刷新逻辑放在onHiddenChanged里判断!hidden才执行。这个习惯帮我避开了很多线上问题,希望也能帮到你。
本文还有配套的精品资源,点击获取