0) 先选路线(很关键)
-
页数 ≤ 10:直接 ViewPager2 + FragmentStateAdapter,常规优化即可。
-
页数 10 ~ 50:仍可 ViewPager2,但务必只缓存少量页面 + 共享资源(见 §2)。
-
页数 ≫ 50(百级) :不要每页一个 Fragment。改为:
-
方案 A:虚拟化分页(推荐) ——用“3 页滚动复用池”(环形复用 3 个 Fragment/自定义 View),根据当前位置重新绑定数据。
-
方案 B:单页 + 分段列表——一个 Fragment + 一个 RecyclerView,Tab 只是锚点/筛选,跳转到相应 section(Sticky Header/索引)。
两个方案都避免“为每个 tab 保持一个页面实例”。
-
1) 如果必须用 ViewPager2(≤ ~50 页)
1.1 适配器必须选对
- 用 FragmentStateAdapter(VP2)/ FragmentStatePagerAdapter(VP1) ,不要再用 FragmentPagerAdapter(它会常驻所有 Fragment,内存炸)。
- 设置稳定 ID,方便增删 Tab 不触发全重建:
class MyAdapter(
fa: FragmentActivity,
private var categories: List<Category>
) : FragmentStateAdapter(fa) {
override fun getItemCount() = categories.size
override fun createFragment(pos: Int) = PageFragment.new(categories[pos].id)
override fun getItemId(pos: Int) = categories[pos].id.hashCode().toLong()
override fun containsItem(id: Long) = categories.any { it.id.hashCode().toLong() == id }
}
1.2 控制保活与预取
viewPager.offscreenPageLimit = 1 // 仅左右各 1 页
(viewPager.getChildAt(0) as RecyclerView).overScrollMode = View.OVER_SCROLL_NEVER
不要把 offscreenPageLimit 拉大;大了=内存/CPU 压力陡增。
1.3 懒加载 & 生命周期
- 在页面 onViewCreated 里只做轻量初始化;真正的数据加载放到**“可见后”**:
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { // 仅当前页 RESUMED 时跑
viewModel.loadIfNeeded()
}
}
VP2 会把当前页设为 RESUMED,相邻页 STARTED/CREATED;利用这个特性做懒加载。
1.4 每页里通常是列表:把可复用做到极致
- 共享 RecycledViewPool(多页同结构的 RecyclerView 复用同一池):
val sharedPool = RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(VIEW_TYPE_ITEM, 50)
}
class PageFragment : Fragment(R.layout.page) {
override fun onViewCreated(v: View, s: Bundle?) {
rv.setRecycledViewPool(sharedPool)
rv.setHasFixedSize(true)
(rv.layoutManager as? LinearLayoutManager)?.apply {
isItemPrefetchEnabled = true
initialPrefetchItemCount = 8
}
}
}
-
避免嵌套滚动:页内列表尽量 isNestedScrollingEnabled = false,由父层处理;或使用 app:layout_behavior 正确接入 nested scroll。
-
图片/视频按需加载、滑动时暂停解码/播放(Coil/Glide setPauseOnScroll 思想)。
1.5 预取相邻页的数据(而不是“创建相邻 Fragment”)
viewPager.registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(pos: Int) {
prefetch(pos + 1); prefetch(pos - 1) // 只拉数据,不做 UI inflate
}
})
2) 页很多时:两种“跳出 Pager 的思路”
2.1虚拟化分页(3 页环形复用)—— 强烈推荐
- 思想:屏幕上最多显示 1 页,左右各 1 页是过渡。我们只持有 3 个“容器” (3 Fragment 或 3 自定义 View)。滑到新位置时,把最远的那个复用,给它绑定新 categoryId/数据即可。
- 适配器伪码(自定义 View 版更轻):
class VirtualPagerAdapter : RecyclerView.Adapter<VH>() {
override fun getItemCount() = Int.MAX_VALUE // 视觉无限
override fun onBindViewHolder(h: VH, pos: Int) {
val real = pos % categories.size
h.bind(categories[real])
}
}
// 用 TabLayout 计算并滚动到“映射位置”(currentBase + delta)
-
优点:内存固定、小;切页只做数据重绑,不创建新 Fragment。
2.2单页 + Section 列表
- 一个 RecyclerView,按类目拼成多段 Section,Tab 只是锚点。
- 用 LinearSmoothScroller/SnapHelper 定位到某 Section;加 Sticky Header 实现“像分页一样”的体验。
- 优点:只有一个页面,复用最佳;缺点:数据/状态管理在一个列表里稍复杂。
3) Tab 自身的优化(Tab 很多时)
- 懒创建 Tab:TabLayoutMediator 绑定后不要一次性做复杂自定义 View;可在 onPageSelected 时惰性膨胀 Tab 的复杂布局。
- 开启 mode="scrollable";避免超长文案排版抖动,预设最小宽。
- 变动频繁时用 DiffUtil 更新数据源,再 notifyItemRangeChanged,避免全量重建。
4) 内存与渲染细节
- 统一用 ViewBinding,减少 findViewById 与错误引用。
- Fragment 不持有外部大对象;图片用生命周期感知加载;退出页立刻取消协程/订阅。
- 首屏/首个 Tab:能展示占位就先展示,占位轻(Shape/渐变),不要大阴影/复杂圆角层叠。
5) 观测 & 验证(别拍脑袋)
- 打点:切页耗时、首帧、内存(Java/Kotlin heap + Bitmap + Graphics);观察 OffscreenPageLimit 改动的影响。
- adb shell dumpsys meminfo your.pkg & Android Studio Profiler 看峰值;大于 256–512MB 要警惕。
- 卡顿排查:Systrace/System Trace 看 Choreographer 帧;如果切页掉帧,多半是页内首次 inflate/解码/布局过重→改为懒加载 + 预取。
6) 高频坑清单
- 用了 FragmentPagerAdapter 或把 offscreenPageLimit 设很大 → 直接 OOM/掉帧。
- 每个页面都各自一个 RecycledViewPool → 切页时频繁 inflate,明显卡。
- 页内做耗时 I/O 或 decode → 放到可见后或后台,首帧只占位。
- 动态增删 Tab 没有稳定 ID → 整个 ViewPager2 抖到重建。
- 嵌套滚动没处理好 → 滚动冲突/测量多次;用 NestedScroll 正确接入或禁掉一层滚动。
7) 一套“能直接用”的配置清单
- ViewPager2 + FragmentStateAdapter + offscreenPageLimit=1
- TabLayout mode="scrollable";Tab 复杂视图惰性创建
- 页内 RecyclerView:共享 RecycledViewPool、setHasFixedSize(true)、initialPrefetchItemCount=8
- 懒加载:repeatOnLifecycle(RESUMED) 拉数据;相邻页只预取数据****
- 页数>50:改“3 页环形复用”或“单页多 section”方案