柚木

Android 打造一个丝滑的自动轮播控件

现在很多的 App 都有自动轮播的 banner 界面,用于展示广告图片或者显示当前比较热门的一些活动,除了具备比较酷炫的效果之外,通过轮播的方式来减少对界面的占用,也是很赞的一个设计点。本文主要是总结自动轮播控件的实现过程,以及对这类控件的一些优化的技巧。

一、如何实现

在开始进行我们的代码编程之前,我们先要思考一下,在 Google 提供的官方 Api 里面,有没有类似的控件实现了相似的功能,毕竟官方的控件大都经过了时间的考验,无论是稳定性还是性能方面都是非常不错的,如果我们能够基于官方的控件进行相应的改造,控件的稳定性也会有相对的保障。

在比较常见的主流控件里面,其实 ViewPager 和 RecyclerView 已经实现了类似的功能,尤其是 ViewPager,可以说是已经实现了我们这个控件的大部分功能,所以如果我们基于 ViewPager 来进行改造的话,也能让我们的轮播控件更加稳定。

那 ViewPager 跟我们需要的自动轮播控件有多少差距呢,主要有两个:

  • 不支持自动播放
  • 无法从最后一张滑动到第一张

所以我们主要是针对这两部分进行相应的改造,从而实现我们自己的自动轮播控件。

1.1 实现自动轮播功能

要想实现自动轮播功能,我们最先想到的应该是通过 Timer 或者 ScheduledExecutorService 来实现计时器的功能,然后让 ViewPager 通过 serCurrentItem(int position) 方法,将当前的 Item 设置为下一个 position 的数据,但是如果通过定时器来实现的话,会有一个问题,那就是我们在需要让 banner 进行停止播放的时候就比较麻烦,所以通过 Handler 用 sendMessage 的形式,进行事件的发送实现 ViewPager 的自动轮播,以及某些场景的停止是比较合理的。

我们来看下代码:

    private static class AutoScrollHandler extends Handler {

        private WeakReference<AutoScrollViewPager> mBannerRef;

        private static final int MSG_CHANGE_SELECTION = 1;

        AutoScrollHandler(AutoScrollViewPager autoScrollViewPager) {
            mBannerRef = new WeakReference<>(autoScrollViewPager);
        }

        private void start() {
            removeMessages(MSG_CHANGE_SELECTION);
            sendEmptyMessageDelayed(MSG_CHANGE_SELECTION, AUTO_SCROLL_TIME);
        }

        private void stop() {
            removeMessages(MSG_CHANGE_SELECTION);
        }

        @Override
        public void handleMessage(Message msg) {
            if (msg.what == MSG_CHANGE_SELECTION) {
                if (mBannerRef == null || mBannerRef.get() == null) {
                    return;
                }
                AutoScrollViewPager banner = mBannerRef.get();

                if (banner.mSelectedIndex == Integer.MAX_VALUE) {
                    int rightPos = banner.mSelectedIndex % banner.mBannerList.size();
                    banner.setCurrentItem(banner.getInitPosition() + rightPos + 1, true);
                } else {
                    if (!hasMessages(MSG_CHANGE_SELECTION)) {
                        banner.setCurrentItem(banner.mSelectedIndex + 1, true);
                        sendEmptyMessageDelayed(MSG_CHANGE_SELECTION, AUTO_SCROLL_TIME);
                    }
                }

            }
        }
    }

可以看到,我们先传入外部的 ViewPager,然后通过弱引用的形式防止内存泄露,通过在 handlerMessage() 方法里面,调用 setCurrentItem() 方法,将当前 ViewPager 的 Item 设置为对应的 position + 1 的数据,所以我们只要在外部调用 Handler 的 sendMessage() 方法,就能使 ViewPager 进行自动的无限轮播。

1.2 让 ViewPager 从最后一张滑动到第一张

