BannerViewPager源码剖析(二)

BannerViewPager系列文章共三篇,此文为第二篇,另外两篇参看下面链接:

《打造一个丝滑般自动轮播无限循环Android库》

《剖析BannerViewPager中Indicator的设计思想》

上篇文章《打造一个丝滑般自动轮播无限循环Android库》非常详细的介绍了BannerViewPager的基础功能及使用方法。我们知道BannerViewPager不但可以支持任意的页面布局,而且可以支持任意的Indicator。那么BannerViewPager的这些功能是如何实现的呢?本篇文章将深入源码来带大家了解BannerViewPager的设计思路。

一、如何支持任意的Item布局

产品的需求千变万化,你永远也猜不到下一步产品会给你提一个什么样的需求。因此对于一个比较人性化的Banner库来说,它也应该支持开发者去自定义任意的Item页面布局。BannerViewPager就是本着这样的思路来做的。接下来将通过两小节的内容来探究BannerViewPager是如何实现任意Item布局的。

1.BannerViewPager的ViewHolder

我们先从setHolderCreator(HolderCreator holderCreator)这个方法说起。在使用BannerViewPager的时候必须设置一个HolderCreator,代码如下:

1
2
3
4
5
6
bannerViewPager.setHolderCreator(new HolderCreator<CustomPageViewHolder>() {
@Override
public CustomPageViewHolder createViewHolder() {
return new CustomPageViewHolder();
}
})

在HolderCreator的createViewHolder方法中返回了一个CustomPageViewHolder,这个CustomPageViewHolder是我们自己实现的。其内部会通过createView方法来inflate出来一自定义的itemView,并在onBind方法中为itemView绑定数据。码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CustomPageViewHolder implements ViewHolder<CustomBean> {
private ImageView mImageView;
private TextView mTextView;
@Override
public View createView(ViewGroup viewGroup, Context context, int position) {
View view = LayoutInflater.from(context).inflate(R.layout.item_custom_view, viewGroup, false);
mImageView = view.findViewById(R.id.banner_image);
mTextView = view.findViewById(R.id.tv_describe);
return view;
}

@Override
public void onBind(Context context, CustomBean data, int position, int size) {
mImageView.setImageResource(data.getImageRes());
mTextView.setText(data.getImageDescription());
}
...
}

在BannerViewPager内部setupViewPager的时候,针对HolderCreator做了如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void setupViewPager() {
if (holderCreator != null) {
BannerPagerAdapter<T, VH> bannerPagerAdapter =
new BannerPagerAdapter<>(mList, holderCreator);
bannerPagerAdapter.setPageStyle(mPageStyle);
bannerPagerAdapter.setCanLoop(isCanLoop);
bannerPagerAdapter.setPageClickListener(position -> {
if (mOnPageClickListener != null) {
mOnPageClickListener.onPageClick(PositionUtils.getRealPosition(isCanLoop, position, mList.size(), mPageStyle));
}
});
mViewPager.setAdapter(bannerPagerAdapter);
...
} else {
throw new NullPointerException("You must set HolderCreator for BannerViewPager");
}
}

上述代码中判断如果holderCreator为null时就抛出了一个NullPointerException,这也解释了为什么必须要为BannenrViewPager设置holderCreator。当holderCreator不为null时,将holder传递到了BannerPagerAdapter中,并且为BannerPagerAdapter设置了相关参数和页面点击事件。

