ARTICLE DETAIL

资讯详情

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

Android ViewPager入门到进阶:Adapter选型、常见坑与无限轮播实战

Android ViewPager入门到进阶:Adapter选型、常见坑与无限轮播实战 1. 为什么几乎所有App都离不开ViewPager先搞懂它解决什么问题很多人第一次接触Android开发做的第二个或者第三个页面就是引导页、轮播图或者首页Tab页。我见过不少新手在群里问“ViewPager到底有什么用”也有做了半年开发的同学还在靠Copy代码的方式用ViewPager。这篇东西我就从基础说起把ViewPager的“简单使用”讲透包括它背后的工作机制、调用的API、以及那些文档里不会写但实际开发一定会踩的坑。先说结论ViewPager是Android官方提供的一个容器控件核心能力是让多个页面View或者Fragment可以横向滑动切换并且切换过程带过渡动画。这种交互模式在移动端太常见了——引导页一页一页往左滑、首页顶部Banner自动轮播、资讯类App的Tab页左右切换、商品详情页的图片画廊底层基本都是ViewPager在干活。1.1 没有ViewPager的日子是怎么过的如果不用ViewPager你想实现“左右滑动切换页面”最原始的办法是自己写一个HorizontalScrollView把多个页面横向排开然后监听滑动距离来计算当前应该停在哪个位置。听起来不复杂但你很快会遇到几个头疼的问题页面内容多了以后一次性全部加载会导致首屏卡顿滑到一半松手时没有自动吸附到某一页的逻辑每个页面内部的点击事件和手势会跟外层滑动冲突页面销毁和重建完全要自己管理内存压力大了以后你根本不知道是该保留还是该回收。ViewPager为这些问题提供了标准答案它自带滑动手势处理自动判断是翻页还是取消它通过setOffscreenPageLimit()控制预加载页数帮你平衡流畅度和内存它要求你通过Adapter提供页面内容从而解决了页面的创建和回收问题。你可以把ViewPager理解成“专门给页面容器用的RecyclerView”——RecyclerView解决的是列表项的复用和布局ViewPager解决的是页面的滑动和切换。1.2 一图流理解ViewPager的工作机制ViewPager的工作机制核心就一条它本身不保存页面内容只负责“问Adapter要页面”。当用户滑动时ViewPager根据当前滑动位置和方向计算出需要展示哪些页面然后调用Adapter的instantiateItem()去创建、destroyItem()去销毁、isViewFromObject()去判断复用关系。这个套路和RecyclerView的Adapter如出一辙。换句话说ViewPager只是一个壳真正的业务逻辑都在Adapter里。这就是为什么所有ViewPager教材都会花大篇幅讲Adapter——理解了Adapter的几个方法你就理解了ViewPager的一半。1.3 ViewPager在真实项目中的典型场景从实际项目来看ViewPager最常见的使用场景有这么几类一是引导页首次安装时展示产品功能介绍二是首页Banner配合Handler或者定时器做自动轮播三是Tab页切换跟TabLayout、BottomNavigationView配合构成应用的主框架四是图片浏览类似相册里左右滑动看大图。后面这两种场景里页面通常不是简单的布局文件而是完整的Fragment所以衍生出了FragmentPagerAdapter和FragmentStatePagerAdapter两个专门的Adapter。下面我用一整章来讲Adapter家族这是新手最懵的部分也是面试最常问的部分。2. Adapter家族怎么选PagerAdapter、FragmentPagerAdapter、FragmentStatePagerAdapter到底啥区别我在带新人的时候经常发现一个现象大家知道要写一个Adapter继承FragmentPagerAdapter也能把Demo跑起来但你问他为什么不是继承PagerAdapter为什么要传BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT这个参数答不上来。这不行基础教程的价值就是把这些知识点讲清楚不然换一个场景你就不会写了。2.1 PagerAdapter是根View和Fragment只是它的两种表现形式PagerAdapter是所有ViewPager适配器的基类。它要求你实现四个核心方法public abstract int getCount(); public abstract boolean isViewFromObject(View view, Object object); public abstract Object instantiateItem(ViewGroup container, int position); public abstract void destroyItem(ViewGroup container, int position, Object object);getCount()返回总页数instantiateItem()负责创建第position页的视图并添加到container里同时返回一个Object作为该页的标识isViewFromObject()负责判断container里的某个View是不是对应某个Object标识也就是复用判断destroyItem()负责销毁滑动离开的页面。如果你直接用PagerAdapter那页面内容就是一个View比如ImageView或者TextView。这种方式用在引导页、Banner这类页面简单直接的场景没有问题。但如果你每个页面都要加载数据、管理生命周期甚至需要用到Fragment的特性那就不能只塞一个View了你得往instantiateItem()里添加一个Fragment来占位。这就是下面两个FragmentAdapter存在的原因。2.2 FragmentPagerAdapter和FragmentStatePagerAdapter的核心差异这两个类名字很像很多人直接当作同一个东西用。实际上它们的页面管理策略完全不同选错了典型症状是“页面多的时候内存暴涨”或者“Fragment状态莫名其妙的丢失”。FragmentPagerAdapter的做法是Fragment一旦创建就会一直保存在FragmentManager里不会销毁只会detach再attach。它的优势是切换非常快因为没有重建成本劣势是页面多了以后内存占用很高。所以它适合页面数量少且固定的场景比如三个Tab的主框架。FragmentStatePagerAdapter的做法是Fragment在不可见时会真正销毁只保存状态Bundle划回来时重新创建。它的内存表现要好很多但代价是每次切换需要重新走一遍onCreateView如果页面里有重量级数据你会发现卡顿。它适合页面数量多、内容重的场景比如一个几屏的新闻列表。还有一个很多人忽略的点从API 28开始构造函数需要显式传入BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT或BEHAVIOR_SET_USER_VISIBLE_HINT。前者表示只有当前页是RESUMED状态其他页最多到STARTED后者是旧的默认行为。官方推荐用BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT因为这样能保证只有当前可见的Fragment在onResume里执行业务比如上报曝光、停止播放视频。2.3 选型建议三种Adapter分别什么时候用场景推荐Adapter理由引导页、Banner纯View页面PagerAdapter轻量没有Fragment开销Tab框架固定3-5个FragmentFragmentPagerAdapterFragment常驻切换快状态稳定列表页很多内容量大FragmentStatePagerAdapter内存可控销毁重建可接受这个表记住就行面试和实际开发都够用。记住一句话页面少用PagerAdapter或者FragmentPagerAdapter页面多用StatePagerAdapter。3. 第一次实战从零搭一个带TabLayout的ViewPager理解了原理动手写起来其实很快。下面我用最常用的“TabLayout FragmentPagerAdapter”组合演示一遍完整流程。开发环境以Android Studio为准这是目前Android开发的事实标准IDE顺带说一句网上问“Android Studio怎么设置中文”的朋友很多新版AS在Settings - Appearance Behavior - System Settings - Language and Region里可以直接选简体中文不用再装什么汉化包了。3.1 添加依赖在build.gradle模块级里添加Material库的依赖TabLayout就在这个库里dependencies { implementation com.google.android.material:material:1.9.0 }注意版本号按你自己的项目情况来建议用Android Studio的Dependencies面板在线搜索最新稳定版不要硬编码一个过老的版本。3.2 写布局文件主布局里放一个TabLayout和一个ViewPager纵向排列LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical com.google.android.material.tabs.TabLayout android:idid/tabLayout android:layout_widthmatch_parent android:layout_heightwrap_content / androidx.viewpager.widget.ViewPager android:idid/viewPager android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / /LinearLayout这里用layout_weight1让ViewPager占据剩余高度是标准的做法。很多人写布局的时候不写weight结果ViewPager被推到屏幕外面去找了半天问题其实是布局写错了。3.3 创建Fragment每个Tab页面对应一个Fragment。简单起见写一个带参数的Fragment根据传入的标题显示不同内容public class PageFragment extends Fragment { private static final String ARG_TITLE arg_title; public static PageFragment newInstance(String title) { PageFragment fragment new PageFragment(); Bundle args new Bundle(); args.putString(ARG_TITLE, title); fragment.setArguments(args); return fragment; } Nullable Override public View onCreateView(NonNull LayoutInflater inflater, Nullable ViewGroup container, Nullable Bundle savedInstanceState) { TextView textView new TextView(requireContext()); String title getArguments() ! null ? getArguments().getString(ARG_TITLE) : ; textView.setText(这是 title 页面); textView.setTextSize(24); textView.setGravity(Gravity.CENTER); return textView; } }注意用newInstance加上setArguments()传参而不是直接new PageFragment(xxx)。原因在于当系统因为内存不足回收Fragment后会通过无参构造函数重建Fragment如果参数是直接写在字段里的重建之后就丢了通过setArguments()传的参数会被系统保存在Bundle里能正确恢复。这是Android Fragment机制的基本常识新人一定要养成这个习惯。3.4 写AdapterAdapter继承FragmentPagerAdapter主要做三件事返回页面数量、按位置创建Fragment、返回Tab标题public class MainPagerAdapter extends FragmentPagerAdapter { private final ListString titles; public MainPagerAdapter(NonNull FragmentManager fm, ListString titles) { super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); this.titles titles; } NonNull Override public Fragment getItem(int position) { return PageFragment.newInstance(titles.get(position)); } Override public int getCount() { return titles.size(); } Nullable Override public CharSequence getPageTitle(int position) { return titles.get(position); } }3.5 在Activity里组装public class MainActivity extends AppCompatActivity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); TabLayout tabLayout findViewById(R.id.tabLayout); ViewPager viewPager findViewById(R.id.viewPager); ListString titles Arrays.asList(首页, 发现, 消息); MainPagerAdapter adapter new MainPagerAdapter(getSupportFragmentManager(), titles); viewPager.setAdapter(adapter); tabLayout.setupWithViewPager(viewPager); } }到这里一个可以左右滑动、带Tab指示器的应用框架就出来了。注意tabLayout.setupWithViewPager(viewPager)这行它会自动把Tab和页面绑定点击Tab切页面、滑动页面Tab跟着动、Tab的标题自动从getPageTitle()里取。很多人写到这里发现Tab上没文字99%是因为忘了在Adapter里重写getPageTitle()。3.6 进阶多Tab页面怎么配合协调布局实际App里首页顶部往往不是单独一个TabLayout而是跟AppBarLayout、CollapsingToolbarLayout配合形成“下拉折叠头部 滑动切换内容”的效果。这个体系叫CoordinatorLayout协调布局Banner轮播、SearchBar悬浮、Tab吸顶都是这个布局方案里实现的。用法不复杂核心是把TabLayout放进AppBarLayout再通过app:layout_scrollFlags控制头部是否折叠。这部分属于ViewPager的扩展场景先记住有这回事等基础Demo跑通再深入。4. 踩坑实录ViewPager用起来简单的背后是这些隐藏问题ViewPager的Demo确实十分钟能跑通但放到真实项目里就会冒出各种诡异问题。下面我把自己踩过的、以及帮别人排查过的高频坑列出来每一条都有真实的场景描述和排查路径不是网上那种“你需要注意以下几点”的应付式总结。4.1 setOffscreenPageLimit不是越大越好setOffscreenPageLimit(int limit)用来设置当前页两侧预加载的页面数量默认是1。你会发现官方文档写的是“Page limit”很多人以为设得越大越流畅于是设成3、4甚至更多。页面确实不重建了但Fragment数量多了以后占用内存也大而且预加载的Fragment在不可见时也会执行onCreateView如果里面有请求接口、加载图片的逻辑会白白消耗流量和资源。我给一个相对稳妥的建议页面内容轻、数量少保持默认就行如果页面内容重建议把初始化数据放到setUserVisibleHint或者结合BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT的onResume里做懒加载而不是用大的OffscreenPageLimit硬扛。4.2 Fragment重建导致的“页面空白”用一个真实案例来说明。我曾经维护过一个资讯类AppTab页里每个Fragment是一个列表用户滑到第三个Tab过了十几分钟再切回来发现页面空白。排查链路是先复现发现切到后台再回来容易触发然后加日志发现getItem没有被调用但Fragment的onCreateView执行了继续查发现问题是FragmentPagerAdapter在持有FragmentManager的引用时遇到Activity因内存原因重建Fragment实例被恢复了但ViewPager的Adapter是新建的导致ViewPager持有的Fragment引用和FragmentManager里的不一致。这个问题的终极解法是不要手动持有Fragment引用也不要试图用findFragmentByTag去恢复页面数据而是依赖FragmentPagerAdapter的自动恢复机制。如果你确实需要在Activity重建后保留一些状态把状态放进Fragment的onSaveInstanceState()里而不是放在Activity的成员变量中。这个坑的排查过程花了我差不多一个下午最后发现答案是“什么都不用作只要你在Adapter里用的是getSupportFragmentManager()而不是自定义的FragmentManager”。4.3 多层ViewPager嵌套滑动冲突Banner里套ViewPager、ViewPager里再套横向RecyclerView这种嵌套结构一多手势冲突就来了。常见的表现是手指放在内层页面上左右滑外层的ViewPager也跟着动了或者完全不动。原因很简单多个可横向滑动的容器都在响应同一个手势系统不知道你想操作哪一个。解决办法有三个层次轻量方案内层容器在onTouchEvent里根据滑动角度判断方向横向滑动时请求父容器不要拦截纵向滑动时不处理。通用方案给内层RecyclerView写一个LinearLayoutManager子类重写canScrollHorizontally()当内层到达边界时返回false让事件手势回传给外层ViewPager。治理方案从产品层面避免这种嵌套。一个ViewPager嵌套多个横向滑动区域本身就违背了移动端交互的核心直觉能拆就拆。我个人的处理原则是能避免就避免避免不了就按场景定制手势判断逻辑绝不用“统一设置一个flag”这种一刀切的做法。因为每个页面的滑动需求不一样一刀切会引发更隐蔽的手势问题。4.4 从相册选图放在ViewPager里展示时的权限问题Banner和图片浏览器里经常需要从相册选图。新项目如果用ActivityResultContracts.PickMultipleVisualMedia这类API选多张图要注意这属于ActivityResult API的范畴跟ViewPager本身没有关系但它会交叉影响。常见的幺蛾子是把选图结果的Uri直接传给ImageLoader结果部分机型上图片加载不出来报错信息里会出现类似content://com.android.providers.media.documents/document/image%3A123这种Uri。原因是没有持久化读取权限或者没有在加载器里处理ContentResolver的查询逻辑。我的建议是无论是PickVisualMedia还是文件路径方式拿到Uri以后统一转成一个本地缓存文件路径再交给图片加载框架。这样既绕开了权限生命周期问题也让ViewPager的Adapter实现更简单——它只需要接受字符串路径不需要知道Uri从哪来。这个习惯在写Banner、图片浏览器的时候能省掉很多麻烦。5. 从“能跑”到“好用”无限轮播、卡片叠加这些玩法怎么接进去ViewPager高级玩法里问得最多的是两个一个是无限轮播Banner一个是叠卡片效果。热搜里“android viewpager叠卡片”最近讨论度很高我在这里把它们的实现思路都说一下都只需要在Adapter上做文章不需要改ViewPager本身。5.1 无限轮播取模思想加一个计数终点的假循环实现无限轮播Banner的经典做法是把总数设成一个很大的值比如Integer.MAX_VALUE然后在getItem里对真实数量取模Override public Object instantiateItem(ViewGroup container, int position) { int realPosition position % realCount; // 用realPosition创建View } Override public int getCount() { return Integer.MAX_VALUE; }这样用户可以从第一个滑到“第无数个”来回滑动永远到不了终点视觉上就实现了无限轮播。初始位置设成realCount * 1000这种偏中间的数值是为了让左右两个方向都能滑。如果你的页面是Fragment而不是纯View就用FragmentStatePagerAdapter因为它会真正销毁远离的Fragment不会让无限多个Fragment堆在内存里。注意别用FragmentPagerAdapter做无限轮播那个会把所有Fragment实例常驻滑几个小时你的内存就爆了。自动轮播的定时器方面我的建议是用Handler.postDelayed()配合onPageSelected回调每次页面切换完成后重置定时器。这样用户手指按住暂停、松开继续体验比单纯一个死循环Thread.sleep好很多。5.2 叠卡片效果负Margin加缩放配合所谓的“叠卡片”CoverFlow风格核心是让相邻页面露出一部分并且当前页放大、两侧页缩小。实现分两步。第一步在布局里给ViewPager设置负的左右Margin让页面之间能重叠androidx.viewpager.widget.ViewPager android:idid/viewPager android:layout_widthmatch_parent android:layout_height200dp android:layout_marginLeft-40dp android:layout_marginRight-40dp /第二步给ViewPager设置PageTransformer在transformPage()里根据滑动偏移量动态改变缩放和透明度viewPager.setPageTransformer(false, new ViewPager.PageTransformer() { Override public void transformPage(NonNull View page, float position) { if (position 0) { // 当前页或左边页 page.setScaleX(1 position * 0.1f); page.setScaleY(1 position * 0.1f); page.setAlpha(1 position); } else { // 右边页 page.setScaleX(1 - position * 0.1f); page.setScaleY(1 - position * 0.1f); page.setAlpha(1 - position); } } });position的范围是从-1到10代表页面正好停在正中间负数代表在当前页左边正数代表在右边滑动过程中它会连续变化。利用这个值你就可以做各种过渡效果缩放、平移、旋转、透明度、圆角变化全都能做。这个API是ViewPager留给开发者最灵活的定制口想要做出与众不同的交互效果花时间研究PageTransformer是值得的。5.3 Banner不进协调布局的替代方案很多项目在不引入CoordinatorLayout的前提下直接用RelativeLayout把ViewPager放在某一块固定高度的区域上面盖一层“跳过”按钮也能实现基础的Banner交互。如果将来要加“上滑时Banner折叠、下滑时展开”这类效果再迁移到协调布局也不迟。先跑通再说不要为了用一种高级布局而引入一套复杂的依赖。6. ViewPager2来了旧项目要不要迁移最后说一下ViewPager2。现在新建项目Google官方推荐直接用androidx.viewpager2:viewpager2替代旧版ViewPager。ViewPager2内部就是用RecyclerView实现的所以它天然支持DiffUtil、LayoutManager这些RecyclerView的能力还解决了旧版ViewPager几个老大难问题不支持下划线式Indicator它直接用RecyclerView.Adapter、不支持竖向滑动它支持设置orientation、Fragment的懒加载更规范。6.1 两个版本的关键差异对比维度ViewPagerandroidxViewPager2内部实现自定义ViewGroupRecyclerViewAdapterPagerAdapter系列RecyclerView.Adapter页面类型View或FragmentView或Fragment竖向滑动不支持支持PageTransformersetPageTransformer注册到RecyclerView动画机制懒加载需自行处理FragmentStateAdapter内置TabLayout联动setupWithViewPager直接支持需要借助TabLayoutMediator6.2 老项目迁移的实际成本迁移成本主要看你的代码里用到了哪些新特性。如果是纯View页面迁移成本很低把PagerAdapter改成RecyclerView.Adapter把instantiateItem、destroyItem改成onCreateViewHolder和onBindViewHolder就行。如果是Fragment页面需要把FragmentPagerAdapter换成FragmentStateAdapter同时把tabLayout.setupWithViewPager(viewPager)换成用TabLayoutMediator来绑定。这里要说清楚TabLayout和ViewPager2的联动API变了这是迁移最容易被卡住的地方。setupWithViewPager在ViewPager2里不存在了必须用new TabLayoutMediator(tabLayout, viewPager2, (tab, position) - { tab.setText(titles.get(position)); }).attach();TabLayoutMediator的作用和旧版setupWithViewPager类似但它要求你显式声明如何配置Tab的标题所以代码看起来更啰嗦好处是灵活性更高。6.3 我的建议新项目直接用ViewPager2没毛病生态已经成熟第三方库基本都兼容了。老项目如果当前ViewPager跑得好好的没有翻页卡顿、没有Fragment状态丢失、没有竖向滑动需求就不必急着大动干戈迁移。等你有重构需求的时候一起带过去比单独切Adapter要省事得多。我见过一些团队把迁移ViewPager2列在技术债清单里每次排期都往后拖最后拖到整个底层的Fragment容器结构都要改成本翻了不止一倍。技术栈升级这种事要结合项目现状和排期节奏不能为了追新而追新但也不能因为怕麻烦一直拖着。写到这里ViewPager从“知道”到“会用”的链路算是打通了。从Adapter选择、第一步实战、易踩的坑到无限轮播和卡片叠放玩法再到ViewPager2的迁移判断每一部分都是实际开发中真正会遇到的。最后分享两个我自己的习惯做ViewPager相关的页面时先在纸上画清楚页面的数据来源和生命周期再动手写代码遇到参数不理解的API先去看官方源码注释大多数困扰网上传得神乎其神的问题源码里三五行注释就能说明白。基础教程能给你的只是路径真正的手感还得靠自己在项目里一遍一遍试。
返回列表