一起撸个朋友圈吧 - 图片浏览(下)【ViewPager优化】

3,013 阅读11分钟

项目地址:github.com/razerdp/Fri… (能弱弱的求个star或者fork么QAQ)


【ps:评论功能羽翼君我补全了后台交互了哟,如果您想体验一下不同的用户而不是一直都是羽翼君,可以在FriendCircleApp下,在onCreate中,将LocalHostInfo.INSTANCE.setHostId(1001);的id改为1001~1115之间任意一个】

在上一篇,我们实现了朋友圈的图片浏览,在文章的最后,留下了几个问题,那么这一片我们解决这些。

本篇需要解决的几个问题(本篇主要为控件的自定义,但相信我,不会很难):

- viewpager如何复用

- 图片浏览viewpager的指示器

本篇图片预览如下:

preview

Q1:指示器

我们知道,在微信图片浏览的时候,多张图下方是有个指示器的,比如这样

当然,我们可以找库,但这个如此简单的控件为此花时间去找库,倒不如我们自己来定制一番对吧。

我们来分析一下,可以如何实现这个指示器功能。

首先可以确认的是,指示器要跟ViewPager联调,就必须要跟ViewPager的滑动状态进行关联。

而对于ViewPager的滑动状态,使用的最多的就是ViewPager.OnPageChangeListener这个接口。

从图中我们可以看到,微信下方的指示器滑动的时候,白点并没有什么移动动画,而是直接就跳到另一个点上面了,这样一来,这个控件的实现就更加的容易了。

因此我们可以初步得到思路如下:

  • 首先可以肯定的是,指示器不应该隶属于ViewPager,否则每次instantiateItem的时候又inflate出来是很不合理的,所以我们的indicator必须跟ViewPager同级,但可以通过ViewPager的滑动状态来改变。

  • 第二,小点点的数量永远都是0~9,因为微信的图片数量最多9张。

  • 第三,小点点都是水平居中,因此我们的indicator可以继承LinearLayout来实现。

  • 第四,小点点有两个状态,一个选中,一个非选中。所以小点点的定制必须要提供改变选中状态的接口。


Q1 - 代码的编写:

小点点的自定义

既然思路有了,那么剩下来的也仅仅是用代码将我们的思路实现而已。

首先我们来弄小点点。

由于我懒得打开AE,所以我选择直接采用Drawable的方式来写。

来到drawable文件下,新建一个drawable

首先来定制一个未选中状态的drawable

<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <size android:width="25dp" android:height="25dp"/>
    <stroke android:color="@color/white" android:width="1dp"/>
</shape>

代码非常简单,效果也仅仅是一个圆环。

未选中的drawable

而选中的实心圆只是把上述代码的stroke换成solid而已,这里就略过了。

然后我们新建一个类继承View,叫做**“DotView”**

或许看到继承View你就会觉得,难道又要重写onMeasure,onLayout什么的?烦死了。。。。

其实不用,毕竟咱们用的是drawable。。。

我们的代码整体结构如下:

public class DotView extends View {
    private static final String TAG = "DotView";

    //正常状态下的dot
    Drawable mDotNormal;
    //选中状态下的dot
    Drawable mDotSelected;

    private boolean isSelected;

    public DotView(Context context) {
        this(context, null);
    }

    public DotView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DotView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, AttributeSet attrs) {
        mDotNormal = context.getResources().getDrawable(R.drawable.ic_viewpager_dot_indicator_normal);
        mDotSelected = context.getResources().getDrawable(R.drawable.ic_viewpager_dot_indicator_selected);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

    }

    public void setSelected(boolean selected) {
        this.isSelected = selected;
        invalidate();
    }

    public boolean getSelected() {
        return isSelected;
    }
}

可以看到,我们只需要实现onDraw方法和提供是否选中的方法而已。其他的都不需要。

在onDraw里面,我们编写以下代码:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width=getWidth();
        int height=getHeight();



        if (isSelected) {
            mDotSelected.setBounds(0,0,width,height);
            mDotSelected.draw(canvas);
        }
        else {
            mDotNormal.setBounds(0,0,width,height);
            mDotNormal.draw(canvas);
        }
    }