我们接下来到BannerPagerAdapter中一探究竟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class BannerPagerAdapter<T, VH extends ViewHolder> extends PagerAdapter {

@Override
public @NonNull
Object instantiateItem(@NonNull final ViewGroup container, final int position) {
View itemView = getView(PositionUtils.getRealPosition(isCanLoop, position, mList.size(), mPageStyle),container);
container.addView(itemView);
return itemView;
}

...
@SuppressWarnings("unchecked")
private View getView(final int position, ViewGroup container) {
ViewHolder<T> holder = holderCreator.createViewHolder();
if (holder == null) {
throw new RuntimeException("can not return a null holder");
}
return createView(holder, position, container);
}

private View createView(ViewHolder<T> holder, int position, ViewGroup container) {
View view = null;
if (list != null && list.size() > 0) {
view = holder.createView(container, container.getContext(), position);
holder.onBind(container.getContext(), list.get(position), position, list.size());
return view;
}

}

在BannerPagerAdapter的getView方法中通过holderCreator.createViewHolder()拿到了自定义的ViewHolder,此时即为上边的CustomPageViewHolder 。接下来在createView方法中调用CustomPageViewHolder的createView方法拿到我们自定义的itemView,并通过holder.onBind方法将集合中的数据传递给了CustomPageViewHolder。到这里我们就完成了自定义item布局以及item数据的绑定。

2 .BannerViewPager的泛型设计

在上一小节中通过HolderCreator来实现任意的页面布局,但此时我们应该会面临一个问题,既然可以支持任意的页面布局那么BannerViewPager中接收的数据也应该时任意类型的。对于该问题我们可以引入泛型来实现。首先看BannerViewPager的泛型:

1
2
3
4
5
6
7
8
9
10
public class BannerViewPager<T, VH extends ViewHolder> extends RelativeLayout implements
ViewPager.OnPageChangeListener {

// 轮播数据集合
private List<T> mList;

private HolderCreator<VH> holderCreator;

// ...
}

BannerViewPager有两个泛型参数,第一个参数T是对应的数据类型,它用来作为BannerViewPager中List集合的泛型。另一个泛型参数VH规定了必须是继承ViewHolder的类,用来作为HolderCreator的泛型。而ViewHolder和HolderCreator均是一个带有泛型参数的接口,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface ViewHolder<T> {
View createView(ViewGroup viewGroup,Context context, int position);

/**
* @param context context
* @param data 实体类对象
* @param position 当前位置
* @param size 页面个数
*/
void onBind(Context context,T data,int position,int size);
}

public interface HolderCreator<VH extends ViewHolder> {
/**
* 创建ViewHolder
*/
VH createViewHolder();
}

另外,T和VH两个泛型也同时作为BannerPagerAdapter的泛型参数:

1
2
3
4
5
6
7
8
9
10
public class BannerPagerAdapter<T, VH extends ViewHolder> extends PagerAdapter {

private List<T> list;

public BannerPagerAdapter(List<T> list, HolderCreator<VH> holderCreator) {
this.list = list;
this.holderCreator = holderCreator;
}

}

可以看到,我们通过泛型约束,使得涉及到的相关类中的参数数据类型保持了同步,从而实现了BannerViewPager可以接收并处理任意的数据类型。

二、如何实现无限循环轮播

关于ViewPager的无限循环无外乎两种方案。

第一种方案是在PagerAdapter的getCount中返回一个Integer.MAX_VALUE,即一个最大的Integer整数。然后将setCurrentItem的值设置为 Integer.MAX_VALUE / 2,在滑动过程中不断取余以此来达到一个无限循环轮播的假象。

另外一种方案是额外增加两个ViewPager的item count,然后在第0个Item填充最后一条数据,在最后一个Item填充第0条数据。当右滑到第一个Item的时候将currentItem置为pageSize-1,当滑动到最后一个Item的时候将currentItem置为1,以此来达到一个无限循环的目的,此方案的示意图如下:

无限循环示意图
BannerViewPager的无限轮播使用的是第二种方案。至于这两种方案孰优孰劣不好判断,因为我并没有深入研究过方案一,因此对比这两种方案的优略对比我暂时没有发言权。但是关于第二种方案的优略我可以加以分析。

方案二的优点:

1.这一方案实现了真正意义上的无限轮播

相比方案一设置了一个最大值来制造无限轮播的假象,方案二实现的是一种真正的无限轮播。这个方案通过手动切换position使得轮播能够无限持续下去。这一点可能要略优于方案一。(其实Integer.MAX_VALUE的数值已经达到了数十亿级别,即使从一数到十亿恐怕都要几天吧?所以这一点也算不上方案一的缺点)

2.页面切换较少出现空白页

曾经看过几个使用方案一实现的Banner都有偶尔出现空白页的问题,当然不排除是这些库写的有问题,毕竟我也见过使用方案一实现非常好的库。而BannerViewPager在使用方案二时并没有经过什么特殊处理,却也很少见到空白页问题,当然也不排除是我代码写的好。(板儿砖尽管扔过来吧,哈哈!)。

方案二的缺点:

虽然BannerViewPager使用的是方案二,但是秉着公正的态度,绝不包庇这一方案的缺点。这个方案的优点虽然我苦思冥想也只想出来了两条,但是关于它的缺点我却能罗列出来很多。正所谓谁(sei)用谁(sei)知道!

1.onPageSelected(int)方法重复调用问题

我们为BannerViewPager开启自动轮播,并为其设置页面改变的监听事件,如下:

1
2
3
4
5
6
7
mBannerViewPager.setAutoPlay(true)
.setOnPageChangeListener(new OnPageChangeListenerAdapter() {
@Override
public void onPageSelected(int position) {
BannerUtils.e("position " + position);
}
})

然后可以看到打印的Log:
在这里插入图片描述
在BannerViewPager只有三个页面的情况下,页面position selected的周期是0、1、2、0。很明显,第0个页面被多调用了一次。虽然在大多数情况下并没有影响,但是当需要在选中第0个页面时做一些逻辑的话,就会产生一定影响。至于这个问题有没有解决办法,暂时还未去做进一步探究。

2.在一屏三页模式下,这一方案在轮播到最后一页时会出现下一页短暂空白的问题

出现这一问题的原因是因为为了完成循环在切换到最后一页时我们立即将position切换到了position为1的页面,而此时position为2个页面还未加载出来,因此就有了短暂的空白问题。为了解决这一问题,又不得不在原来循环的基础上再增加两个页面,并将setOffscreenPageLimit设置为2。这样无形中增加了内存开销,并且使得逻辑处理变得更为复杂!

3.需要对position进行变换

为了实现循环我们将page count增加了2,为了解决一屏三页的空白问题我们将page count增加了4。但对外暴露的接口需要拿到正确的position,此时我们就不得不在BannerViewPager内部对position进行变换,使之能够对应到正确的position。虽然解决了问题,但是这些逻辑处理却变得很臃肿。尽管方案一也会存在position的变换问题,但是相比方案二还是优雅很多。

综上来看,方案二其实并不是一个完美的方案,相反它却存在诸多的问题!但是由于没有用过方案一,所以对于方案一有没有坑,现在也不好妄下结论。有熟悉方案一的同学可以在文章下留言告知。后续我会单独开一个分支来尝试下方案一。如果方案一没有大问题,BannerViewPager的循环轮播方案可能会迁移到第一个解决方案(目前2.4.3版本已切换为方案一,许多问题迎刃而解。真香!)。

三、千变万化的Indicator

在最初的设计中,BannerViewPager同其它大多Bannenr库一样,内部维护了一个Indicator的List集合用来存放Indicator的icon,然后根据页面size动态的添加Indicator。显然这样的Indicator非常不灵活,如果UI觉得之前颜色不好看,需要换个颜色。你说,OK!没关系,你给我切图就好了。但是如果UI说我需要一个Indicator跟随ViewPager滑动的效果,那么此时你一定一脸茫然不知所措!于是和UI开启了漫长的拉锯战…扯远了,我们继续回归正题。考虑到这个问题,在后来的版本中针对Indicator进行了优化重构,现在的BannerViewPager已经可以支持任意样式的Indicator。并且还可以根据需求任意摆放Indicator的位置。是否期待了解这些神奇的功能是如何实现的呢?接下来就一起来探究
首先,定义了一个IIndicator的接口,该接口继承了ViewPager.OnPageChangeListener接口。其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface IIndicator extends ViewPager.OnPageChangeListener {
void setPageSize(int pageSize);

void setNormalColor(int normalColor);

void setCheckedColor(int checkedColor);

void setSlideMode(IndicatorSlideMode slideStyle);

void setIndicatorGap(int gap);

void setIndicatorWidth(int normalIndicatorWidth, int checkedIndicatorWidth);

void notifyDataChanged();
}

在BannerViewPager内部持有了IIndicator的实例,并且setIndicatorView方法只接收IIndicator类型的参数。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BannerPagerAdapter<T, VH extends ViewHolder> extends PagerAdapter {

// 轮播指示器
private IIndicator mIndicatorView;

/**
* 设置自定义View指示器,自定义View需要需要继承BaseIndicator或者实现IIndicator接口自行绘制指示器。
* 注意,一旦设置了自定义IndicatorView,通过BannerViewPager设置的部分IndicatorView参数将失效。
*
* @param customIndicator 自定义指示器
*/
public BannerViewPager<T, VH> setIndicatorView(IIndicator customIndicator) {
if (customIndicator instanceof View) {
isCustomIndicator = true;
mIndicatorView = customIndicator;
}
return this;
}
}

在setIndicatorView内部通过判断customIndicator是否是View的实例,以此确保了指示器必须继承自View并实现IIndicator接口。并且可以看到在满足条件的情况下将isCustomIndicator置为了true,以此来标记是否是自定义的指示器。如果外部没有调用setIndicatorView设置自定义指示器或者设置的指示器不符合要求,那么mIndicatorView岂不是就为null了?不慌,我们接着往下看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class BannerPagerAdapter<T, VH extends ViewHolder> extends PagerAdapter {
/**
* 构造ViewPager
*
* @param list ViewPager数据
*/
public void create(List<T> list) {
initBannerData(list);
}

private void initBannerData(List<T> list) {
if (list != null) {
mList.clear();
mList.addAll(list);
if (mList.size() > 0) {
if (mList.size() > 1) {
if (isCustomIndicator && null != mIndicatorView) {
initIndicator(mIndicatorView);
} else {
initIndicator(IndicatorFactory.createIndicatorView(getContext(), mIndicatorStyle));
}
}
// ...
}
}
}
}

我们在使用BannerViewPager的时候设置完参数配置之后需要调用create(List<T> list)方法,在这个方法中会根据list的数据情况来初始化Indicator。上述代码中只有在list.size()大于1的时候才会初始化Indicator,并且在后边判断如果是自定义的Indicator就直接初始化指示器,如果没有自定义指示器,那么就调用指示器工厂,根据设置的指示器Style生成内置指示器样式。这么一来就实现了内置指示器和自定义指示器的切换。

那么问题又来了,关于Indicator位置任意摆放是如何实现的呢?为什么自定义指示器可以直接new出来,也可以放在xml中呢?关于这两个问题就需要来看下initIndicator做了什么操作了。

1
2
3
4
5
6
7
8
9
10
private void initIndicator(IIndicator indicatorView) {
mIndicatorLayout.setVisibility(mIndicatorVisibility);
mIndicatorView = indicatorView;
if (((View) mIndicatorView).getParent() == null) {
mIndicatorLayout.removeAllViews();
mIndicatorLayout.addView((View) mIndicatorView);
initIndicatorViewMargin();
initIndicatorGravity();
}
}

在initIndicator中会首先判断indicatorView的parent是否为null。什么情况下indicatorView的parent会为null呢?答案就是内置指示器和setIndicatorView()的参数通过new的方式传进来的情况下indicatorView的parent会是null的情况!那么此时就将indicator添加到BannerViewPager内部mIndicatorLayout中就可以了。而如果Indicator是声明在xml中的情况,此时通过findViewById获得的Indicator其parent一定不会是null,那么在initIndicator中只是将其赋值给了mIndicatorView。以此完成了对内置IndicatorView的替换。这样其实不管通过怎样的方法设置IndicatorView都保证了BannerViewPager内部始终只会维护一个Indicator。方法非常巧妙,这里我想要一个赞!(似乎又嗅到了板儿砖的气息)

四、遇到的其他问题及解决方案

在BannerViewPager的开发过避免不了的会碰到一些问题,虽然有些已经解决了,但有些可能还悬而未决。但是不管解决没解决以供大家参考或讨论。

1.手指滑动页面过程中应停止自动轮播

自动轮播的功能是通过Handler来实现的。通过postDelayed开启轮播,通过removeCallbacks停止轮播。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 开启轮播
*/
public void startLoop() {
if (!isLooping && isAutoPlay && mList.size() > 1) {
mHandler.postDelayed(mRunnable, interval);
isLooping = true;
}
}

/**
* 停止轮播
*/
public void stopLoop() {
if (isLooping) {
mHandler.removeCallbacks(mRunnable);
isLooping = false;
}
}

如果在手指滑动的过程中没有停止轮播,体验上来说非常不好。因此,需要处理这种情况。解决方案是重写ViewPager的setOnTouchListener方法,监听手指滑动的时候停止轮播,抬起手指的时候开启轮播。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void setTouchListener() {
mViewPager.setOnTouchListener((v, event) -> {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
isLooping = true;
stopLoop();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
isLooping = false;
startLoop();
default:
break;
}
return false;
});
}

