踩石行动:ViewPager无限轮播的坑

2016-6-19

前言

View轮播效果在app中很常见,一想到左右滑动的效果就很容易想到使用ViewPager来实现。对于像我们常说的banner这样的效果,具备无限滑动的功能是可以用ViewPager实现的,不过使用ViewFlow更简单些。

最近项目里的一个页面的banner功能出了问题,使用的是viewPager + handler实现的,之前的代码实在是设计的过于复杂,就自己重新实现了一遍。整体来说,ViewPager可以实现无限滚动,但方式比较绕。

ViewPager的使用

首先来简单概括下ViewPager的使用。

1.编写PagerAdapter。

需要实现PagerAdapter中以下方法:

  • Object instantiateItem(ViewGroup container, int position)

    ViewPager每次最多需要保持1-3个View,此方法就是我们提供page view的地方。生成的View对象一定要添加到container中才可以正常显示。返回的Object对象是和此View关联的一个自定义对象(类似View.setTag),比如可以把一个对应View的数据对象返回。一般的,没有特殊需要时,我们返回View对象本身。

  • boolean isViewFromObject(View view, Object object)

    就是指示ViewPager中的View对象和instantiateItem返回的Object对象的关系。如果在instantiateItem我们返回的是View本身,那么此处return view == object就可以。

  • void destroyItem(ViewGroup container, int position, Object object)

    要知道PagerView是每次最多显示3个page view的,为了像ListView对应的BaseAdapter那样复用View对象,此方法为我们提供了回收添加到ViewPager中的不再显示的对象的方式。

    object就是instantiateItem返回的对象,container、position正是instantiateItem的position,container。

    根据前面的分析,在destroyItem中,我们把position处的page view从container移除即可,此处的object对象正是instantiateItem中add到container的page view对象。执行完container.removeView((View) object)后,可以使用一个List来维护回收的View,这样可以避免创建大量的View对象——就像ListView的BaseAdapter那样——转而使用List中的可服用View对象,确切的说,如果展示的是同一“类型”的视图(布局orView),那么最多需要4个View对象,我们就可以满足ViewPager的显示需要了。

  • public int getCount()

    返回ViewPager要展示的page view的总数量。ViewPager的左右滑动正是根据getCount()以及当前展示的page的位置来控制的。

2. ViewPager和PagerAdapter关联同步

ViewPager和PagerAdapter的关系就如同ListView和BaseAdapter的关系,是视图和视图数据适配器的关系——满满都是模式。

  1. ViewPager.setAdapter(PagerAdapter adapter)

    首先把创建好的PagerAdapter对象设置给ViewPager对象,这样,它们就关联了。ViewPager就展示了此PagerAdapter的数据。

  2. ViewPager.setCurrentItem(int item)

    设置viewPager当前展示的page位置,默认是0。

  3. PagerAdapter.notifyDataSetChanged()

    当PagerAdapter的数据发生改变时,必须执行此方法和关联的ViewPager进行同步,否则运行中会产生异常。

    不过PagerAdapter不像BaseAdapter那样,notifyDataSetChanged方法在UI表现上是有问题的,建议每次数据发生变化后,直接使用setAdapter重新关联。原因下面会有说明

实现无限滑动的思路

典型的,为了让ViewPager可以无限滑动,我们让getCount返回一个很大的值,例如Integer.MAX_VALUE,然后setCurrentItem把ViewPager显示的当前Page设置在总页数的中间位置。

思路如上,下面给出完整的代码:

