第127篇 ViewPager2 与 Fragment:懒加载的正确实现

简介: ViewPager2 内核是 RecyclerView,由 FragmentStateAdapter 托管 Fragment 生命周期,依赖 `getItemId`/`containsItem` 实现稳定复用。旧版 `setUserVisibleHint` 已废弃,应统一用 `repeatOnLifecycle(STARTED)` 控制可见性逻辑。关键在理解“复用机制+生命周期托管”,避免数据错乱、重复加载与内存泄漏。

先把结论放在前面: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-两条线

有任何问题欢迎在评论区留言交流。

相关文章
|
23天前
|
缓存 编译器 PHP
第113篇 Compose 与 Kotlin 特性:为什么 Compose 离不开 Kotlin
本文深入解析 Jetpack Compose 的底层机制,揭示其“声明式 UI”背后的三大支柱:带接收者 Lambda、编译器插件(KCP)与稳定性注解(`@Composable`/`@Stable`/`@Immutable`)。重点剖析编译器如何重写函数、实现智能跳过重组,以及常见性能陷阱(如列表无 key、lambda 不稳定)的根因与解法,助你面试直击本质。
86 3
|
23天前
|
Android开发
第122篇 Activity 启动模式:standard 到 singleInstance
本节详解Activity启动模式,直击“最容易出玄学bug”的栈管理机制。核心指出:行为由**系统、任务栈、launchMode、Intent flags四者共同决定**,仅记四个枚举值远远不够。重点剖析四种任务栈本质及flags优先级,并给出通知跳转、登录回退、启动图等真实场景的避坑方案与代码范式。
81 1
|
23天前
|
安全 Java 编译器
第098篇 空安全与 Java 混编:平台类型的风险控制
本文深度剖析Kotlin与Java混编中空安全的“信任边界”:指出平台类型、注解失效与反射泛型三大漏洞,提出“类型收口+运行时断言+架构约定”三层防线,强调用`requireNotNull`替代`!!`、用`sealed`封装多态结果,真正实现NPE可控可追溯。
46 0
|
存储 安全 API
第130篇 WorkManager:后台任务的最终归宿
WorkManager 专解“保证完成+尊重系统调度”问题,非实时执行方案。核心能力:持久化任务队列、约束条件(如网络/电量/存储)、可组合工作链。选型关键:可延迟但必须成功——用它;需即时响应、用户可见——用前台服务或推送。
90 0
|
23天前
|
调度 Android开发 容器
第126篇 Fragment 事务与回退栈:add、replace、show、hide
Fragment事务异步队列执行,非调用即生效;回退栈存操作记录而非实例。`show/hide` 快但常驻内存、不可回退;`replace`+栈可回退但重建视图。选型关键:是否需回退?是否敏感内存?——是判断题,非知识点。
76 1
|
缓存 API Android开发
第125篇 Fragment 通信演进:从接口回调到共享 ViewModel
本文梳理Fragment通信演进史,揭示五代方案(接口回调、setFragmentResult、广播、共享ViewModel、Fragment Result API)的耦合本质。核心结论:**仅推荐三种场景化方案——Fragment Result API传一次性结果、共享ViewModel管共同状态、事件总线限于跨模块解耦**。重点剖析“耦合藏在哪”,并指出`viewLifecycleOwner`误用、事件重复消费、ViewModel状态污染等高频坑及自动化防治策略。
64 0
|
缓存 API Android开发
第129篇 前台服务与后台限制:通知与省电的平衡
前台服务本质是“用户可见即获优待”的系统契约,非绝对保活。需严格满足:Android 8+ 5秒内调`startForeground`、按API版本声明`foregroundServiceType`及对应权限、展示可感知通知。违背即崩溃或被拒。
117 0
|
Android开发
第124篇 Fragment 生命周期:与 Activity 的联动陷阱
Fragment生命周期含三套独立流程:实例、视图、上下文。关键分界是`onCreateView`至`onDestroyView`为视图生命周期,此时Fragment实例仍存活;`onDestroy`后实例才真正销毁。常见坑如重复弹窗、内存泄漏,根源在于混淆三者时机。推荐实践:视图相关操作绑定`viewLifecycleOwner`,数据交由`ViewModel`管理。
77 0
|
API Android开发 C++
第147篇 属性动画与估值器:插值曲线如何驱动画面
属性动画核心是三段流水线:`ValueAnimator` 推进时间(输出0~1进度)→ `TimeInterpolator` 重映射节奏(如先快后慢)→ `TypeEvaluator` 计算实际值(如float/integer插值)。三者正交、缺一不可,理清此链即破“插值器vs估值器”迷思。
70 0
|
24天前
|
SQL JSON 前端开发
「这段逻辑放前端还是后端?」——陪小迪画一条判定线
小迪把库存判断写在了页面上,还把 price 一起提交给后端。老陈点破:这个判断写在了用户手里。一条判定线加四个问题——用户能不能绕过它、算它要哪些数据、算错了谁负责、你要的是快还是对。前面三个例子讲金额、权限、防重复提交最容易放错的位置,后面反过来讲哪些纯交互的事不该做成接口。
82 2

热门文章

最新文章