我们知道,ViewPager 是无法从最后一页滑动到第一页的,但我们可以换一个思路,如果我们在 ViewPager 的 Adapter 里面,通过 getCount() 方法将 ViewPager 的大小设置为无限大,然后通过取余的方式来保证滑动的页面一直对应数据源的那几个数据,这样便能让 ViewPager 实现从最后一张滑动到第一张的效果。

    public int getCount() {
        if (mBannerList == null) {
            return 0;
        }
        if (mBannerList.size() == 1) {
            return 1;
        } else {
            return Integer.MAX_VALUE;
        }
    }

    public Object instantiateItem(ViewGroup container, final int position) {
        if (mBannerList != null && mBannerList.size() > 0) {
            View imageView = null;
            Uri uri = Uri.parse(mBannerList.get(position % mBannerList.size()).cover_url); // 通过取余的方式
            imageView = new SimpleDraweeView(mContext);
            ((SimpleDraweeView) imageView).setImageURI(uri);
            container.addView(imageView);
            return imageView;
        }
        return null;
    }

二、如何进行优化

在上面我们只是简单的实现了 ViewPager 的自动轮播功能,但其实还有很多的细节需要我们进行优化,例如:我们是通过将 ViewPager 的大小设置为无限大的方式,来实现从最后一张滑动到第一张的,但这时候如果不进行缓存的话,我们在 Adapter 的 instantiateItem(ViewGroup container, final int position) 方法里面,便需要返回很多新 new 出来的 View,这样会造成不必要的内存浪费,只有对这些细节进行优化,才能让我们的控件更加的好用,稳定性和性能方面也会更加优异。

2.1 通过缓存减少内存浪费

为了让 ViewPager 能实现无线轮播的功能,我们是使用了通过将 getCount() 的大小设置为无限大的方式来实现的,但这会产生一个问题,这样会使我们在 Adapter 的 instantiateItem() 方法中返回很多新 new 出来的 View,而造成不必要的内存浪费。

所以我们可以通过一个 List 作为缓存池,在 Adapter 中的 destroyItem() 方法中将废弃的 object 存到缓存池中,重复利用,这样便能避免内存浪费。

    private final ArrayList<View> mViewCaches = new ArrayList<>();

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        ImageView imageView = (ImageView) object;
        container.removeView(imageView);
        mViewCaches.add(imageView);
    }

然后在 Adapter 的 instantiateItem() 方法中,从 List 中取出已经被缓存的 View,进行重复利用

    public Object instantiateItem(ViewGroup container, final int position) {
        if (mBannerList != null && mBannerList.size() > 0) {
            View imageView = null;
            Uri uri = Uri.parse(mBannerList.get(position % mBannerList.size()).cover_url);
            if (mViewCaches.isEmpty()) {
                imageView = new SimpleDraweeView(GlobalContext.getContext());
            } else {
                // 当缓存集合有数据时,进行复用
                imageView = (ImageView) mViewCaches.remove(0);
            }
    }

2.2 适当的停止自动轮播

当我们触摸 Banner 或者离开当前展示 Banner 的页面时,如果 banner 还在不停的进行无线轮播的话,会造成没必要的性能损失,所以我们需要在触摸 Banner 以及当前的 Activity 为不可见状态的时候,停止 Banner 的轮播,从而提升性能。

    public boolean dispatchTouchEvent(MotionEvent ev) {
        int action = ev.getAction();
        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL
                || action == MotionEvent.ACTION_OUTSIDE) {
            startAutoPlay();
        } else if (action == MotionEvent.ACTION_DOWN) {
            stopAutoPlay();
        }
        return super.dispatchTouchEvent(ev);
    }

2.3 改变 ViewPager 切换速度

原生的 ViewPager 在进行自动轮播的时候,切换速度是特别快的,会给人一种很突兀的感觉,而且 ViewPager 也没有提供接口给我们对 ViewPager 进行切换速度的设置,所以我们需要通过反射的方式,使用 Scroller 来进行切换速度的设置,从而让我们的 Banner 更加的丝滑。

    public AutoScrollViewPager(Context context) {
        this(context, null);
        initViewPagerScroll();
    }

    private void initViewPagerScroll() {
        try {
            Field mField = ViewPager.class.getDeclaredField("mScroller");
            mField.setAccessible(true);
            BannerScroller scroller = new BannerScroller(getContext());
            mField.set(this, scroller);
        } catch (Exception e) {
            Log.d(TAG, e.getMessage());
        }
    }

public class BannerScroller extends Scroller {

    private static final int BANNER_DURATION = 1000;
    private int mDuration = BANNER_DURATION;

    public BannerScroller(Context context) {
        super(context);
    }

    @Override
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        super.startScroll(startX, startY, dx, dy, mDuration);
    }
}

至此,我们的自动轮播控件,无论是性能上还是稳定性上都已经很不错了。