欢迎光临
我们一直在努力

不再被页面状态绕晕:小进的 ViewPager2 + Fragment 最佳实践

不再被页面状态绕晕:小进的 ViewPager2 + Fragment 最佳实践

大家好,我是小进。

顶部 Tab、左右滑动的引导页、新闻分类和底部导航,经常都能看到 ViewPager2。它看起来只是一个“可以左右滑动的容器”,但一旦和 Fragment 组合使用,就会遇到页面重复创建、状态丢失、通信混乱和预加载过多等问题。

这篇文章会从一个最小示例开始,再逐步讲清楚 FragmentStateAdapter、生命周期、稳定 ID、页面通信、TabLayout 和性能优化。你不需要先背所有 API,先理解 ViewPager2 如何管理页面,后面的实践就会顺很多。

一、ViewPager2 到底解决什么问题

ViewPager2 是基于 RecyclerView 实现的可滑动分页组件,支持水平和垂直方向。它通常负责三件事:

  • 根据当前位置显示某一页;
  • 处理手势滑动、页面切换和动画;
  • 复用或销毁页面内容,减少不必要的内存占用。

当每一页是一个 Fragment 时,页面由 FragmentStateAdapter 创建和管理。可以把它们理解成:

ViewPager2:分页容器和滑动手势

FragmentStateAdapter:根据位置提供页面

Fragment:每一页自己的界面和状态

ViewPager2 不应该用来替代复杂列表。单页内部有大量重复内容时,使用 RecyclerView;ViewPager2 只负责“页与页之间的切换”。

二、添加依赖和布局

确认项目使用 AndroidX,并添加 ViewPager2:

implementation("androidx.viewpager2:viewpager2:<version>")
implementation("androidx.fragment:fragment-ktx:<version>")

XML 中放入一个 ViewPager2:

<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent" />

页面如果需要 Tab 标题,可以在上方增加 TabLayout。TabLayout 属于 Material Components,依赖和版本以项目当前配置为准。

三、最小可运行示例

先准备一个简单的页面 Fragment:

class ArticlePageFragment : Fragment(R.layout.fragment_article_page) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val title = requireArguments().getString(ARG_TITLE).orEmpty()
view.findViewById<TextView>(R.id.titleText).text = title
}

companion object {
private const val ARG_TITLE = "arg_title"

fun newInstance(title: String) = ArticlePageFragment().apply {
arguments = bundleOf(ARG_TITLE to title)
}
}
}

然后实现 FragmentStateAdapter:

class ArticlePagerAdapter(
fragment: Fragment,
private val titles: List<String>
) : FragmentStateAdapter(fragment) {

override fun getItemCount(): Int = titles.size

override fun createFragment(position: Int): Fragment {
return ArticlePageFragment.newInstance(titles[position])
}
}

在宿主 Fragment 中设置适配器:

class ArticlePagerFragment : Fragment(R.layout.fragment_article_pager) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

val pager = view.findViewById<ViewPager2>(R.id.viewPager)
pager.adapter = ArticlePagerAdapter(
this,
listOf("Android", "Kotlin", "Jetpack")
)
}
}

这里传入 this 很关键:适配器使用宿主 Fragment 的 childFragmentManager 和生命周期。Activity 中直接使用时,则传入 Activity 或 FragmentActivity。

四、为什么推荐 FragmentStateAdapter

旧的 FragmentPagerAdapter 和 FragmentStatePagerAdapter 属于 ViewPager 时代的 API。ViewPager2 使用 FragmentStateAdapter,它会保存 Fragment 的状态,并在页面离开较远时销毁不需要的视图或实例。

这意味着:

  • 不要在适配器里手动缓存所有 Fragment 实例;
  • 不要依赖 getItem(position) 之类旧 API;
  • 页面数据变化时,要让适配器知道哪些页面真的发生了变化。

FragmentStateAdapter 不是“永远保留每一页”。页面的视图可能被销毁并重新创建,所以 UI 状态要放在 ViewModel 或可恢复状态中,而不是只放在 View 成员变量里。

五、稳定 ID:动态页面列表的关键

如果页面数量和顺序固定,默认的 position ID 通常够用。但新闻分类、筛选结果这类列表会插入、删除或重排页面时,不能把 position 当作永久身份。

可以覆盖 getItemId() 和 containsItem():

data class Category(val id: Long, val name: String)

