先把结论放在前面:ViewPager2 内部就是一个 RecyclerView,页面项由 FragmentStateAdapter 托管,Fragment 的生命周期由它按可见性自动触发。所以"RecyclerView 复用"的那套坑——itemViewType、getItemId、notifyDataSetChanged 的抖动——在 ViewPager2 上同样存在,而且因为它同时管理着 Fragment 实例,坑的破坏力更大。
ViewPager2 相比旧版 ViewPager 的改进不只是 API 变化,这题的坑都在边界上,逐个踩一遍才算真懂。
先讲清它为什么换成了 RecyclerView
旧版 ViewPager 的问题写在源码里:内部直接持有 View 列表和 SparseArray<Fragment>,没有复用机制,页面多了会吃掉大量内存;还有一个 notorious 的 bug——必须 setUserVisibleHint 才能让内部 Fragment 感知可见性,于是开发者到处手动调 setUserVisibleHint,与 FragmentManager 的可见性回调打架,表现为"加载两次"或"切回来还是旧数据"。
ViewPager2 的做法是彻底换掉内核:
- 内部用
RecyclerView.Adapter管理页面项,天然带复用,内存占用与页面数解耦。 - 用
FragmentStateAdapter托管 Fragment,可见性判断交给setMaxLifecycle,FragmentManager 负责把不可见页面的状态推进到STARTED之前(停在CREATED),并调用onPause。 - 用
LayoutManager的orientation决定横向或纵向,替代旧版单独的横向实现。 - 与
TabLayout联动改用TabLayoutMediator,不再需要FragmentPagerAdapter那一套。
class ReportPagerActivity : AppCompatActivity() {
private lateinit var binding: ActivityReportBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityReportBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.pager.adapter = ReportAdapter(this, SECTIONS)
binding.pager.offscreenPageLimit = 1 // 默认只缓存相邻一页
TabLayoutMediator(binding.tabs, binding.pager) {
tab, pos ->
tab.text = SECTIONS[pos].title
}.attach()
}
}
class ReportAdapter(activity: FragmentActivity, private val sections: List<Section>) :
FragmentStateAdapter(activity) {
override fun getItemCount() = sections.size
override fun createFragment(position: Int): Fragment =
SectionFragment.newInstance(sections[position].id)
// 关键:给出稳定的 id,避免复用时把旧 Fragment 的数据挂到新条目上
override fun getItemId(position: Int): Long = sections[position].id.toLong()
override fun containsItem(itemId: Long): Boolean =
sections.any {
it.id.toLong() == itemId }
}
getItemId + containsItem 这对方法是整题的核心。 FragmentStateAdapter 靠它们判断"这一项是不是原来那一项"。不重写的话默认 getItemId 返回位置下标,于是只要数据顺序变了,位置 0 的新数据会被认为还是位置 0 的旧 Fragment,页面内容错乱且不会重建。containsItem 则支撑"数据删除中间项"的情况,让对应 Fragment 被正确销毁而不是被错误复用。
最常见的坑是
旧版 setUserVisibleHint 在新版本已废弃仍被使用,时序混乱导致加载两次。
在 ViewPager2 + FragmentStateAdapter 下,Fragment 的可见性由 setMaxLifecycle 控制,onResume 才是真正的"可见"。如果还沿用旧版的 setUserVisibleHint 或自己写"当前页 == position 则加载数据"的逻辑,就会出现两套可见性判定:Fragment 生命周期说它可见了,页面自己的判定说还没,于是两个入口都触发了一次加载,网络请求发两次、日志出现重复请求。
修法很简单但要彻底:把"页面可见"的判断统一收敛到一个入口,只信 Fragment 生命周期。
class SectionFragment : Fragment(R.layout.fragment_section) {
private val viewModel: SectionViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.title.text = viewModel.title() // View 绑定,天然不重复
// 数据加载挂到 STARTED 之后,配合 repeatOnLifecycle
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.data.collect {
render(it) }
}
}
}
}
这里顺带说清 setMaxLifecycle 的行为:不可见页面被推进到 CREATED(onPause 已调、onStop 未必调),可见页面推到 RESUMED。所以 onPause 里适合暂停轮询与视频,onStop 里适合释放重资源。不要在不可见页面继续跑动画或高频刷新,这是 ViewPager2 页面常见的耗电与卡顿来源。
还有一个更隐蔽的坑:ViewPager2 与 Fragment 最好的预防是自动化——检查与断言替人守着每个坑位。可落地的三条:① 基类 BaseFragmentStateAdapter 里默认实现 getItemId 为内容 id,并在 createFragment 上方加注释说明"不要用位置";② 单测用 FragmentScenario + ActivityScenario 覆盖"数据中间项被删除后切页"这类边界;③ Lint 拦截 setUserVisibleHint 的调用(已废弃 API 应直接移除而不是保留)。
三条高频追问
问:offscreenPageLimit 怎么设?
默认值 1 表示除当前页外还预加载相邻页。它直接影响"左右滑动时数据是否已准备好"的体验与内存占用。设为 OFFSCREEN_PAGE_INDICATOR(常量 -1)会按初始布局需要预加载;手写大值(如 5)会同时保持 5 个 Fragment 的 RESUMED,内存和电量都上升。实践建议:先保持默认,只有在明确遇到"横滑网络请求卡顿"且页面轻量时才提高。
问:ViewPager2 怎么实现"一屏多页"或卡片式露出?
两类做法。① 用 LayoutManager 之外的方案:ViewPager2 本身不支持外边距露出,要用 RecyclerView + PagerSnapHelper 自己实现。② 覆盖 onMeasure 调整子项宽度(ViewPager2 不允许改间距,要外边距需自定义 LayoutManager 或用 RecyclerView)。能答出"ViewPager2 做不到,需要换 RecyclerView"比强行 hack 更能体现判断力。
问:和 Navigation 组件怎么配合?
ViewPager2 适合同级 Tab 的横向切换,Navigation 适合有层级关系的页面跳转。两者不是替代关系。实际项目里常见的组合是:Tab 用 ViewPager2,Tab 内部的下钻用 Navigation;或者直接用 Navigation 的 TabLayout 切换器 + 多级 back stack。判据是页面之间有没有"回退"语义——同级切换用 ViewPager2,有层级就得用 Navigation。
落到项目里怎么做
一条能直接落地的规则:ViewPager2 的适配器必须重写 getItemId / containsItem,用业务 id 而不是位置。这一条能挡掉这类组件最常见的数据错乱。
配套实践三条:① 抽 BaseFragmentStateAdapter,把 id 规则固定在基类,子类只提供 createFragment 与数据;② 所有"页面可见才做的重活"挂 repeatOnLifecycle(STARTED),把停止逻辑放 onPause;③ 页面数量可控时才用 ViewPager2,若需要动态增删且有 id 稳定性要求,先把数据层的主键定好再接这个组件。
给正在准备面试的你
这题的答法要落到"复用机制 + 生命周期托管"这两个词上,而不是罗列 API。推荐这条线:ViewPager2 内核是 RecyclerView → 因此要重写 getItemId/containsItem 防错配 → Fragment 可见性由 setMaxLifecycle 托管、不可见页停在 CREATED → 旧版 setUserVisibleHint 那套要彻底废弃 → offscreenPageLimit 是内存与体验的权衡 → 最后用"同级用 ViewPager2、有层级用 Navigation"收尾。补上"数据中间项删除"这个边界案例,答案就完整了。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Fragment-事务与回退栈:add、replace、show、hide
下一篇预告:Service-生命周期:started-与-bound-两条线
有任何问题欢迎在评论区留言交流。