这里仅仅为了确定drawable的大小并根据不同的状态进行不同的drawable绘制。非常简单。

indicator的自定义

在上面的思路里,我们可以通过继承LinearLayout来实现指示器。

因此我们新建一个类继承LinearLayout,取名**“DotIndicator”**

在这个指示器中,我们需要确定他拥有的功能:

  • 包含0~9个DotView
  • 通过公有方法来设置当前选中的DotView
  • 通过公有方法来设置当前显示的DotView的数量

因此我们可以初步设计以下代码结构:

package razerdp.friendcircle.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Gravity;
import android.widget.LinearLayout;
import java.util.ArrayList;
import java.util.List;
import razerdp.friendcircle.utils.UIHelper;

/**
 * Created by 大灯泡 on 2016/4/21.
 * viewpager图片浏览器底部的小点点指示器
 */
public class DotIndicator extends LinearLayout {
    private static final String TAG = "DotIndicator";

    List<DotView> mDotViews;

    private int currentSelection = 0;

    private int mDotsNum = 9;

    public DotIndicator(Context context) {
        this(context,null);
    }

    public DotIndicator(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public DotIndicator(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, AttributeSet attrs) {
        setOrientation(HORIZONTAL);
        setGravity(Gravity.CENTER);

        buildDotView(context);
    }

    /**
     * 初始化dotview
     * @param context
     */
    private void buildDotView(Context context) {

    }

    /**
     * 当前选中的dotview
     * @param selection
     */
    public void setCurrentSelection(int selection) {
      
    }

    public int getCurrentSelection() {
        return currentSelection;
    }

    /**
     * 当前需要展示的dotview数量
     * @param num
     */
    public void setDotViewNum(int num) {
        
    }

    public int getDotViewNum() {
        return mDotsNum;
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mDotViews.clear();
        mDotViews=null;
        Log.d(TAG, "清除dotview引用");
    }
}

在这里说明一下,由于我们操作不同位置的dotview,所以我们需要有一个列表来存下这些dotview。

另外,我们设置指示器必须是水平的同时Gravity=CENTER

另外注意记得在onDetachedFromWindow清除所有引用哦。否则无法回收就内存泄漏了。

接下来我们补全代码。

首先是buildDotView

在这里我们将会进行indicator的初始化,也就是将9个dotView添加进来

/**
     * 初始化dotview
     * @param context
     */
    private void buildDotView(Context context) {
        mDotViews = new ArrayList<>();
        for (int i = 0; i < 9; i++) {
            DotView dotView = new DotView(context);
            dotView.setSelected(false);
            LinearLayout.LayoutParams params = new LayoutParams(UIHelper.dipToPx(context, 10f),
                    UIHelper.dipToPx(context, 10f));
            if (i == 0) {
                params.leftMargin = 0;
            }
            else {
                params.leftMargin = UIHelper.dipToPx(context, 6f);
            }
            addView(dotView,params);
            mDotViews.add(dotView);
        }
    }

这里有一个需要注意的是第0个dotview是不需要marginleft的。

接下来补全setCurrentSelection

这个方法我们的思路也很简单,首先将所有的DotView设置为未选中状态,然后再设置对应num的DotView为选中状态。虽然是遍历了两次数组,但因为很少东西,而且CPU的处理速度完全可以在肉眼无法观察的速度下完成,所以这里无需过度考虑。

/**
     * 当前选中的dotview
     * @param selection
     */
    public void setCurrentSelection(int selection) {
        this.currentSelection = selection;
        for (DotView dotView : mDotViews) {
            dotView.setSelected(false);
        }
        if (selection >= 0 && selection < mDotViews.size()) {
            mDotViews.get(selection).setSelected(true);
        }
        else {
            Log.e(TAG, "the selection can not over dotViews size");
        }
    }

值得注意的是,我们需要留意边界问题

最后我们补全setDotViewNum

这里的思路跟上面的差不多,首先我们将所有的dotview设置为可见,然后将指定数量之后的dotview设置为GONE,这时候由于LinearLayout的Gravity是CENTER,所以剩余的dotView会水平居中。