class CategoryPagerAdapter(
fragment: Fragment,
private val categories: List<Category>
) : FragmentStateAdapter(fragment) {

override fun getItemCount(): Int = categories.size

override fun createFragment(position: Int): Fragment {
return CategoryFragment.newInstance(categories[position].id)
}

override fun getItemId(position: Int): Long = categories[position].id

override fun containsItem(itemId: Long): Boolean {
return categories.any { it.id == itemId }
}
}

更新列表后调用 notifyDataSetChanged(),适配器就能根据稳定 ID 判断哪些页面可以复用。ID 必须稳定且唯一,不能每次刷新都随机生成。

六、页面参数怎么传

Fragment 不建议通过公开可变字段传参,因为系统重建 Fragment 时,这些字段可能丢失。使用 arguments:

class DetailFragment : Fragment(R.layout.fragment_detail) {
private val articleId: Long by lazy {
requireArguments().getLong(ARG_ARTICLE_ID)
}

companion object {
private const val ARG_ARTICLE_ID = "article_id"

fun newInstance(articleId: Long) = DetailFragment().apply {
arguments = bundleOf(ARG_ARTICLE_ID to articleId)
}
}
}

参数适合传 ID、筛选条件等小数据,不适合传大对象、Bitmap 或整个数据库结果。页面根据 ID 通过 ViewModel 或 Repository 加载真正的数据。

七、ViewPager2 与 Fragment 的双重生命周期

这是新手最容易混淆的地方:Fragment 有 Fragment 生命周期,Fragment 的 View 还有独立的 View 生命周期;ViewPager2 又会根据距离当前页的远近管理页面。

因此要遵守一个原则:只在 View 存活期间访问 View,只在 Fragment 或 ViewModel 存活期间保存业务状态。

class ArticleFragment : Fragment(R.layout.fragment_article) {
private var _binding: FragmentArticleBinding? = null
private val binding get() = _binding!!

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentArticleBinding.bind(view)
}

override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}

不要在 onDestroyView() 之后继续使用 Binding,也不要把 Activity 或 View 的引用保存到长生命周期对象中,否则容易内存泄漏。

setMaxLifecycle 与当前页

ViewPager2 会配合 FragmentManager 管理页面的最大生命周期,通常当前页处于 RESUMED,附近页面可能处于 STARTED。不要假设所有页面都同时处于前台;播放动画、相机预览等资源应根据可见状态启动和停止。

八、页面状态保存的正确姿势

旋转屏幕、进程重建或页面被暂时销毁时,ViewPager2 可能重新创建 Fragment。推荐使用 Fragment 自己的 ViewModel 保存页面业务状态:

class ArticleViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
val draft = savedStateHandle.getStateFlow("draft", "")

fun updateDraft(value: String) {
savedStateHandle["draft"] = value
}
}

页面 UI 通过 viewLifecycleOwner.lifecycleScope 收集状态,并在 repeatOnLifecycle 中观察:

viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.draft.collect { text ->
binding.editor.setText(text)
}
}
}

小型的临时 UI 状态可以使用 onSaveInstanceState(),但不要把大对象塞进 Bundle。ViewPager2 的页面缓存不是状态管理方案。

九、页面之间如何通信

1. 共享 ViewModel:首选

宿主 Fragment 和子页面需要共享筛选条件、选中项或登录状态时,可以使用作用域为宿主的 ViewModel:

private val sharedViewModel: PagerViewModel by viewModels({ requireParentFragment() })

不同页面观察同一个 StateFlow,由状态变化驱动 UI。这样比直接持有另一个 Fragment 的引用更安全。

2. Fragment Result API:一次性结果

某个页面完成编辑后,只需向宿主返回一次结果,可以使用 Fragment Result API:

parentFragmentManager.setFragmentResult(
"edit_result",
bundleOf("saved" to true)
)

接收方在生命周期安全的位置注册监听:

parentFragmentManager.setFragmentResultListener(
"edit_result",
viewLifecycleOwner
) { _, bundle ->
if (bundle.getBoolean("saved")) reload()
}

不要让页面互相调用内部方法,也不要把事件塞进 Bundle 传递复杂对象。

3. 不要把页面位置当业务状态

currentItem 只是当前滑动位置,不应该直接代表业务选中状态。页面列表发生变化后 position 可能改变,业务层应使用稳定 ID。

十、和 TabLayout 联动

使用 Material Components 的 TabLayoutMediator:

TabLayoutMediator(tabLayout, viewPager) { tab, position ->
tab.text = titles[position]
}.attach()