2.关于instantiateItem的优化问题

我们知道,在ViewPager每次切换页面的时候都会调用instantiateItem去实例化ItemView,也就意味着我们在这个方法中通过ViewHolder的createView方法每次切换页面都会被调用重新初始化绑定数据。这样对程序来说是一种性能上的浪费。针对这种情况,在2.4.3之前的版本中做了些优化。在BannerPagerAdapter中维护一个List mViewList集合,用来存放创建出来的itemView.在itemView初始化成功后,为其设置tag并保存到集合中,当在此切换页面时我们从集合中取出itemView并对比tag,如果一致则直接使用即可。这样就避免了重复的创建对象,造成一些性能开销。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class BannerPagerAdapter<T, VH extends ViewHolder> extends PagerAdapter {

private List<View> mViewList = new ArrayList<>();

private View findViewByPosition(ViewGroup container, int position) {
for (View view : mViewList) {
if (((int) view.getTag()) == position && view.getParent() == null) {
return view;
}
}
View view = getView(position, container);
view.setTag(position);
mViewList.add(view);
return view;
}
}

但是这一优化却又会引发另一个问题,即内存问题!通常App的轮播控件都是图片,而图片是比较占用内存的。此时我们把所有的itemView都存储在一个集合中这样真的是一个好的方案吗?在ViewPager页面少的情况下问题可能不会凸显。但是如果ViewPager的页面很多的情况下问题就相当严重了!于是,后来我灵光一闪,突发奇想!那我就设置一个最大缓存呗?当集合中的个数超过阈值的时候就把最近用过的一个itemView移除掉不就好了?妙哉妙哉!可转念一想,这尼玛和设置一个setOffscreenPageLimit有什么区别呢?当我们在考虑这些问题的时候Google工程师早就替我们想到了!所以关于ViewPager的instantiateItem是否有必要去优化我目前持保留态度。但是,在BannerViewPager 2.4.3之前的版本中确实做了上述优化,因此前些版本中可能会存在内存问题。至于2.4.3或之后版本大概会去掉这部分优化。这个问题可能也只能留在未来,待升级到ViewPager2后解决了!关于这个问题欢迎大家在文章下方留言,各抒己见!

