
做安卓开发的几乎没有人能绕开 ViewPager 和 Fragment 这对组合。刚入门的时候觉得页面滑动很炫配着 Fragment 做 Tab 切换特别顺手可一旦业务复杂起来问题就来了明明已经滑到了第二个页面第一个页面的 Fragment 还在后台偷偷拉数据明明页面已经离开了用户视线视频播放器的声音还在响埋点统计出来的数据总是对不上真实操作。这些现象统称 ViewPager 与 Fragment 生命周期不协调问题。我在项目里被它折磨过好几个版本排查了不少稀奇古怪的线上 Bug今天就把这块的机制原理、典型症状和根治方案一次性聊透所有代码和思路都是可以直接拿去用的。先交代一下背景。这篇文章适合两类读者一类是刚接触安卓开发、准备用 ViewPager 写底部导航或者 Banner 的新手另一类是已经写了很久项目、总是被页面可见性判断折腾得焦头烂额的进阶开发者。我会先讲清楚 ViewPager 的工作模型再拆解生命周期为什么对不上号然后给出几套不同场景下的解决方案最后附上一份踩坑排查清单。内容会涉及一些源码级别的细节但我会尽量用项目里的真实案例来解释保证你读完就能上手改代码。1. ViewPager 与 Fragment 生命周期机制拆解1.1 ViewPager 的工作模型与预加载原理要理解生命周期为什么不协调首先得搞清楚 ViewPager 内部到底是怎么管理 Fragment 的。ViewPager 本身就是一个容器它持有的是一个 FragmentManager通过 PagerAdapter 去创建、销毁、绑定 Fragment。这里的核心机制是离屏加载也就是预加载。ViewPager 默认会加载当前页和左右相邻的各一页这个数量由setOffscreenPageLimit控制默认值是 1。也就是说你看到的是第一页但第二页早就已经被创建并走完 onResume 了当你从第一页滑到第二页第一页并不会立刻销毁而是停留在缓存范围内。这个过程里Fragment 的创建时机和你看见页面的时机完全对不上。很多新手踩的最大的坑就在这里以为 Fragment 的 onResume 等于用户看到了它。实际上在 ViewPager 的场景下onResume 只代表这个 Fragment 处于 resumed 状态而这个状态由当前选中页索引决定。预加载的相邻页面虽然不在屏幕上却同样处于 resumed 状态导致你在 onResume 里做的事情被提前执行了。1.2 Fragment 在 ViewPager 内部的生命周期变化这里必须区分两类生命周期一类是宿主 Activity 触发的生命周期比如 onActivityCreated、onStart、onStop另一类是 Fragment 自身的状态迁移比如 onResume、onPause。在 FragmentTransaction 手动 add 的场景里Fragment 的 onResume 和 onPause 会随着它是否处于前台而切换但在 ViewPager 里这套逻辑不成立。我画过一张对比图给自己看普通 Fragment 切换是上一个 onPause下一个 onResume而 ViewPager 里所有缓存范围内的 Fragment 都处于 STARTED 和 RESUMED 之间来回摆动的状态。你切到第三页第一页还在缓存范围内它的 Fragment 状态是 STARTED 而不是 RESUMED但是不要高兴太早——第二页是当前选中页它的状态是 RESUMED而第一页虽然不可见它实际上也没有收到任何回调它的状态保持在不影响下次回来看起来还是老样子的层级。这就导致一个很头疼的问题你在 Fragment 里依赖 onResume 和 onPause 做的逻辑比如埋点、暂停视频、注销广播在 ViewPager 的页面切换中经常不触发。系统认为它还在 resumed 范围内只是不在屏幕焦点上。更麻烦的是AndroidX 后来给 Fragment 引入了 Max Lifecycle 的概念不同版本的 ViewPager 适配器对生命周期状态的控制方式也不一样。1.3 FragmentPagerAdapter 与 FragmentStatePagerAdapter 的差异适配器选型也是生命周期问题的一个关键变量。FragmentPagerAdapter 在 detach 的时候不会销毁 Fragment 实例只是移出视图层次 FragmentStatePagerAdapter 则会把非缓存页面的 Fragment 彻底销毁只保存 savedInstanceState。两者对生命周期的影响完全不同前者页面实例常驻状态保存简单但内存占用较高后者适合动态增删页面的场景但页面不可见时可能连 onDestroyView 都走一遍回来还要重建视图。我见过不少团队在 FragmentStatePagerAdapter 上做懒加载结果发现滑回来的页面数据又得重新拉一遍这不是你的显式代码写错了而是适配器帮你把页面销毁重建了。所以选哪个适配器取决于你的页面数据量和是否需要长期保留实例。如果 Tab 页数量固定且都在五个左右直接用 FragmentPagerAdapter 或者 AndroidX 下的 FragmentStateAdapter 都行如果超过十个或者需要动态增删 Tab就得接受页面可能在滑出去后被销毁的事实并针对性地做数据缓存而不是硬做懒加载。2. 生命周期不协调的核心问题定位2.1 典型症状从数据提前加载到页面残留事件生命周期不协调的症状表现很多但归纳起来就一句话你在错误的生命周期节点做了应该基于用户可见性才能做的事情。最常见的几个现象如下。第一数据提前加载。你打开应用第一页还没完全显示出来第二页的网络请求已经发出去了。如果第二页依赖登录态或者路由参数这个请求大概率会失败或者返回的数据被丢弃等你真正滑过去的时候还得再加载一次。第二页面残留事件没有清理。比如视频播放页面滑出屏幕了还在播地图页面的定位监听还在跑录音页面的麦克风还在工作。你在 onPause 里做了暂停处理但这个回调在 ViewPager 里根本不触发导致各种资源被白白消耗。第三埋点数据错乱。很多团队把页面浏览埋点放在 onResume 里结果一个页面滑入滑出三次统计了五次曝光。这不是埋点系统的问题而是生命周期回调在 ViewPager 场景下没有和用户可见性画等号。第四切回页面数据不刷新。有的页面需要每次可见时都刷新数据比如钱包余额、消息列表。因为 onResume 不一定触发或者触发了但无法区分是不是从后台回到前台导致数据一直停留在旧状态。这些都是业务代码里最基础的能力但一旦放到 ViewPager 里就面目全非根源就在于大家默认了Fragment 生命周期反映用户可见性这个前提而 ViewPager 的预加载机制恰好破坏了它。2.2 为什么 onResume 和 onPause 在 ViewPager 里失灵具体来说ViewPager 在页面切换时调用的是适配器的setPrimaryItem方法它只会在当前选中页改变的时候更新主 Fragment然后通过 FragmentTransaction 把 Fragment 的状态调整到当前页 RESUMED其他页 STARTED。注意这个调整并不是调用 onPause 和 onResume它走的是 FragmentManager 内部的 state 迁移逻辑最终会走到performPause或performResume但只有当该 Fragment 真的从 RESUMED 状态退出来的时候才会触发 onPause。当我们滑到第二页第一页虽然不再可见但它仍然保持在 STARTED 状态并且缓存范围内。也就是说它不会调用 onPause因为系统的判定里它还在活跃 Fragment的附近只是不处于主焦点。这种情况下你在 onPause 里做的暂停逻辑自然就没有执行机会。多个页面同时处于 RESUMED 或者 STARTED 状态在旧版 AndroidX 中尤其容易出现这就是生命周期不协调的最直接来源。这里稍微提一下setUserVisibleHint。在很长一段时间里这是判断 ViewPager 内 Fragment 是否对用户可见的官方方案当页面进入或移出屏幕时系统会调用这个回调传进来的布尔值代表可见性。但这个接口在 AndroidX 里已经被标记为废弃而且不同版本的 Fragment 线程模型下时序并不完全一致容易和 Fragment 自身生命周期交织出一些诡异问题。我们后面会用一套更完整的方案替代它。2.3 业务影响范围量化性能、体验与数据准确性生命周期错位带来的影响不仅是代码感觉不太对它会直接反映到线上数据和应用性能。我举个例子一个外卖 App 的首页有三页推荐、订单、我的。推荐页面在 onResume 里请求一手数据订单页在 onResume 里请求订单列表。用户打开 App 后第一屏只看了推荐订单页的请求就已经发出去了白白消耗了一次网络流量也增加了应用启动阶段的并发请求数。如果用户在弱网环境这些预加载请求还会抢占主线程的连接池拖慢真正重要的推荐接口。从体验角度看数据提前加载通常不会直接闪屏但会带来数据刷新策略混乱的问题。某些页面做了下拉刷新和自动刷新切回来时如果可见性判断不可靠就会频繁触发刷新动画用户看着 UI 一顿乱闪就会产生这个 App 是不是有病的第一印象。从数据准确性计算的角度埋点是最容易出错的场景。曝光埋点、页面停留时长埋点、点击转化埋点全部依赖页面可见性定义。如果 Visible 事件多发或者漏发运营同学拿到的漏斗数据就是错的后续的产品决策全部建立在错误的数据基础上。这种问题的严重性不亚于功能 Bug因为它在上线初期几乎不会被用户投诉但会在数据复盘的时候突然爆发。3. 解决方案实战针对不同场景的生命周期校准策略3.1 方案一在 onResume 里叠加可见性标志位最直接的办法是在 onResume 里不直接执行业务逻辑而是加一个可见性判断只有当当前 Fragment 处于用户可见状态时才真正加载数据。这个方法的核心是多维护一个布尔变量并在 onResume 和 onHiddenChanged 里交叉判断。写一个基础模板Kotlin 代码如下class BaseLazyFragment : Fragment() { private var isViewCreated false private var isVisibleToUser false private var hasLoadedData false override fun onResume() { super.onResume() if (isVisibleToUser !hasLoadedData) { loadData() hasLoadedData true } else if (isVisibleToUser) { refreshData() } } override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) this.isVisibleToUser isVisibleToUser if (isVisibleToUser isViewCreated !hasLoadedData) { loadData() hasLoadedData true } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) isViewCreated true if (isVisibleToUser !hasLoadedData) { loadData() hasLoadedData true } } private fun loadData() { // 这里放真实的初始化逻辑 } private fun refreshData() { // 这里放每次可见时的刷新逻辑 } }这个方案解决的是首次加载和再次可见刷新两个问题。首次可见时通过 setUserVisibleHint 和 onViewCreated 的组合判断保证只在第一次真正滑到该页面时才发请求再次可见时通过 onResume 里的分支判断刷新数据但不再重复初始加载。注意 setUserVisibleHint 在 ViewPager 页面创建时可能先于 onViewCreated 调用所以必须等两个条件都满足后再执行业务否则会出现视图还没准备好就刷新 UI 的问题。这套方案的缺点是依赖 setUserVisibleHint在 AndroidX 里它毕竟已废弃如果你是在新项目里建议看下面的方案二和三。3.2 方案二借助 onHiddenChanged 实现精确可见性控制如果你使用的是 FragmentTransaction show/hide 方式实现的 Tab 切换onHiddenChanged 是比 setUserVisibleHint 更精确的可见性回调。show/hide 的 Fragment 始终处于 RESUMED 状态但系统会在显隐切换时调用 onHiddenChanged传回的布尔值就是当前是否隐藏。结合 onResume可以覆盖页面切换和从后台回到前台两大类事件。这里给一个更通用的封装将可见性判断统一交给一个接口处理避免每个 Fragment 都写重复代码interface OnPageVisibleListener { fun onPageVisible() fun onPageInvisible() } abstract class VisibleFragment : Fragment(), OnPageVisibleListener { private var isViewCreated false private var isVisibleToUser false private var hasLoadedData false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) isViewCreated true if (isVisibleToUser) { onPageVisible() markLoadIfNeeded() } } Deprecated(Deprecated in Java) override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) this.isVisibleToUser isVisibleToUser if (isVisibleToUser isViewCreated) { onPageVisible() markLoadIfNeeded() } else { onPageInvisible() } } override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) if (!hidden isViewCreated) { onPageVisible() markLoadIfNeeded() } else if (hidden) { onPageInvisible() } } override fun onResume() { super.onResume() if (isVisibleToUser isViewCreated) { onPageVisible() } } private fun markLoadIfNeeded() { if (!hasLoadedData) { hasLoadedData true onPageLoadFirst() } } abstract fun onPageLoadFirst() }这里我把 onPageVisible 和 onPageInvisible 暴露给子类然后在 onPageLoadFirst 里只做一次数据加载。实际项目中每个子类只需要实现这三个方法就能保证首次可见才加载、每次可见都刷新、离开页面时清理资源三大诉求。需要注意 onHiddenChanged 只在 Fragment 被 show/hide 时回调所以你需要在创建 Fragment 时就把它加进 FragmentManager而不是 add 一页换一页。这个方法同样有坑如果你的 Fragment 是被 ViewPager 管理的show/hide 这套方式完全不适用因为 ViewPager 用的是 attach/detach它不会调用 onHiddenChanged。所以这个方案要结合 TAB 模式来选择别生搬硬套。3.3 方案三使用 AndroidX 的 Max Lifecycle 控制常驻状态如果你升级到了 AndroidX并且使用 FragmentStateAdapter 或者 FragmentPagerAdapter其实可以用一个更优雅的方案通过 FragmentTransaction 的setMaxLifecycle方法精确指定当前页面和缓存页面的最大状态。它的原理是这样的非当前页只允许走到 STARTED当前页才走到 RESUMED这样就能天然避免不可见页面还处于 RESUMED的现象。具体来说在创建适配器时传入 behavior 参数如果你用的是旧的 FragmentPagerAdapter传BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT就能实现只有当前 Fragment 才收到 onResume。如果你用的是 FragmentStateAdapter它本身在 AndroidX 中就是基于将 Fragment 提升到 RESUMED 状态来切换页面的默认行为更贴近需求不需要额外设置而回到 STARTED 的页面会收到 onPause 回调。这意味着在新项目中可以完全抛开 setUserVisibleHint 和 onHiddenChanged直接在 onResume 和 onPause 里做业务逻辑。这是我最推荐新项目采用的方式因为它不依赖废弃 API语义清晰排查起来也容易。但要注意一个细节setMaxLifecycle 是 FragmentManager 在内部管理的你在写业务代码时不要手动调用 transaction.setMaxLifecycle 去改 ViewPager 中的 Fragment否则会和 ViewPager 的内部状态机打架。class ViewPagerFragmentAdapter( fragmentManager: FragmentManager, lifecycle: Lifecycle ) : FragmentStateAdapter(fragmentManager, lifecycle) { override fun getItemCount(): Int tabCount override fun createFragment(position: Int): Fragment { return SimpleFragment.newInstance(position) } }这种写法下Fragment 的 onResume 只在真正滑到当前页时触发onPause 在滑出当前页时触发。针对页面可见性这个需求几乎可以无脑把所有逻辑都塞进这两个回调里。如果你还在用旧版 support 包建议尽快升级到 AndroidX这一条能省掉后期一大堆兼容性折腾。3.4 从 ViewPager 升级到 ViewPager2 的优势与成本有些项目还在用 ViewPager因为同事说 ViewPager2 的 API 不熟悉或者升级成本高。我的建议是如果还有机会改尽量上 ViewPager2。它基于 RecyclerView 实现生命周期管理更加规范FragmentStateAdapter 默认就会使用 setMaxLifecycle 机制几乎没有 ViewPager 老版本那些非当前页处于 RESUMED的问题。你可以在 onResume 和 onPause 里放心处理业务不必搞什么可见性标志位。当然ViewPager2 也不是没有缺陷。它的滑动性能依赖 RecyclerView 的复用机制如果你的每个 Fragment 页面内容都非常重需要注意页面销毁重建频率。另外ViewPager2 的 onPageSelected 回调时机和 ViewPager 不一样一些老写法里的 position 参数含义也不同。迁移时工作量最大的是测试用例很多依赖 ViewPager 特性的自定义动画和 Tab 联动都需要重新适配。从我自己的经验看一个中型 App 的 Tab 架构要从 ViewPager 迁到 ViewPager2预估改动量在两到三天但换来的是稳定的生命周期回调和完善的状态管理能力长期维护非常值得。4. 踩坑实录与排查清单4.1 高频问题速查表症状根因处理方式页面还没可见就发起了网络请求ViewPager 预加载机制导致 onResume 提前触发使用方案三的 BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT或加可见性标志位切回页面数据不刷新onResume 未触发或无法区分后台回前台汇总 onResume onHiddenChanged 或 setUserVisibleHint统一抛 onPageVisible 事件切走页面视频还在播放onPause 未触发在 onPageInvisible 里执行暂停或用 ViewPager2 后直接依赖 onPause来回滑动时页面闪烁FragmentStatePagerAdapter 销毁重建频繁根据页面数量换用 FragmentPagerAdapter或者设置合理的 offscreenPageLimit埋点重复上报多个页面同时处于 resumed 状态使用可见性事件代替 onResume 做埋点上报首次进入应用黑屏可见性判断过早视图尚未创建在 onViewCreated 后合并判断可见状态不能只看 setUserVisibleHint从后台切回时页面全部重新加载宿主的 onStart/onResume 会带动所有缓存 Fragment在宿主回调里过滤非当前页只让当前可见页刷新这是我整理了多个项目问题后总结出的高频清单涵盖了搜索热词里面经常提到的viewpager 和 fragment 切换生命周期异常有效生命周期判断等场景。查问题的时候可以直接对照表格定位省去重复试错的成本。4.2 排查技巧给生命周期打上日志标记排查生命周期不协调的问题我第一个建议是写一个统一的生命周期日志工具类。不要一个一个 Fragment 去加 Log那样太慢而且容易漏。用一个自定义的 BaseFragment在所有生命周期回调里统一打印当前 Fragment 的类名、方法名和关键状态参数。open class LoggableFragment : Fragment() { private val tag javaClass.simpleName override fun onAttach(context: Context) { super.onAttach(context) Log.d(tag, onAttach) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d(tag, onCreate) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Log.d(tag, onViewCreated) } override fun onStart() { super.onStart() Log.d(tag, onStart) } override fun onResume() { super.onResume() Log.d(tag, onResume, userVisibleHint $userVisibleHint) } override fun onPause() { super.onPause() Log.d(tag, onPause) } override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) Log.d(tag, onHiddenChanged, hidden $hidden) } Deprecated(Deprecated in Java) override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) Log.d(tag, setUserVisibleHint $isVisibleToUser, isResumed $isResumed) } override fun onStop() { super.onStop() Log.d(tag, onStop) } override fun onDestroyView() { super.onDestroyView() Log.d(tag, onDestroyView) } }实测的时候把目标 Fragment 继承换掉然后快速滑动 ViewPager观察日志顺序。你会发现很多本应该没有的问题日志里全暴露了。重点看三组关系当前页 onResume 时非当前页是不是还挂着 RESUMED滑动过程中 setUserVisibleHint 和 onResume 的先后顺序从后台回前台时所有缓存页面是否都会走 onResume。我一直强调排查这类问题不要靠猜日志一打时序一目了然。如果你用的 Android Studio建议配合 Fragment 的 Layout Inspector 一起看能看到每个 Fragment 当前的生命周期状态再和日志对比很快就能定位是哪个环节没有同步。4.3 自查清单把这些点全过一遍再上线最后分享一个我在项目提测前必过的清单都是踩过坑以后才总结出来的。你可以在自己的项目里做成一个 checklist每次改完 ViewPager 相关页面都逐项确认。首次进入时每个 Fragment 的网络请求是否只发一次有没有被预加载机制提前触发页面切走时音频、视频、定位、传感器等高耗资源是否都已暂停页面切回时是否需要刷新数据刷新入口是否能正确触发还是只在首次创建时加载从后台回到前台时当前可见页面是否执行了预期的刷新不可见页面是否误触发了埋点事件是否与页面可见性严格对应同一页面是否会重复上报曝光Fragment 里有没有依赖 onResume 做只执行一次的操作如果有改成配合可见性判断。页面被 FragmentStatePagerAdapter 销毁重建后状态恢复是否正确请求缓存是否有兜底多层嵌套 ViewPager 时内层页面的可见性判断是否受到外层页面影响是否遗漏了外层不可见时暂停内层处理的逻辑这个清单可以贴在工位旁边也可以提现在代码 review 的模板里。每次上线前过一遍基本上能拦截掉九成以上的生命周期问题。最后再聊一点个人经验。我用 ViewPager 踩过最大的坑不是技术方案选错而是团队里不同模块采用了不同方案A 模块用的 setUserVisibleHintB 模块用的 onResume 加标志位C 模块直接升级 ViewPager2最后公共组件根本没法统一维护。所以如果你的项目是多模块并行开发最好在架构层面就定死一个方案我最推荐的就是升 AndroidX 后默认使用 ViewPager2 FragmentStateAdapter把 onResume 和 onPause 当作唯一可信的页面切换回调如果确实没法升就用一套 UI 层统一封装的 LazyFragment 基类所有页面继承它而不是每次都各写各的。这样不仅排查问题方便后面做性能优化、埋点治理也能在一个地方统一改不用满项目追着生命周期事件跑。