TabLayoutMediator 会在绑定时读取页面标题。页面列表动态更新后,确保标题列表和适配器数据保持一致;数据结构复杂时,建议让标题来自同一个单一数据源。

如果用户可以频繁切换页面,不要在每次 onPageSelected() 中重复启动网络请求。使用 ViewModel 缓存数据,并让请求具备取消和去重能力。

十一、预加载与页面数量

offscreenPageLimit 可以控制额外保留多少页:

viewPager.offscreenPageLimit = 1

默认值通常足够。把它调得很大,会让更多 Fragment 和 View 同时存活,增加内存和初始化成本;设得过小,则可能频繁创建视图。只有经过实际测量,确认页面切换卡顿且页面本身适合缓存时,才调整它。

页面内部的大图、视频和地图组件尤其消耗资源。即使 Fragment 还存在,也应在不可见时暂停不必要的工作。

十二、嵌套滑动与方向设置

ViewPager2 默认水平滑动,也可以改为垂直:

viewPager.orientation = ViewPager2.ORIENTATION_VERTICAL

如果页面内部还有横向 RecyclerView,可能出现手势竞争。可以通过明确滑动方向、调整触摸处理或避免不必要的嵌套来解决。不要一遇到滑动冲突就关闭用户滑动;先确认究竟是哪一层在消费事件。

十三、Fragment 嵌套 Fragment 的注意事项

当 ViewPager2 位于一个 Fragment 内时,适配器必须使用该 Fragment 的 childFragmentManager,因此要传入宿主 Fragment:

FragmentStateAdapter(this)

不要误传 parentFragmentManager 对应的 Activity,除非你的适配器确实以 Activity 为宿主。传错宿主可能导致页面找不到、状态恢复异常或 Fragment 生命周期不符合预期。

十四、常见坑位排查

现象常见原因处理方式
页面重建后数据丢失 状态只放在 View 或适配器字段 使用 ViewModel 和 SavedStateHandle
页面显示错位 用 position 当永久 ID 覆盖 getItemId() 和 containsItem()
Fragment 找不到 嵌套场景传错宿主 Fragment 中传 this,Activity 中传 Activity
页面重复请求 每次创建 View 都无条件请求 ViewModel 缓存、请求去重和生命周期收集
内存占用很高 offscreenPageLimit 设置过大 恢复默认值,测量后再调整
Binding 崩溃 onDestroyView() 后仍访问 View 在 onDestroyView() 置空引用
Tab 标题错乱 Tab 数据和页面数据不同步 使用同一数据源并在更新后重新绑定
滑动卡顿 页面初始化或图片加载太重 异步加载、缓存资源、减少预加载

十五、什么时候不该用 ViewPager2

如果只是底部导航的几个独立页面,Navigation Component 通常比 ViewPager2 更适合,因为它提供返回栈、深链接和统一导航管理。ViewPager2 更适合“页面之间有明确顺序、用户可以滑动切换”的场景。

如果页面数量巨大或完全由数据驱动,考虑单个 Fragment 内使用 RecyclerView,不要让每个条目都成为 Fragment。Fragment 是有生命周期成本的,不应该替代普通列表项。

十六、动手练习:做一个分类阅读器

我建议做一个“分类阅读器”来串起这些知识:

  • 使用 ViewPager2 + FragmentStateAdapter 展示三个文章分类;
  • 用 TabLayoutMediator 显示分类标题;
  • 每个页面内部使用 RecyclerView 展示文章列表;
  • 用稳定的分类 ID 实现动态增删分类;
  • 用页面级 ViewModel 缓存文章和滚动状态;
  • 用共享 ViewModel 实现统一搜索;
  • 打开详情页后通过 Fragment Result API 返回收藏结果;
  • 旋转屏幕、切后台、快速滑动,观察页面是否重复加载或丢失状态。
  • 写在最后

    ViewPager2 的最佳实践可以浓缩成几句话:让 FragmentStateAdapter 负责页面创建,让 ViewModel 负责状态,让稳定 ID 负责动态页面身份,让生命周期负责资源开关。

    先做一个固定三页的小例子,再加入动态分类、搜索和旋转屏幕测试。等你能解释清楚“页面为什么重建、状态放在哪里、哪一页应该处于前台”,ViewPager2 + Fragment 就不再是靠运气调出来的组合了。

    — 小进

    赞(0)
    未经允许不得转载:171主机测评 » 不再被页面状态绕晕:小进的 ViewPager2 + Fragment 最佳实践
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址