3.RecyclerView+ViewPager会有非Smooth的页面滑动情况

这个问题不是太好描述,我们直接通过一张GIF来看


从图中可以很直观的看到,把BannerViewPager向上划出屏幕再很快划回来,此时BannerViewPager页面切换的动画没有了,很生硬的直接跳到了下一页。这个问题不是BannerViewPager的bug,而是ViewPager内部原因导致的,可以看到很多线上的APP都存在这个问题,例如喜马拉雅(喜马拉雅的轮播图真心做的好看呀,效果也很赞!)。这个bug虽然不影响使用,但是总感觉效果不太好。因此还是要处理一下。处理之前先来分析一下问题原因。
在ViewPager内部有一个私有成员变量mFirstLayout,其默认值为true。这个参数用来标记是否是第一次layout的。如果是第一次layout那么滑动就不是smooth的。代码如下:

1
2
3
4
5
6
7
8
public void setCurrentItem(int item) {
mPopulatePending = false;
setCurrentItemInternal(item, !mFirstLayout, false);
}

void setCurrentItemInternal(int item, boolean smoothScroll, boolean always) {
setCurrentItemInternal(item, smoothScroll, always, 0);
}

这个参数在onLayout方法中会被置为false.代码如下:

1
2
3
4
5
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 在onLayout的最后一行
mFirstLayout = false;
}