 /**
     * 当前需要展示的dotview数量
     * @param num
     */
    public void setDotViewNum(int num) {
        if (num > 9 || num <= 0) {
            Log.e(TAG, "num必须在1~9之间哦");
            return;
        }

        for (DotView dotView : mDotViews) {
            dotView.setVisibility(VISIBLE);
        }
        this.mDotsNum = num;
        for (int i = num; i < mDotViews.size(); i++) {
            DotView dotView = mDotViews.get(i);
            if (dotView != null) {
                dotView.setSelected(false);
                dotView.setVisibility(GONE);
            }
        }
    }

同样需要注意边界问题。

完成之后,我们回到图片浏览的布局,将我们的自定义dotindicator添加到布局,并对其父布局底部。

xml

最后在我们封装好的PhotoPagerManager引入DotIndicator

在调用showPhoto的时候,先设置dotindicator展示的dotview数量,然后再设置选中的dotview

showphoto

最后在viewpager的pagechangerlistener监听中设置dotindicator的对应方法就好了

设置当前展示的dotview

【DotIndicator完】


Q2:viewpager复用

在上一篇文章,我们看到当某个动态的图片数量超过3张,我们点击第四张图片的时候,会发现放大动画并不明显。

这是因为ViewPager的机制,ViewPager默认会缓存当前item左右共三个view,当划到第四个,则会重新执行initItem,对应我们的adapter,就是重新new了一个PhotoView,由于这个PhotoView并没有图片,所以放大动画无法展示。

而我们选择解决方案就是,在adapter初始化的时候,就直接把9个photoview给new出来放到一个对象池里面,每次执行到instantiateItem就从池里面拿出来,这样就可以防止每次都new,保证放大动画。

因此我们的改动如下:


/**
 * Created by 大灯泡 on 2016/4/12.
 * 图片浏览窗口的adapter
 */
public class PhotoBoswerPagerAdapter extends PagerAdapter {
    private static final String TAG = "PhotoBoswerPagerAdapter";

    private static ArrayList<MPhotoView> sMPhotoViewPool;
    private static final int sMPhotoViewPoolSize = 10;
	...跟上次一样

    public PhotoBoswerPagerAdapter(Context context) {
    ...不变
        sMPhotoViewPool = new ArrayList<>();
        //buildProgressTV(context);
        buildMPhotoViewPool(context);
    }

    private void buildMPhotoViewPool(Context context) {
        for (int i = 0; i < sMPhotoViewPoolSize; i++) {
            MPhotoView sPhotoView = new MPhotoView(context);
            sPhotoView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT));
            sMPhotoViewPool.add(sPhotoView);
        }
    }

	...resetDatas()方法不变

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        MPhotoView mPhotoView = sMPhotoViewPool.get(position);
        if (mPhotoView == null) {
            mPhotoView = new MPhotoView(mContext);
            mPhotoView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT));
        }
        Glide.with(mContext).load(photoAddress.get(position)).into(mPhotoView);
        container.addView(mPhotoView);
        return mPhotoView;
    }
	...setPrimaryItem()方法不变

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {

        container.removeView((View) object);
    }

	...其余方法不变
    //=============================================================destroy
    public void destroy(){
        for (MPhotoView photoView : sMPhotoViewPool) {
            photoView.destroy();
        }
        sMPhotoViewPool.clear();
        sMPhotoViewPool=null;
    }
}

在adapter初始化的时候,我们将对象池new出来,并new出10个photoview添加到池里面。

在instantiateItem我们直接从池里面拿出来,如果没有,才创建。然后跟以前一样,glide载入。

在destroyItem我们把view给remove掉,这样可以防止在instantiateItem的时候在池里拿出的view拥有parent导致了异常的抛出。

最后记得提供destroy方法来清掉池的引用哦。


Q2 - 关于PhotoView在ViewPager里面爆出的"ImageView no longer exists. You should not use this PhotoViewAttacher any more."错误

如果您细心,会发现我的代码里写的是MPhotoView而不是PhotoView

原因就是如小标题。

在viewpager中,如果采用对象池的方式结合PhotoView来实现复用,就会因为这个错误而导致PhotoView的点击事件无法相应。

要解决这个问题,就必须得查看PhotoView的源码。

首先我们找到这个错误的提示位置

错误位置

