
我的入门 Android 之旅几乎是从“顶部导航栏”开始的。不管是资讯类的栏目切换还是商城首页的分类入口顶部可滑动的 Tab 导航几乎是标配。早期很多项目会自定义一个横向滚动的 TextView 集合加上手写触摸事件去联动页面不仅代码量大联动还需自己算位移和惯性调试非常费劲。后来官方推出了 TabLayout ViewPager 这套组合再配合 Fragment 承载页面内容顶部导航栏的实现一下子变得既稳定又顺手。这篇文章我想把 TabLayout ViewPager Fragment 实现顶部导航的完整链路拆开讲清楚。不仅告诉你每行代码是做什么的更会解释这些代码背后的机制比如为什么 Fragment 页面不会卡顿、为什么要重写 getPageTitle、SetupWithViewPager 到底干了什么事。不管你是刚接触 Android 的小白还是已经写过几个 App 的中级开发者看完这篇都应该能把顶部导航栏这一套吃到透遇到问题也能自己定位。1. 核心思路拆解为什么是这三件套1.1 各组件分工其实很明确TabLayout、ViewPager、Fragment 不是三个平起平坐的组件它们各管一段职责很清楚TabLayout 负责顶部标签条的展示和点击。它可以横向滚动、显示固定数量的 TabTab也能带图标、自定义角标。ViewPager 负责页面内容的横向滑动。它内部有一个继承自 ViewGroup 的容器用适配器模式动态填充每一页的内容并控制预加载、缓存、销毁等生命周期。Fragment 负责单页内容的结构和状态。它既可以保留自身的视图状态也能在页面切换时控制数据加载甚至配合 SavedState 在系统回收后恢复。把它们组合起来TabLayout 把点击事件同步给 ViewPagerViewPager 把滑动距离和页面切换状态同步给 TabLayoutFragment 则作为 ViewPager 里每一页的“内容单元”被创建和管理。这跟我早期自己手写一套联动逻辑相比最大的好处是成本和稳定性滑动时的手势冲突、页面缓存、宽度适配官方都帮你处理了。1.2 这套方案的取舍点有人会问如果只是点击切换不用滑动手势是不是可以不用 ViewPager直接通过 FragmentManager 切换 Fragment可以但没必要。单纯使用 FragmentManager 切换 Fragment你需要自己处理点击高亮、过渡动画、状态恢复。另外用户已经习惯了这种“内容横向滑”的交互预期直接点击切换总觉得手感生硬。ViewPager 能给你很多免费体验惯性滑动、紧绷回弹、左右预览、页面缓存。当然这套方案也不是万能。如果你的顶部导航有大量子项目而且子页面内部又各自带了复杂滚动比如嵌入了横滑列表甚至另一个 ViewPager手势冲突会明显增加这种时候你要评估是否真的需要顶部 Tab 还是改成侧边抽屉布局。大多数业务场景下三件套依然是最稳妥的顶部导航实现。2. 环境准备与依赖配置2.1 Gradle 依赖的正确添加方式早期项目还有 support 包和 AndroidX 的区分如果你接手的是一个老项目得先确认是否已经迁移到 AndroidX。新项目就不用纠结了直接在模块的 build.gradle 里添加依赖// 主要是这三个依赖 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.viewpager2:viewpager2:1.0.0这里有个细节如果你用的是 Google 官方 Material 库里的 TabLayout那么组件类名是com.google.android.material.tabs.TabLayout不是老 support 包里的android.support.design.widget.TabLayout。AndroidX 迁移后所有老包名都要换布局 XML 里的android.support.design.widget.TabLayout也得改掉否则编译直接报找不到类。版本方面我见过很多人在 material 库上随便用最新版本结果跟 compileSdk 版本不匹配或者跟 appcompat 不一致导致资源冲突。建议按官方稳定版本来别为了新鲜追最新适配问题最烦。2.2 创建基础 Fragment 页面不管顶部有多少个 Tab每个页面基本都长成一个 Fragment。比如我要做一个有三个 Tab 的顶部导航首页、消息、我的那就分别创建三个 Fragment 类。class HomeFragment : Fragment() { private lateinit var textView: TextView override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.fragment_home, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) textView view.findViewById(R.id.tv_title) // 每个页面可以单独加载自己的数据 // 比如拉取列表、显示文案、初始化子控件 } }这里的onCreateView和onViewCreated是两个容易混淆的点。onCreateView负责把布局 XML 变成 ViewonViewCreated表示 View 已经被加载进 ViewPager 容器此时再做子控件的绑定和赋值更安全。我见过新手在onCreateView里直接 findViewById也可以但要注意view参数才代表根布局别走到 Activity 的 findViewById 里去了。三个页面的 XML 很简单比如fragment_home.xml里面放一个居中的 TextView 表示占位。实际项目里就是一个完整的页面里面可以是列表、WebView 或者复杂的自定义控件对三件套实现来说都一样。?xml version1.0 encodingutf-8? FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent TextView android:idid/tv_title android:layout_widthwrap_content android:layout_heightwrap_content android:layout_gravitycenter android:text首页页面内容/ /FrameLayout2.3 适配器选择FragmentPagerAdapter 还是 FragmentStatePagerAdapter这一步很关键。很多人直接写一个类继承 FragmentPagerAdapter复制了网上的代码就跑没出事就继续出事就懵。这两个适配器最大的区别是页面生命周期管理策略FragmentPagerAdapter已经滑过的 Fragment 会保存在内存中不会销毁视图适合页面数量少、页面比较轻量、需要保留状态的场景。三个 Tab 用这个没问题。FragmentStatePagerAdapter超出缓存范围的 Fragment 会被销毁只保留状态适合页面数量多或者每个页面占内存大的场景比如几十个 Tab或者每个 Tab 里还有 WebView。我们做顶部导航栏通常页面数不多用 FragmentPagerAdapter 是合理的。但要注意它在 AndroidX 里已经对这个适配器进行了标记官方更推荐使用两个参数的构造方法并且让你传入BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT或BEHAVIOR_SET_USER_VISIBLE_HINT。我主要建议用BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT这个行为是当前可见 Fragment 才执行onResume非当前 Fragment 只执行onPause而不执行onResume能帮助你正确判断页面是否对用户可见。老版本那种setUserVisibleHint方式已经过时容易漏调不建议用。3. 从零实现顶部导航栏的完整过程3.1 Activity 布局怎么搭我推荐的布局有两种常见写法。第一种是用 CoordinatorLayout AppBarLayout 包裹 TabLayout这样整个顶部栏能更好地和 AppBar 的 elevation、滚动联动配合。第二种是简单的垂直 LinearLayout上面放 TabLayout下面放 ViewPager布局文件干净直接能用。先看简单版本?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res/auto android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical com.google.android.material.tabs.TabLayout android:idid/tablayout android:layout_widthmatch_parent android:layout_heightwrap_content app:tabModefixed app:tabIndicatorColorcolor/colorPrimary app:tabSelectedTextColorcolor/colorPrimary app:tabTextColorcolor/colorGray/ androidx.viewpager.widget.ViewPager android:idid/viewpager android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1/ /LinearLayout这个布局没有用 ViewPager2而是用传统 ViewPager。原因很简单TabLayout 和 ViewPager 组合的历史原因很多现有项目都是传统 ViewPager 写法。如果你打算用 ViewPager2那么适配器逻辑和关联方式都会变我会在后面单独讲区别。tabModefixed表示所有 Tab 保持固定宽度就算文本很短也会拉宽适合 Tab 少的场景。如果 Tab 数量超过屏幕宽度或者文字特别长可以设置成scrollable这时 TabLayout 变成横向可滚动并且会按内容宽度显示。3.2 写一个适配器并绑定 Fragment适配器要做三件事返回第几个位置的 Fragment、告诉 ViewPager 总共有多少页、提供 Tab 标题。标题可以从 Fragment 内部拿也可以直接在适配器里维护一个 String 数组。class HomePagerAdapter( fm: FragmentManager, behavior: Int ) : FragmentPagerAdapter(fm, behavior) { private val titleList listOf(首页, 消息, 我的) override fun getItem(position: Int): Fragment { return when (position) { 0 - HomeFragment() 1 - MessageFragment() else - MineFragment() } } override fun getCount(): Int titleList.size override fun getPageTitle(position: Int): CharSequence? titleList[position] }注意几个细节getItem方法返回的是原始 Fragment 实例不是已经存在缓存里的实例。ViewPager 内部会用instantiateItem管理缓存如果 Fragment 已经存在会优先复用不会每次重新创建。getCount必须和 Tab 数量一致否则切换时会越界。getPageTitle是 TabLayout 显示标题的数据来源。如果不重写setupWithViewPager 关联后就只能看到空 Tab。如果你用 Java代码逻辑完全一样只是变成匿名内部类或者单独的适配器类。我一般会把适配器单独拎出来而不是写成一个匿名内部类这样后续增加页面逻辑方便测试也更容易。3.3 关键一步setupWithViewPager 到底做了什么在 Activity 里绑定代码很简单val tabLayout findViewByIdTabLayout(R.id.tablayout) val viewPager findViewByIdViewPager(R.id.viewpager) val adapter HomePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) viewPager.adapter adapter tabLayout.setupWithViewPager(viewPager)很多人喜欢直接复制这三行就跑但如果你不知道 setupWithViewPager 的原理后面遇到“点击 Tab 不联动”“标题不显示”“页面错乱”时很难定位。我来展开讲一下。setupWithViewPager是 TabLayout 的一个便捷方法。它的内部逻辑大致是把 ViewPager 的 addOnPageChangeListener 注册进来这样 ViewPager 滑动时 TabLayout 能收到 onPageSelected、onPageScrolled 等回调从而更新选中状态和指示器位置。把自己内部的 OnTabSelectedListener 注册给 ViewPager这样点击 Tab 时能调用 ViewPager 的 setCurrentItem 切换页面。自动填充每个 Tab 的标题这个标题就是通过 adapter.getPageTitle(position) 获取的。处理一些生命周期状态比如移除旧监听器避免内存泄漏。所以如果你手动写两套监听逻辑或者先设置 setOnTabSelectedListener 再调用 setupWithViewPager可能会出现监听器被覆盖、回调不触发的情况。我的习惯是一切交给 setupWithViewPager要是后续需要单独监听点击事件就在 setupWithViewPager 之后再加上自己的 OnTabSelectedListener但不要在页面切换时重复 setCurrentItem。3.4 页面内容填充与数据加载三个 Fragment 页面在真实项目里不会只是占位文字而是会放列表、轮播图或者 WebView。数据加载放哪最合适我建议把数据请求放在onViewCreated或者onResume但不要每次切换都重复请求。比如首页有一个接口页面第一次展示时请求一次切到别 Tab 再切回来时是否重新请求取决于业务需求。如果是商品分类这种实时性强的可以在onResume里判断是否需要刷新但要加防抖和去重避免频繁切换导致接口轰炸。FragmentPagerAdapter 的默认缓存逻辑是会预加载左右相邻页面也就是说你在首页时消息页可能已经提前初始化了。如果消息页的 onCreateView 里有重逻辑比如解析大图、初始化地图 SDK就会拖慢启动速度。这时候可以考虑把重逻辑放在 onResume 判断可见后再执行或者把页面改成懒加载。懒加载的实现虽然有点反模式但在图片多、内容重的页面里有实际价值。懒加载的基础写法是记录 Fragment 是否已可见只有在首次可见且视图创建完成时才加载数据。传统上通过 getUserVisibleHint 判断AndroidX 新版本可以用 onResume 和 onPause 的组合因为前面提到的BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT已经保证了不可见 Fragment 不会执行 onResume。这个机制比老版本靠谱。3.5 从 Activity 传递参数到 Fragment如果每个 Tab 页面需要不同的参数比如分类 ID、商品类型建议在创建 Fragment 时用 Bundle 传参而不是直接通过构造方法传参数。原因很简单系统可能在内存不足时重建 Fragment重建时会调用无参构造方法参数会丢失。用 Bundle 传递的参数能由 Fragment 自行通过arguments恢复。class HomeFragment : Fragment() { private var categoryId: String? null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { bundle - categoryId bundle.getString(category_id) } } companion object { fun newInstance(categoryId: String): HomeFragment { val fragment HomeFragment() val args Bundle() args.putString(category_id, categoryId) fragment.arguments args return fragment } } }然后在适配器里创建页面时用 newInstance 传入参数override fun getItem(position: Int): Fragment { return when(position) { 0 - HomeFragment.newInstance(hot) 1 - MessageFragment.newInstance(notification) else - MineFragment.newInstance(profile) } }这一点新手踩坑比较多明明页面数据对不上排查半天结果是 Fragment 参数传丢了或者所有页面共用了同一个静态变量。养成用 Bundle 传参的习惯能省掉很多奇怪 bug。4. 顶部导航栏的自定义与体验优化4.1 定制 Tab 的文本样式和指示器默认的 TabLayout 是下划线指示器短横线会跟随页面滑动做平滑移动。如果你觉得不好看可以自定义app:tabIndicatorGravitytop把指示器放到顶部。app:tabIndicatorHeight2dp控制指示器厚度。app:tabIndicatorColor设置指示器颜色。app:tabSelectedTextColor和app:tabTextColor分别设置选中和未选中文字颜色。app:tabTextAppearance可以设置字号、字重但不建议在里面直接修改文字颜色容易和各状态的配色冲突。有时候产品要求选中的 Tab 标题加粗未选中不加粗。这里有个坑直接给 TabLayout 的 addOnTabSelectedListener 里改 TextView 的字体会闪烁。因为 TabLayout 在选中状态切换时会调用文本更新你手动改的字体状态可能被后面的刷新覆盖。更稳妥的做法是使用自定义 View 作为每个 Tab 的展示或者使用 ColorStateList 和自定义 Span 方式处理。不过大多数项目其实不需要这么细使用不同文字颜色已经够辨识度了。4.2 带图标 Tab 的实现方案如果顶部 Tab 需要图标比如一个“首页”带个小房子图标有三种常见做法使用 Material 库提供的自定义 tabView 设置图标也就是给每个 Tab 手动 setIcon。给每个 Tab 创建一个自定义布局里面放一个 ImageView 和一个 TextView。让每个 Tab 整体变成一个自定义 View完全控制点击和选中态。如果图标和文字要同时显示而且选中态需要切图标最直接的是自定义 TabView。在 onCreate 里遍历 TabLayout 已经建好的 Tab然后给 Tab 设置自定义视图。tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { tab?.setIcon(R.drawable.icon_selected) } override fun onTabUnselected(tab: TabLayout.Tab?) { tab?.setIcon(R.drawable.icon_unselected) } override fun onTabReselected(tab: TabLayout.Tab?) { // 用户重复点击当前 Tab可以处理回到列表顶部 } })不过要注意这种手动切换图标的方式要和 setupWithViewPager 同时使用监听器会叠加但你不能在 onTabSelected 里再调用 viewPager.setCurrentItem否则会和 setupWithViewPager 内部的 setCurrentItem 重复轻微地闪烁严重时会连续跳两次页面。4.3 页面切换动画和过渡效果ViewPager 自带的切换动画是横向滑动如果想让 Tab 切换时页面有淡入淡出效果可以用viewPager.pageTransformer设置自定义动画。比如做一个简单的缩放 Alpha 切换viewPager.pageTransformer ViewPager.PageTransformer { page, position - val alpha 1 - abs(position) page.alpha alpha.coerceIn(0.5f, 1f) page.scaleX (0.9f 0.1f * (1 - abs(position))).coerceIn(0.9f, 1f) }这个 position 是页面相对偏移0 代表完全显示-1 或 1 代表完全滑出。通过修改 page 的 alpha 和 scaleX 可以实现简单的视差缩放。但要注意 setOffscreenPageLimit 也要一起调整如果预加载页数太少动画还没播完页面就被回收体验反而更差。并且缩放效果如果用得太重低端机上会有锯齿和性能问题。4.4 ViewPager2 和 TabLayout 的差异如果你新开项目有人推荐直接上 ViewPager2 TabLayout这也是趋势。ViewPager2 基于 RecyclerView 实现不再使用 FragmentPagerAdapter而是通过 FragmentStateAdapter。两者的代码差异比较明显TabLayout 和 ViewPager2 不能直接用 setupWithViewPager需要用 TabLayoutMediator 来关联。FragmentStateAdapter 没有 getPageTitle 方法标题要通过 TabLayoutMediator 的 TabConfigurationStrategy 回调来设置。使用 ViewPager2 时方向默认从左到右横向但也能通过 orientation 改成纵向顶部导航一般不需要但这能看出它内部更灵活。用 ViewPager2 的示例class ViewPager2FragmentAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) { override fun getItemCount(): Int 3 override fun createFragment(position: Int): Fragment { return when(position) { 0 - HomeFragment() 1 - MessageFragment() else - MineFragment() } } }val tabLayout findViewByIdTabLayout(R.id.tablayout) val viewPager2 findViewByIdViewPager2(R.id.viewpager2) viewPager2.adapter ViewPager2FragmentAdapter(this) TabLayoutMediator(tabLayout, viewPager2) { tab, position - when(position) { 0 - tab.text 首页 1 - tab.text 消息 else - tab.text 我的 } }.attach()这里最容易犯的错误是忘记调用attach()结果 TabLayout 和 ViewPager2 完全脱离。另外如果适配器内容变化需要调用adapter.notifyItemChanged同时 TabLayoutMediator 的关联关系也最好重建。我的个人看法是存量项目如果三件套已经跑得很稳定没必要非迁移到 ViewPager2。新项目可以使用 ViewPager2但前提是团队已经能处理 RecyclerView 风格的生命周期和复用机制否则排坑成本更高。5. 踩坑实录与排查技巧5.1 ViewPager 的 Fragment 索引越界问题最常见的报错是IndexOutOfBoundsException或者抛出异常“Fragment no longer exists for key ...”. 这种情况在动态增删 Tab 时最容易出现。很多人会直接在外部维护一个 Fragment 列表建一个自定义的 FragmentPagerAdapter外部增删数据后调用 notifyDataSetChanged以为这样就完事了。但 FragmentPagerAdapter 在实例化时会把当前 Fragment 和索引强绑定如果你删掉了第一个位置的 Fragment原本第二个位置的 Fragment 在 ViewPager 内部的索引还是 1但按照新列表它应该变成 0内存和状态对不上就会崩。解决办法是不要仅依赖 notifyDataSetChanged 增删 Fragment要重写getItemPosition并返回POSITION_NONE让 ViewPager 重新创建对应项或者干脆避免运行时增删数量顶部 Tab 的栏目数量在页面上稳定不变比较多。如果确实要动态增减建议 ViewPager2 的方案它的 FragmentStateAdapter 对增删更友好一些。5.2 适配器里直接 new Fragment 导致的崩溃之前我提到过共享参数问题这里要扩展一下。如果你在适配器里每次都直接 new 一个新的 FragmentFragmentManager 会认为这是不同的 Fragment但是 ViewPager 内部又通过 tag 记录了每个 Fragment 的实例状态。一旦这个 Fragment 标记被复用而你又创建了新实例就会出现“Fragment already added”异常。解决方式和传参一样通过 same instance 或者 arguments 复用 Fragment。你在适配器里 new 出来的实例会被 ViewPager 保存不会每次 getItem 都调用。也就是说 getItem 只会在第一次创建时调用后续 ViewPager 走的是缓存所以同一个 Fragment 会被保存在FragmentManager中不要把它再塞进其他容器里。5.3 TabLayout 标题不显示或显示错乱这类问题十个有八个是因为没有重写getPageTitle。如果你自定义了 FragmentPagerAdaptergetPageTitle 默认返回 nullTabLayout 不知道每个 Tab 该显示什么自然只剩一串空白块。还有一种是重写了 getPageTitle 但返回的标题列表和 getCount 长度不一致导致 IndexOutOfBounds。还有一种情况是 setupWithViewPager 之后修改了 adapter 内容忘了再次调用 setupWithViewPager 或者重新设置 adapterTabLayout 不会自动刷新。如果你在动态更新标题应该先阻塞 Tab 状态再重新 setAdapter 或者触发 notifyDataSetChanged并确保 getPageTitle 能取到最新数据。5.4 页面内容被回收后的状态恢复问题使用 FragmentPagerAdapter 时如果 Fragment 被系统回收比如内存不足它的视图会销毁但 Fragment 实例和状态会保留。你切回这个页面会发现它重新执行 onCreateView原来的输入框内容、列表滚动位置如果没有保存就会恢复成初始状态。解决办法是按需保存状态。在 Fragment 里重写onSaveInstanceState把需要恢复的数据放到 Bundle 里然后在onViewCreated里读取并恢复。也可以配合 ViewModel把 UI 数据放到 ViewModel 中这样即使 Fragment 重建ViewModel 也能提供之前的数据。这个组合在顶部 Tab 页面里很实用尤其像消息页用户可能输入过搜索词列表滑到一半被回收回来还得从头翻非常影响体验。5.5 页面内列表和 ViewPager 的滑动冲突如果 Tab 页内部放了一个 RecyclerView用户上下滑动列表时可能会和 ViewPager 的左右滑动产生手势冲突。通常 ViewPager 是通过判断水平滑动距离来决定是否拦截的和 RecyclerView 的上下滑动并不会冲突但如果内容里还有横向滑动控件比如带横向滚动的 Banner情况就复杂了。一般解决办法是控制触摸事件的拦截策略在子 View 的onInterceptTouchEvent里判断如果横向滑动距离大于纵向距离就自己消费事件。或者在 ViewPager 的 dispatchTouchEvent 里根据子 View 是否可滑动来决定是否拦截。最省事的方式是尽量不要在列表项里嵌套横向大滑动的区域否则用户体验也容易飘。5.6 Badge 数字角标的添加产品经理经常会提需求某个 Tab 右上角要显示未读消息角标。这个 TabLayout 是支持 BadgeDrawable 的。val badge tabLayout.getTabAt(1)?.getOrCreateBadge() badge?.isVisible true badge?.number 5要注意的是getOrCreateBadge 会创建一个 BadgeDrawable但如果你后续想隐藏它需要设置isVisible false而不是直接 remove否则可能显示不一致。另外角标如果数字超过 99显示为 99这个上限是内置的也可以自定义 BadgeDrawable 的 maxCharacterCount。5.7 低端机上的性能优化顶部 Tab 页面在低端机上容易卡顿的原因常见有几个每个页面初始化就加载大量图片和复杂布局ViewPager 默认预加载相邻页面导致启动时多个页面同时构建CPU 压力大。页面里大量透明动画、属性动画比如 Tab 切换时每个页面都在跑动画。Fragment 页面里的列表没用 ViewHolder 复用或者下拉刷新控件和 ViewPager 联动处理不当。如果确实卡可以把setOffscreenPageLimit调成 1只预加载相邻一个页面页面里的图片用异步占位避免阻塞 UI避免在 Fragment onCreateView 里做耗时计算把重逻辑挪到子线程或延迟处理。还有一种常见操作是启动时把第一个 Fragment 的数据加载好其他页面先不加载等滑到附近再加载这种懒加载方案收益比较高。6. 几个我反复用到的实践经验6.1 用 Fragment 还是直接用 View没有绝对答案虽然标题是 TabLayoutViewPagerFragment但如果你每个 Tab 页面只是一个简单的纯 View比如某个统计页面只是几张静态图表也可以不创建 Fragment直接用 View 填充 ViewPager 的适配器。但大多数需求不是静态的会有网络请求、列表交互、状态恢复这些用 Fragment 能省很多事也更符合 Android 生态习惯。我的建议是只要页面里有状态或复杂交互就老老实实用 Fragment。6.2 拆掉 Adapter 和 Fragment 的耦合我曾在一个项目里看到适配器内部维护了所有 Fragment 实例并且大量使用全局静态变量传参导致多个 Tab 的数据互相污染。后来我把 Adapter 改成只通过 position 返回 Fragment参数都用 Bundle 传入瞬间清爽很多。适配器不应该知道业务数据它只负责“这个位置长什么样子”真正业务数据应该在对应的 Fragment 内部管理。6.3 记得处理 Fragment 的重建不要相信“跑起来就行”现在做顶部导航栏的门槛已经很低了复制代码能跑但不代表能稳定跑。处理系统和 FragmentManager 的联动尤其是状态恢复这一块如果你没做大概率在用户按 Home 键一段时间再回来时页面会出现奇怪的错乱。早期某项目就是这个问题页面从后台回来Tab 指示器指向第一个但内容是第三个页面原因是 TabLayout 选中状态恢复在 ViewPager 当前页恢复之前执行了。后来我在 onCreate 里判断 savedInstanceState 不为空时手动恢复当前页索引问题就解决了。这类细节每一条看起来都不起眼但攒多了就是 App 稳定性和流畅度的差距。顶部导航看起来是小功能实际上牵扯到 Fragment 生命周期、ViewPager 缓存、事件分发和状态恢复能把它做得丝滑无 bug对一个 Android 开发者来说绝对算基本功锤炼到位。