因此,在正常情况下,onLayout执行之后页面滑动都应该时smooth的。然后,当ViewPager滑动出屏幕的时候其onDetachedFromWindow方法会被调用,而当其再次进入屏幕的时候则会调用onAttachedToWindow这个方法。来看看onAttachedToWindow方法中的代码:

1
2
3
4
5
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mFirstLayout = true;
}

仅仅把mFirstLayout置为了true!而如果此时onLayout没有被触发,而是先发生了页面滚动,那么此时的页面滑动就没了的smooth效果了。了解了原因之后处理起来就简单了,因为mFirstLayout是私有属性,我们无法访问,所以只有通过反射来修改其值。我们在CatchViewPager(继承自ViewPager的一个类)中做如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private boolean firstLayout = true;

@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
hookFirstLayout();
}


@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
firstLayout = false;
}

private void hookFirstLayout() {
try {
Field mFirstLayout = ViewPager.class.getDeclaredField("mFirstLayout");
mFirstLayout.setAccessible(true);
mFirstLayout.set(this, firstLayout);
setCurrentItem(getCurrentItem());
} catch (IllegalAccessException | NoSuchFieldException e) {
e.printStackTrace();
}
}

这样问题就迎刃而解了,再次滑动RecyclerView,一切完好!

4.关于Indicator在SMOOTH下的滑动问题。