首先PhotoView的实现跟我们PhotoPagerMananger的实现思路差不多,都是将事件的处理委托给另一个对象,这样的好处是可以降低耦合度,其他的控件想实现类似功能会更简单。

在getImageView中,如果imageview==null,就会log出这个错误。

我们看看imageview的引用,在PhotoViewAttacher中,imageview是属于弱引用,这样可以更快的被回收。

而imageview的清理则是在cleanup中

/**
     * Clean-up the resources attached to this object. This needs to be called when the ImageView is
     * no longer used. A good example is from {@link android.view.View#onDetachedFromWindow()} or
     * from {@link android.app.Activity#onDestroy()}. This is automatically called if you are using
     * {@link uk.co.senab.photoview.PhotoView}.
     */
    @SuppressWarnings("deprecation")
    public void cleanup() {
        if (null == mImageView) {
            return; // cleanup already done
        }

        final ImageView imageView = mImageView.get();

        if (null != imageView) {
            // Remove this as a global layout listener
            ViewTreeObserver observer = imageView.getViewTreeObserver();
            if (null != observer && observer.isAlive()) {
                observer.removeGlobalOnLayoutListener(this);
            }

            // Remove the ImageView's reference to this
            imageView.setOnTouchListener(null);

            // make sure a pending fling runnable won't be run
            cancelFling();
        }

        if (null != mGestureDetector) {
            mGestureDetector.setOnDoubleTapListener(null);
        }

        // Clear listeners too
        mMatrixChangeListener = null;
        mPhotoTapListener = null;
        mViewTapListener = null;

        // Finally, clear ImageView
        mImageView = null;
    }

那么现在问题的出现就很明显了,爆出这个错误是因为imageview==null,也就是说两个可能:

  • 要么被执行了cleanup
  • 要么就是引用的对象被销毁了

第二点我们可以排除,因为我们有个list来引用着photoview,所以只可能是第一个问题。

最终,我们在PhotoView的onDetachedFromWindow找到了cleanup方法的调用

cleanup

还记得在ViewPager中我们的destroyItem吗,那里我们执行的是container.remove(View),一个View在被remove的时候会回调onDetachedFromWindow。

而在PhotoView中,回调的时候就会执行attacher.cleanup,也就是说attacher已经没有了imageview的引用,然而我们的photoview却是在我们的池里面。

这样导致的结果就是在下一次instantiateItem时,从池里拿出的photoview里面的attacher根本就没有imageview的引用,所以就会log出那个错误。

所以我们的解决方法就很明了了:

把photoview的代码copy,注释掉onDetachedFromWindow中的mattacher.cleanup,然后提供cleanup方法来手动进行attacher.cleanup,这样就可以避免这个错误了。

大概代码如下:

/**
 * Created by 大灯泡 on 2016/4/14.
 *
 * 针对onDetachedFromWindow
 *
 * 因为PhotoView在这里会导致attacher.cleanup,从而导致attacher的imageview=null
 * 最终无法在viewpager响应onPhotoViewClick
 *
 * 这里将cleanup注释掉,把cleanup移到手动调用方法中
 */
public class MPhotoView extends ImageView implements IPhotoView {
    private PhotoViewAttacher mAttacher;

    private ScaleType mPendingScaleType;

    public MPhotoView(Context context) {
        this(context, null);
    }

    public MPhotoView(Context context, AttributeSet attr) {
        this(context, attr, 0);
    }

    public MPhotoView(Context context, AttributeSet attr, int defStyle) {
        super(context, attr, defStyle);
        super.setScaleType(ScaleType.MATRIX);
        init();
    }

    protected void init() {
        if (null == mAttacher || null == mAttacher.getImageView()) {
            mAttacher = new PhotoViewAttacher(this);
        }

        if (null != mPendingScaleType) {
            setScaleType(mPendingScaleType);
            mPendingScaleType = null;
        }
    }

...copy from photoview

    @Override
    protected void onDetachedFromWindow() {
        //mAttacher.cleanup();
        super.onDetachedFromWindow();
    }

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

    public void destroy(){
        setImageBitmap(null);
        mAttacher.cleanup();
        onDetachedFromWindow();
    }

}

至此,我们上一篇留下来的问题全部解决。

下一篇。。。暂时没想到做什么好,大家有没有什么提议的