ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ViewPager与Fragment重复加载问题详解及解决策略

ViewPager与Fragment重复加载问题详解及解决策略 先说一个我印象很深的线上问题。去年年底我负责的一款资讯类App上线后收到反馈用户在底部Tab间来回切换时列表页总是转圈很久后台日志里同一个接口短时间内被请求了十几次更夸张的是偶发页面重叠闪烁。团队第一反应是网络鉴权失败触发了重试结果翻了半天代码网络请求只在Fragment的onCreateView里写了一次。最后定位到的问题就是ViewPager Fragment组合下的“重复加载”——这几乎是每个做多Tab页面的Android开发者都会撞上的坑。这篇文章就把这个坑彻底摊开先梳理重复加载的典型症状再从ViewPager底层机制讲清楚为什么会发生然后给出三套亲测有效的解决方案最后附上一套完整的排查链路。不管你是刚接手项目的新人还是被这个Bug折磨到怀疑人生的老手都能在这里找到能直接落地的答案。1. 先说现象Fragment重复加载到底长什么样1.1 线上最常见的四种症状如果你怀疑自己的项目也存在Fragment重复加载先别急着改代码对照下面这份症状清单确认一下每次从其他Tab切回当前Fragment列表都重新请求网络甚至出现接口短时间多次命中的日志Fragment的onCreateView、onViewCreated在非首次创建时依然被反复执行断点打进去会看到同一个页面多次进入快速左右滑动ViewPager后页面出现叠影或者原本的布局被重复添加到父容器直接抛IllegalStateException: Already have a parentApp旋转屏幕或从后台切回前台后ViewPager显示的页面和实际恢复的Fragment“对不上”出现白屏或错页这四种现象背后的触发机制不完全相同但对外表现都像是“页面被重新加载了一遍”。如果你遇到过其中任何一种恭喜你你已经站在这个坑的边缘了。1.2 一个看起来完全正常的复现工程很多人在自查时一开始都不相信代码有问题因为写法看起来太标准了。我见过最多的写法是这样的public class HomeFragment extends Fragment { private String title; public static HomeFragment newInstance(String title) { HomeFragment fragment new HomeFragment(); Bundle args new Bundle(); args.putString(title, title); fragment.setArguments(args); return fragment; } Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view inflater.inflate(R.layout.fragment_home, container, false); loadData(); // 在这里发请求看起来没毛病 return view; } }Activity侧viewPager.setAdapter(new FragmentPagerAdapter(getSupportFragmentManager(), BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { Override public Fragment getItem(int position) { return HomeFragment.newInstance(titles.get(position)); } Override public int getCount() { return titles.size(); } });这套代码几乎每个教程都这么写但恰恰是这种“标准写法”把重复加载的问题掩盖了。原因后面会详细拆这里先记住一个结论getItem负责的是“创建Fragment实例”但ViewPager对Fragment的销毁和重建是另一套独立逻辑两者叠加时重复加载就开始出现了。1.3 先分清预加载其实不是Bug这里必须先给ViewPager正个名。ViewPager为了保证滑动时的流畅性默认会预加载当前页左右各一页的页面。也就是说当你停在Tab 1时Tab 2的Fragment其实已经在后台被创建了。这属于正常预加载不是重复加载。预加载的本质是空间换时间让你划过去的那一下页面已经准备好而不是现创建现加载。真正需要警惕的是重复加载同一个位置的业务数据被反复请求、同一个View被反复创建、同一个Fragment在FragmentManager里出现多份实例。把预加载和重复加载分清楚后续选方案时才不会搞错方向。2. 扒开根因ViewPager的缓存机制与Fragment生命周期2.1 ViewPager到底是怎么“养活”前后两页的经典ViewPager内部不是RecyclerView而是一个自己管理的容器。它内部维护了一个ArrayListItemInfo核心方法是populate()每次页面切换时会计算当前页mCurItem的前后各mOffscreenPageLimit个页面需要的就通过Adapter的instantiateItem()去创建并添加不需要的则通过destroyItem()销毁。默认情况下setOffscreenPageLimit的值是1所以ViewPager屏幕外最多保留左右各1个页面。具体到FragmentPagerAdapter它内部会通过FragmentManager.beginTransaction()对Fragment进行add、attach、detach、remove。关键在于detach不等于销毁实例Fragmeng实例还留在FragmentManager里只是View被销毁了。这里才是问题的第一个闸口很多开发者以为“Fragment被销毁了”所以每次进入都重新初始化数据但Fragment实例和它的状态其实一直活着。2.2 FragmentPagerAdapter与FragmentStatePagerAdapter的差异这两种Adapter是所有重复加载问题的源头必须分清楚对比维度FragmentPagerAdapterFragmentStatePagerAdapter销毁策略detach保留实例和状态remove销毁实例可能保存状态View处理View被销毁下次attach时重建View随实例一起销毁适用场景页面少、状态重页面多、需要省内存内存占用高低反复切换时的表现同一实例反复经历onCreateView每次都可能新建实例很多人用FragmentPagerAdapter最大的误解是反正实例不销毁切回来应该不会重新走onCreateView。但实际上detach后View被销毁重新attach时Fragment的onCreateView会再次执行网络请求如果放在这里就会表现为“重复加载”。FragmentStatePagerAdapter则更直接页面滑出缓存范围后实例被移除滑回来时再创建一个全新的实例。这种模式下如果不加缓存控制重复加载问题会更频繁。2.3 最容易触发重复加载的四个写法结合排查经验绝大多数重复加载都出自下面这四种情况在onCreateView或onResume里直接请求数据。这是最常见的一种。onCreateView每次重建View都会执行onResume每次切入都会执行网络请求放在这些位置天然容易重复。Activity重建后Adapter继续用新实例列表。旋转屏幕、深色模式切换、进程重建都会导致Activity重新创建而FragmentManager会恢复之前的所有Fragment实例。这时如果你在Activity的onCreate里又构造了一组新的Fragment传给Adapter旧实例和新实例同时存在页面就会叠加或互相抢位置。手动在页签切换时add Fragment。有的项目为了让切换更可控在TabLayout的监听里手动add或show/hideFragment这等于在ViewPager自己的事务之外又维护了一份Fragment集合。两份事务互相覆盖非常容易出现重复添加。动态调整offscreenPageLimit或调用notifyDataSetChanged。尤其是调小缓存页数原本正在存活范围内的页面突然被destroy滑回来时又重新创建用户肉眼感受就是“每次回来都在重新加载”。2.4 onCreateView反复执行的真相之前有个同事跟我争论“我的Fragment明明只有一个实例为什么onCreateView每次切换都执行”这个问题的答案正是理解整个重复加载问题的最佳切入点。在FragmentPagerAdapter的机制里页面滑出屏幕后Fragmment会进入detach状态此时onDestroyView()会被调用——注意不是onDestroy()。View被销毁但Fragment实例及其持有的数据仍然被FragmentManager管理着。当你再次滑回来时ViewPager会重新attach这个Fragment系统为了让页面更省内存选择重建View。于是onCreateView再次执行。所以onCreateView的重复执行是Android生命周期设计的一部分它不是异常更不是“系统故障”。真正需要解决的是不要让这些生命周期回调反复触发重量级业务逻辑。我们做优化不是去阻止onCreateView执行而是让业务加载这件事变得可控、可复用、可防重。3. 解法一用FragmentManager按Tag复用实例挡住“多实例叠加”3.1 重复实例是怎么来的如果你看到页面叠影或者Fragment的onCreate在短时间内被调用了两次那基本可以断定FragmentManager里出现了同一页面的多个实例。出现这种情况的常见路径是Activity首次创建时getItem返回了new HomeFragment()同时Activity重建时FragmentManager恢复了之前的旧实例。之后ViewPager重新布局发现当前页需要Fragment于是又通过getItem创建了一个新的。旧实例在FragmentManager里新实例又加进来两个都认为自己属于当前页页面自然叠在一起。还有一种路径是程序员自己不小心在代码里除了ViewPager还手动调用了add(fragment)。如果这个fragment实例和ViewPager内部实例不是同一个对象重复就产生了。3.2 正确按“android:switcher:”规则查找实例FragmentPagerAdapter内部给每个Fragment设定了一个稳定tag规则是android:switcher: viewPager.getId() : position所以最稳妥的做法是在getItem中不再无条件new而是先尝试从FragmentManager里按这个tag查找已有实例。查得到就直接复用查不到才创建。public class SafeFragmentPagerAdapter extends FragmentPagerAdapter { private FragmentManager fragmentManager; private int containerId; public SafeFragmentPagerAdapter(FragmentManager fm, int containerId) { super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); this.fragmentManager fm; this.containerId containerId; } Override public Fragment getItem(int position) { String tag android:switcher: containerId : position; Fragment fragment fragmentManager.findFragmentByTag(tag); if (fragment null) { fragment HomeFragment.newInstance(titles.get(position)); } return fragment; } }这里有个细节containerId是ViewPager这个控件的id不是它的父容器id。因为FragmentPagerAdapter的tag规则用的是container.getId()也就是你在XML里给ViewPager设置的android:id。把containerId通过构造器传进来避免在getItem里去拿Activity否则容易踩空指针。3.3 这个方案的边界这个方案解决的是“多实例叠加”和“Activity重建后新旧实例混乱”这两类问题。但它对“View重建导致的业务重复请求”帮助有限——单实例的Fragment依然会在每次attach时执行onCreateView如果数据请求写在onCreateView里该重复还是会重复。所以在项目里我通常把方案一作为第一道防线让实例层次保持干净再配合方案二或方案三去解决View和业务加载的问题。三道防线各司其职才能把重复加载堵死。4. 解法二缓存根布局挡住“重复Inflate与业务初始化”4.1 最省事的View缓存写法如果你希望Fragment反复切换时View不用重新创建布局状态也能原样保留可以在Fragment里缓存根View。这也是很多老项目里广泛使用的一种技巧public class HomeFragment extends Fragment { private View rootView; Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { if (rootView null) { rootView inflater.inflate(R.layout.fragment_home, container, false); initView(rootView); loadData(); } else { // View已存在先移除旧的父容器引用 ViewGroup parent (ViewGroup) rootView.getParent(); if (parent ! null) { parent.removeView(rootView); } } return rootView; } }这个写法有两点必须解释清楚第一为什么rootView为空时才加载数据因为rootView的创建和业务初始化是一体的View不销毁就不用重新inflate自然也不用重新走数据加载。这样即便Fragment被detach再attachonCreateView虽然执行了但进去直接被else分支挡住页面保留原样。第二为什么必须removeView当Fragment从ViewPager中被detach时View其实还挂在ViewPager的容器ViewGroup里。如果不先移除下次返回这个View给它时系统会抛出“Already have a parent”的运行时异常。4.2 与ViewBinding配合的正确姿势现在很多新项目已经用ViewBinding替代了findViewById。如果我告诉你ViewBinding和View缓存不能简单混用你可能会困惑。其实是有正确套路的。ViewBinding和findViewById最大的区别是inflate一次绑定对象后如果对同一个View再次进行绑定可能会导致监听器重复设置、Binding内部状态错乱。所以缓存的粒度要提升到Binding这一层而不是View这一层public class HomeFragment extends Fragment { private FragmentHomeBinding binding; Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { if (binding null) { binding FragmentHomeBinding.inflate(inflater, container, false); binding.textTitle.setText(Hello); loadData(); } return binding.getRoot(); } Override public void onDestroyView() { super.onDestroyView(); // 释放Binding避免内存泄漏 binding null; } }这里有个容易踩的坑如果Fragment被detach后View销毁但我们的缓存意味着View不会销毁那onDestroyView会不会执行答案是detach一定会执行onDestroyView。所以一旦在onDestroyView里把binding置空了下一次attach时binding为空又会重新inflate缓存失效。这是ViewPager下View缓存方案最大的矛盾点。我自己实践下来的处理方式是缓存方案只用于“页面确实需要保留状态”的场景并且不在onDestroyView里无条件清空binding而是用一个额外的布尔变量标记当前Fragment是否已经被ViewPager移除出缓存范围。如果只是常规的detach/attach切换保留binding只有当Fragment被真正remove时才彻底清空。这需要和方案一的实例复用逻辑配合起来建议对生命周期有足够把握后再用。4.3 缓存View的代价与清理时机View缓存并不是免费的。它的代价主要有三个内存占用上升每个Fragment的完整View树都常驻内存。页面少还好页面多了之后几十上百个Fragment的View树会是一笔不小的开销。状态过期风险View被缓存后页面内的数据不会自动刷新。如果列表数据需要实时更新你还得手动提供刷新入口。泄漏隐患如果Fragment实例被销毁了但它的View仍然被某个全局静态变量或者外部的ViewGroup持有就形成了泄漏。这就是为什么缓存View时必须保证“View只存在于Fragment自己的生命周期范围内”。我的建议是缓存View方案适合页面数量少、单页View树简单、需要保留输入状态或滚动位置的多Tab场景。页面一旦超过五个或者单个Fragment布局很深优先考虑方案三的状态机懒加载把内存控制权交还给系统。5. 解法三状态机懒加载把“业务加载”牢牢锁住5.1 懒加载到底想解决什么前面两个方案分别解决了“实例重复”和“View重复创建”但真正的业务重复加载——也就是网络请求重复发起——往往需要一个更直接的机制来约束。懒加载的核心思想很朴素Fragment可以预创建但业务数据只有等用户真的看到这个页面时才加载并且只加载一次。这样ViewPager的预加载机制完全保留不会牺牲流畅性同时重复请求问题从源头上被掐断。5.2 三个标志位设计我用三个布尔变量共同控制懒加载mIsViewCreated标记onViewCreated是否已经执行过代表View层是否就绪mIsVisibleToUser标记Fragment当前是否对用户可见mIsDataLoaded标记业务数据是否已经加载防止重复加载的触发条件必须同时满足View已创建、用户可见、数据未加载。三个条件缺一不可。这样的状态机比简单的“在onResume里发请求”可靠得多因为onResume在页面从后台切回前台时也会执行如果网络请求直接放在onResume里一次前后台切换就能造成一次额外请求。5.3 一份可复制的LazyFragment基类下面是一份我在多个项目里用过、验证过稳定性的Java基类。把它作为所有ViewPager内Fragment的父类子类只需要实现onLazyLoad()方法即可public abstract class LazyFragment extends Fragment { private boolean mIsViewCreated false; private boolean mIsVisibleToUser false; private boolean mIsDataLoaded false; Override public void onViewCreated(NonNull View view, Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); mIsViewCreated true; tryLoad(); } Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); mIsVisibleToUser isVisibleToUser; tryLoad(); } private void tryLoad() { if (mIsViewCreated mIsVisibleToUser !mIsDataLoaded) { mIsDataLoaded true; onLazyLoad(); } } protected abstract void onLazyLoad(); Override public void onDestroyView() { super.onDestroyView(); mIsViewCreated false; mIsDataLoaded false; } }子类这样使用public class HomeFragment extends LazyFragment { Override protected void onLazyLoad() { // 只有第一次可见时才会走到这里 viewModel.loadHomeData(); } }这个基类的关键逻辑是tryLoad()里三个条件的“与”判断。setUserVisibleHint是ViewPager切换页面时用来告知当前页可见性的经典回调。首次创建时如果页面恰好是默认页onViewCreated会先执行如果页面是预加载页可见性为false等滑动过来变成true时才触发。这样无论页面创建和可见的顺序如何加载时机都不会错。5.4 ViewPager2与show/hide下的可见性差异这里必须插一段重要提醒ViewPager2不再调用setUserVisibleHint因为它底层改用了RecyclerView架构Fragment的可见性是通过FragmentManager.setMaxLifecycle控制的。基于ViewPager2的项目如果还用上面的基类setUserVisibleHint永远不会被触发懒加载会完全失效。正确做法是改用onResume配合页面选中状态判断或者在registerOnPageChangeCallback的onPageSelected里手动通知可见性。另一个场景是FragmentTransaction.show/hide这种方式也不会调用setUserVisibleHint因为show/hide操作的是整个View的可见性而不是ViewPager的滑动可见性。如果你用show/hide做多Tab切换需要在onHiddenChanged(boolean hidden)回调里自己处理Override public void onHiddenChanged(boolean hidden) { super.onHiddenChanged(hidden); if (!hidden) { mIsVisibleToUser true; tryLoad(); } else { mIsVisibleToUser false; } }所以继承LazyFragment之前先确认你的容器是ViewPager还是ViewPager2是滑动切换还是show/hide切换。机制不一样可见性回调入口完全不一样写错了一个地方懒加载就静默失效。6. 实测对比与排查链路不同场景怎么选出了事怎么查6.1 三种方案的效果对比我用一个三Tab的测试项目对三种方案做了快速切换100次的实测。测试机型是开发常用的中端机统计口径分别是onCreateView执行次数、网络请求次数、页面切换后内存增量。方案onCreateView次数网络请求次数内存表现适用场景不处理对照组100左右100波动大不推荐方案一按Tag复用实例80左右100稳定解决实例叠加单用效果有限方案二缓存根布局约30约30持续上升页面少、状态需要保留方案三懒加载基类100左右约30稳定通用性最强推荐优先使用说明一下方案一的onCreateView看起来还是高是因为它本身不会阻止View重建方案三允许onCreateView执行但网络请求被锁住了所以业务请求次数大幅下降。实际项目中使用时方案一和方案三可以叠加用方案一保证Fragment实例唯一用方案三保证业务只加载一次组合效果是最好的。6.2 排查链路从一份日志开始如果项目里已经出现了疑似重复加载的问题别急着套方案。先把现象定位清楚对号入座去修复。我的排查步骤通常是这样的第一步在Fragment的所有关键生命周期回调里打上带时间戳的Log包括onCreate、onCreateView、onViewCreated、onResume、onPause、onDestroyView、onDestroy。连续切换Tab观察Log输出序列。第二步判断是“多实例”还是“单实例重建”。在Activity里打印getSupportFragmentManager().getFragments().size()如果同一页面出现了两个HomeFragment实例的记录基本就是实例叠加问题走方案一。第三步检查Adapter配置。重点看Activity每次重建时是否重新new了Adapter或Fragment列表以及getItem里是否用了new Fragment()。这一步能排除大半的“Activity重建后状态混乱”。第四步检查Fragment的Tag。看看有没有手动传自定义tag或者getItemId被重写时返回了不稳定值。tag不稳定findFragmentByTag会失效方案一就用不上。第五步用Android Studio的Layout Inspector看当前界面层级。如果发现两套相同的View叠在一起可以确认是重复添加优先检查是否有地方手动执行了add事务。第六步如果Log显示“单实例、无叠影、但网络请求依然重复”直接上方案三懒加载。这类问题通常是业务代码放在了onCreateView或onResume这类高频回调中需要用状态机把加载行为锁住。6.3 隐藏细节与团队规范排查过程中我还发现过几个容易让人误判的隐藏细节这里一并分享不要用Fragment的有参构造传业务对象。Activity重建后系统会通过无参构造恢复Fragment如果你在构造器里传了复杂的业务对象恢复时拿不到就会被迫创建新实例。正确方式是用newInstance()配合setArguments(Bundle)传参。不要轻易做静态单例Fragment。我看到过有人把Fragment做成静态单例来“解决”重复创建结果ViewPager销毁重建后同一个实例被试图添加到不同的容器直接崩溃。静态单例在这里是饮鸩止渴。onResume不等于“用户正在看这个Tab”。在ViewPager的BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT模式下默认只有当前页的onResume会被回调但有些旧项目用的是旧的BEHAVIOR_SET_USER_VISIBLE_HINT模式onResume的语义会不一样。排查时先确认项目用的是哪种Behavior。offscreenPageLimit调大要谨慎。有些人觉得设成2、3页可以避免滑动重建但每多一页就多一页的Fragment预创建和预加载对内存和耗电都是负担。很多“重复请求”其实是被这个设置放大的。团队协作上我后来在code review里定了一条规矩凡是ViewPager内的Fragment网络请求和页面初始化的调用点必须收敛到onLazyLoad()或者一个明确的loadOnce()方法中禁止散落在onCreateView、onResume、onStart里。这条规矩执行两个迭代周期之后线上相关的重复加载反馈基本清零。6.4 最后一点个人体会那次排查花了我一个完整的下午最后发现代码逻辑本身没毛病问题全出在对ViewPager生命周期机制的误解上。后来我养成了一个习惯每做一个多Tab页面先问自己三个问题——Fragment实例是谁创建的它的View什么时候会被销毁业务加载函数会被哪些生命周期回调触发。把这三个问题的答案写清楚重复加载的隐患自然就暴露出来了。如果你现在也在被这个问题的日志刷屏别对着getItem发呆也别急着堆缓存代码。先用Log确定自己遇到的属于哪一种“重复”实例叠加、View重建、还是业务请求重复。对号入座去选方案基本都能干净解决。
返回列表