这是一个由来已久的问题,感觉好难处理!如果不处理循环的话其实是非常容易的,但是如果加上循环之后总是有这样那的问题😂。只能说目前的计算方式不是太对,具体怎么计算的我也不贴出来了!毕竟现在写出来的还是有bug的。很羡慕喜马拉雅的滑动效果做的太Nice了!后续版本中我会想办法再优化这个问题。

五、总结及致谢

到这里关于BannerViewPager的两篇文章就全部结束了,上一篇文章主要着重介绍了BannerViewPager的功能及用法,而本篇文章则详细的讲解了BannerViewPager的实现原理。就目前而言,BannerViewPager并不是一个完美的轮播库,很多地方还有很大值得优化的空间甚至有些功能还存在一些小bug。但是这些都不会阻碍BannerViewPager逐渐走向优秀。在未来的版本中我将会针对这些问题逐一优化。当然,如果你有好的解决方案欢迎在文章下方留言,也可以直接到github提交pull request。如果你有什么好的建议或者遇到什么问题也欢迎在文章下方留言讨论。

最后要特别感谢saiwu-bigkoo大神的Android-ConvenientBanner库以及youth5201314大神的banner库。BannerViewPager中的很多思想来自这两个库。BannerViewPager中内置的四个ViewPager Transform来自ViewPagerTransforms库,在此表示感谢。同时还要感谢玩Android提供的接口支持,以及在开发过程中参考过的文章或其它优秀开源库,不能一一列出,在此一并表示感谢。

最后还是要贴上源码地址,欢迎star、fork 。

点击查看源码


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!