...
import com.nostra13.universalimageloader.core.DisplayImageOptions;
import com.nostra13.universalimageloader.core.ImageLoader; public class BannerPagerAdapter extends PagerAdapter {
private ArrayList<ImageView> reusableImgViews = new ArrayList<>();
private ArrayList<String> bannerPicList = new ArrayList<>();
private Activity activity; private DisplayImageOptions displayImageOptions = new DisplayImageOptions.Builder()
.cacheInMemory(true).cacheOnDisk(true)
.bitmapConfig(Bitmap.Config.RGB_565)
.resetViewBeforeLoading(true)
.considerExifParams(true)
.build(); public BannerPagerAdapter(Activity activity) {
bannerPicList.add("http://img1.gtimg.com/auto/pics/hv1/63/227/1381/89857473.jpg");
bannerPicList.add("https://images0.cnblogs.com/i/316630/201408/092010425847554.png");
bannerPicList.add("http://img5.imgtn.bdimg.com/it/u=854234410,2851953187&fm=15&gp=0.jpg");
bannerPicList.add("http://img0.imgtn.bdimg.com/it/u=1615470112,4224934998&fm=15&gp=0.jpg"); this.activity = activity;
} @Override
public int getCount() {
return Integer.MAX_VALUE;
} public int getStartPageIndex() {
int index = getCount() / 2;
int remainder = index % bannerPicList.size();
index = index - remainder;
return index;
} @Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
} @Override
public Object instantiateItem(ViewGroup container, int position) {
ImageView imgView;
if (reusableImgViews.size() == 0) {
imgView = new ImageView(activity);
imgView.setScaleType(ImageView.ScaleType.FIT_XY);
} else {
imgView = reusableImgViews.remove(reusableImgViews.size() - 1);
} String url = bannerPicList.get(getBannerIndexOfPosition(position));
ImageLoader.getInstance().displayImage(url, imgView, displayImageOptions); container.addView(imgView);
return imgView;
} @Override
public void destroyItem(ViewGroup container, int position, Object object) {
container.removeView((View) object);
reusableImgViews.add((ImageView) object);
} private int getBannerIndexOfPosition(int position) {
return position % bannerPicList.size();
}
}

在Activity的onCreate中:

void onCreate(Bundle savedInstanceState) {
...
viewPager = (ViewPager) findViewById(R.id.banner_viewpager);
BannerPagerAdapter adapter = new BannerPagerAdapter(this);
viewPager.setAdapter(adapter);
viewPager.setCurrentItem(adapter.getStartPageIndex());
...
}

以上代码实现简单的无限滑动足够了,但是,ViewPager有几个局限性,甚至是坑值得注意。

ViewPager的局限性

1. setCurrentItem卡顿

当getCount返回的页数非常大的时候,比如10亿,调用setCurrentItem会引起ANR。这个和getCount以及当前的page位置有关。通过查看源码可以发现,ViewPager中的populate(int newCurrentItem)calculatePageOffsets(ItemInfo curItem, int curIndex, ItemInfo oldCurInfo)这两个方法中,有for循环的执行次数和getCount成正比,具体细节有兴趣的朋友可以观察源码。

经过我的实验,在pageCount非常大的时候,setCurrentItem方法如果引起ViewPager的页码切换跨度大于1时,就会引起明显的卡顿。正巧的是,我们使用ViewPager实现滑动效果(handler自动++或--页码)的时候,每次页码仅仅是增加或者减小1,所以不会卡顿。但是,如果代码中有逻辑setCurrentItem引起页码变化大于1,比如当前在第3页,直接切换到getCount() / 2页时,直接就ANR了。

有意思的是,在onCreate中setAdapter之后,第一次viewPager.setCurrentItem(adapter.getStartPageIndex())并不会引起ANR,应该是onCreate时ViewPager还没有执行一些内部计算的原因。

setCurrentItem引起的ANR和是否指定第二个参数smoothScroll没有关系。

2. notifyDataSetChanged后滑动效果不对

这个情况是UI表现上,ViewPager的左右滑动效果的小bug。

在正常使用ViewPager,没有任何无限滑动的逻辑的情况下:

假设第一次setAdapter的时候,getCount返回1,此时ViewPager只有一个page,不可以左右滑动。

然后改变Adapter对象的内部数据集合大小,getCount返回3,notifyDataSetChanged后,此时可以滑动3个页面。

接下来再修改数据集合,让getCount返回1,notifyDataSetChanged后,此时按期望,ViewPager是不可以滑动的,但是,实际效果是:ViewPager可以滑动——看得见之前3页时的额外View——看到1个还是2个和——notifyDataSetChanged时ViewPager的正在显示的page有关,但是无法滑动到除position为1的其它页码。

大家有兴趣可以自己试下,解决方法很奇葩:

就是每次adapter的数据发生变化后,根据需要先setCurrentItem到默认起始位置,之后执行setAdapter就行。PagerAdapter的notifyDataSetChanged并不像它应该承诺的那样,而为了实现在Adapter数据发生变化后通知更新ViewPager的目的:需要再次执行viewPager.setAdapter(adapter)

3. 关于viewPager设计的吐槽

ViewPager显然是按照了ListView那样的方式来计算总页数的,但是对于一个每次只显示3页的View来说,每次左滑和右滑的时候调用一个让子类重写的判断是否还有左边page view和右边page view的方法岂不更好?

setCurrentItem里面的逻辑简直了,竟然和getCount成正比耗费时间,那就只能当设计者根本没有考虑使用此View在非常大量数据的情况了!真不知道ViewPager是性能卓越了,还是功能丰富了,比起ViewFlow,不知道它多出那么多代码的情况下,还有notifyDataSetChanged和setAdapter的UI表现不同这样的狗血。

更好的无限滑动的解决方案

由于ViewPager的总页数很大时对setCurrentItem造成的限制。需要避免getCount返回很大值来实现可以“无限”左右滑动的假象。

1. getCount、getPageIndexOfPosition

getCount返回一个很小的值,例如360来让viewPager保证可以左右滑动就行。这里假设实际有n个View,那么getCount返回n + 2就可以了,但是,为了避免频繁的setCurrentItem来重置当前页,这个值用不着太小。

举个例子,对于有n = 5个View需要通过ViewPager来实现无限滑动的情况,getCount返回300,那么在instantiateItem等地方,需要根据ViewPager显示的page的position来得到实际的数据集合里显示的数据的索引:

getPageIndexOfPosition方法的逻辑很直接:

public int getItemIndexForPosition(int position) {
return position % data.size();
}

position就是ViewPager对应展示的page view的位置,position和要展示的数据集合的大小的余数就是对应数据集合的数据的索引。

2. setCurrentItem重置viewPager的当前页

当getCount返回一个不是很大的值的时候,ViewPager很快就会到达左右边界,就无法继续滑动了。

解决方式是在ViewPager快要切换到边界时,使用setCurrentItem把它重置回中间位置。

为ViewPager提供继承自SimpleOnPageChangeListener的类的对象:

viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {
if (position <= 2 || position >= adapter.getCount() - 3) {
// 重置页面
int page = adapter.getItemIndexForPosition(position);
int newPosition = adapter.getStartPageIndex() + page;
viewPager.setCurrentItem(newPosition);
}
}
});

注意2点:

  • 重置前后显示的实际数据的位置需要保持不变。

  • 如果考虑到用户体验,为了保证滑动过程中切换page不是非常生硬,可以先setCurrentItem到newPosition +/- 1位置,之后再setCurrentItem(newPosition, true)动画滑动到正确位置。

上面就通过减少getCount的值,结合setCurrentItem完成了ViewPager的无限滑动。

自动轮播

使用handler的sendEmptyMessageDelayed很容易让ViewPager以固定频率自带切换页面。这里强调下,使用线程当然也可以,就是性能上看,避免线程来完成这种“定时”效果——大材小用,Thread是为了不卡顿主线程执行耗时的操作,简单的定时操作handler消息轮询就可以了,app中不要让thread泛滥。

这里handler的所有操作都应该在UI线程中被调用,没有同步的必要:

class AutoScrollHandler extends Handler {
boolean pause = false; @Override
public void handleMessage(Message msg) {
if (!pause) {
viewPager.setCurrentItem(viewPager.getCurrentItem() + 1);
}
sendEmptyMessageDelayed(msg.what, 3000);
} void startLoop() {
pause = false;
removeCallbacksAndMessages(null);
sendEmptyMessageDelayed(1, 3000);
} void stopLoop() {
removeCallbacksAndMessages(null);
}
}

上面pause是为了实现在手指拖拽ViewPager的时候暂停自动轮播,在SimpleOnPageChangeListener中:

@Override
public void onPageScrollStateChanged(int state) {
switch (state) {
case ViewPager.SCROLL_STATE_DRAGGING:
autoSkipHandler.pause = true;
break;
case ViewPager.SCROLL_STATE_IDLE:
autoSkipHandler.pause = false;
break;
}
}

总结

在要展示的View为1个时,没有必要滑动的。

当界面不可见时,可以暂停自动轮播。这样,在onPause和onResume中stopLoop和startLoop,一些情况下onStart和onStop是不执行的。

ViewPager本身的局限性是不适合超大量数据,当然这个假设在实际中又几乎不成立,即便是百万级别的view要展示,viewPager还是不会卡顿。

这里强调的是:既然ViewPager每次只展示最多3个page,而且左右滑动的逻辑可以在每次滑动时进行检查,那么对于任意大的数据集合,它都应该不会卡顿。而且,没有必要在非常大的页码跨度的情况下执行那些根本看不出差别的滑动效果!

实现一个自己的可切换显示View的ViewGroup不是什么难事。最好的,ViewFlow就有这种内置的无限循环滑动的效果,而且自带了简单的pageIndicator那样的小圆点效果。

项目地址是:https://github.com/pakerfeldt/android-viewflow。

非常建议使用。

上一篇:Openstack的打包方法


下一篇:[译文]Domain Driven Design Reference(二)—— 让模型起作用