自定义一个基于Volley NetworkImageView的圆形带网络请求功能的图片控件

来源:互联网 发布:雅思备考攻略 知乎 编辑:程序博客网 时间:2024/06/09 19:34

上一篇文章也是关于Volley的,所以关于Volley的一些重复内容我这里就不写了。说起Volley,我倒是似乎很忠于这个库,因为这个是我第一个学习的网络开源库,用的也顺手,功能也基本够用,所以一直用在自己做的project中,其实抬头看看世界,现在主流的app基本都在用Retrofit,Rxjava,Fresco等等这些高大上的库组合起来的架构了。所以如果有人看到我这篇文章又是个初学者的话,我还是建议你在能力达标的情况下,去学习这些库的用法。

书归正传,今天我要记录的问题是,如何基于Volley的NetworkImageView自定义一个圆形的带网络请求功能的View控件。Volley本身只是个网络请求库,图片请求功能可以说是附带功能,虽然在大多数简单的情况下够用,但是难以应对复杂的情况。Volley有三种获取网络图片的方式,分别是ImageRequest,ImageLoader以及NetworkImageView,它们是依次依赖的关系,所以,NetworkImageView是最好用的,它本身就是一个图片控件,但是却直接带有从网络获取图片的方法,并且它还内置了图片压缩的功能,网络图片会以和NetworkImageView相称的尺寸显示出来,不会造成内存浪费。但是NetworkImageView的缺点在这时也暴露出来了,它的形状和ImageView一样是矩形,如果你想加载一个圆形图片,它就无法实现。如果你用过Glide或者Picasso你就会知道,这些图片加载库的做法是把ImageView作为参数传入,因此无论你想加载什么形状的图片,你只需要自己自定义一个继承自ImageView的控件就行了。但在这里,如果我们只想使用Volley,就得自己实现一个圆形的NetworkImageView。

首先,我们得要一个圆形图片控件,圆形图片控件官方API是不提供的,大家基本用的都是网上开源的,这里我选择了一个比较简单的作为学习之用。我在掘金上找到一篇文章,文章里写的CircleImageView据作者描述具有很好的抗锯齿的特点,而且代码也不算太复杂,于是我决定采用他的代码,至于作者的源码以及讲解大家可以看他的原文:Android 自定义控件之 CircleImageView

直接在项目中新建一个类,把作者的源码考进来就行了。然后为了让这个CircleImageView有网络请求功能,我们需要修改一些代码,比如第一步把CircleImageView继承的父类从系统提供的ImageView改成Volley的NetworkImageView(NetworkImageView的父类也是ImageView),这样,CircleImageView瞬间就具备了网络请求功能,这样做其实是很投机取巧的,所以果然,在后续的使用中遇到了很多坑。首先第一个问题,不给控件指定src,直接就会崩溃:我们本来想的是从网络加载图片然后显示,这样的话,本来没必要给控件指定一个本地的src,但是实际上是行不通的。报错以后我们找到问题的源头是CircleImageView中的onMeasure流程,先贴一段onMeasure的部分源码:

    @Override    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        int intrinsicWidth = getDrawable().getIntrinsicWidth();        int intrinsicHeight = getDrawable().getIntrinsicHeight();        Log.d(TAG, "intrinsicWidth = " + intrinsicWidth + " intrinsicHeight = " + intrinsicHeight);        if (isCircle) {            /**            *1、如果圆形半径设置为0,则使用图片中宽高之间的最小值作为圆形的半径            *2、如果圆形半径不为0,则取半径,宽,高之间的最小值作为半径            **/            int width = resolveAdjustedSize(radius == 0 ? intrinsicWidth : Math.min(intrinsicWidth, radius * 2), Integer.MAX_VALUE, widthMeasureSpec);            int height = resolveAdjustedSize(radius == 0 ? intrinsicHeight : Math.min(intrinsicHeight, radius * 2), Integer.MAX_VALUE, heightMeasureSpec);            int border = Math.min(width, height);            radius = border / 2;            Log.d(TAG, "isCircle border = " + border + " radius = " + radius);            setMeasuredDimension(border, border);

可以看到,onMeasure流程首先就要获取drawable的固定宽高,再通过和使用者设置的半径的大小判断来决定最终的测量半径。因此如果不设置src,getDrawable()只能返回null,所以NullPointerException就会报出,为了简便起见,在使用时先设置一个本地src就能避免这个坑,反正网络图片在加载出来之前是要显示一张图片的,所以在这里直接设置一张也未尝不可。除此之外还有个小细节,NetworkImageView是一个能自动将网络加载的图片压缩成指定大小的控件,所以我们在使用时,半径多数情况下是自己指定的,而不是根据设定的src的宽高来得到半径,因此我们不用CircleImageView代码作者提供的半径选取方案,而是只要半径不为0,就直接将设定的半径作为最终半径,这个代码修改很简单,我就不贴代码了。


注意以上问题以后在普通的使用中就没有什么问题了,但是使用场景不会总是仅仅把图片显示到界面上这么简单,有时候还会遇到更复杂的使用场景,比如RecyclerView,这时候以上代码就会遇到新的坑。

这就是第二个问题,图片资源回收后从缓存中重加载。我写了一个文章评论的模块,打开某一篇文章的评论列表,就会看到各个用户对文章的评论,当然,它会显示每个评论人的头像,于是我的做法是这样的:首先定义一个评论实体类,Comment,其中,Comment中有一个字段,ImageLoader,对,就是Volley中使用NetworkImageView的setImageUrl时必须传入的参数之一,这样每一个Comment的对象都有一个自己的ImageLoader,就是加载每条评论的评论人头像必备的。然后setImageUrl方法放在adapter中的onBindViewHolder方法中执行,这样,在RecyclerView的每个item中显示一张图片的逻辑就写完了。但是测试的时候,把评论列表拉到下面,再拉回最上面的位置的时候,程序又崩溃报错了,查了一下错误日志,发现报错的方法是CircleImageView中的drawableToBitmap方法,我把这个方法的源码贴出来:

private Bitmap drawableToBitmap(Drawable drawable) {        int w = drawable.getIntrinsicWidth();        int h = drawable.getIntrinsicHeight();        Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);        Canvas canvas = new Canvas(bitmap);        drawable.draw(canvas);        return bitmap;    }
代码很短,从名字就能看出来,这个方法是用来把Drawable对象转化为Bitmap的。那它在什么地方调用呢?调用它的两个方法是两种不同的绘制方式的执行流程,关于CircleImageView的两种绘制方式代码作者的原文里有介绍,这里就不重复了。无论采用什么绘制方式,最终都是在onDraw中执行,也就是自定义View的三大流程中的最后一个绘制流程。

错误日志显示的异常是错误的参数传递,宽和高必须都大于0

也就是drawableToBitmap这个方法的第四行,初始化Bitmap的时候中传入的参数w和h没有都大于0。这个原因是什么呢,了解了RecyclerView的机制以及我们刚才测试的时候的操作方式我们就可以知道:在RecyclerView中显示的图片只要移出了屏幕,图片资源就会被回收掉,当再次从屏幕中拉回来的时候,图片资源会被重新加载(这样的机制是为了避免多图OOM),也就是说onBindViewHolder方法在对应的item每次进入屏幕的时候都会执行一次。如果item中的ImageView显示的是本地图片,在重加载的时候就会重新拉出图片来显示,如果显示的是网络图片,由于做了缓存,重加载的时候就会从缓存中将图片拿出来,如果缓存不存在,则会再次发送一次网络请求(之所以会这样执行,也是因为我们把NetworkImageView的setImageUrl方法放在了onBindViewHolder中执行,这个前面已经说过了)。RecyclerView回收图片只是回收图片的内容,而不是把图片这个控件销毁,所以当item中的图片被回收掉又重新加载的时候,CircleImageView的onMeasure和onLayout都不会再次执行,会执行的只有最后一个流程onDraw。所以问题就出在这个onDraw上,前文我们讲过,如果不给CircleImageView指定src的话,程序就会崩溃,原因就是第一个流程onMeasure执行的时候第一步就是获取到图片的drawable,并获得它的固定宽高从而完成onMeasure流程,由于我们指定了src,所以获取到的drawable就不会为空,因此这个问题自然也就解决了,但是,成功完成了CircleImageView的三大流程之后我们又使用setImageUrl方法从网络加载了图片资源并显示在CircleImageView上,这样的话,原本指定的src所对应的drawable对象就不存在了,这时候显示的是从网络加载过来的图片,但这个图片一直是以Bitmap的形式存在,因此当图片资源被回收又重加载的时候,drawableToBitmap方法中的w和h想通过获取drawable的固定宽高的方式来获得它们自己的值得方式就行不通了,所以这里w和h获得的宽高都是不合法的,因此Bitmap的构建也就是不成功的。我们整理一下思路,item中的图片被回收,现在要重新被加载,那首先我们应该考虑的就是把刚才缓存的拿出来直接用,那缓存的图片放在哪里了呢,这时候我们就应该阅读Volley NetworkImageView的源码,上一篇文章已经分析过,请求是通过loadImageIfNecessary来实现的,它有一系列的判断,比如上篇文章讲过的,URL地址重复则不发送请求,以及这次我们要关注的缓存如果存在则直接使用缓存的图片等等,上篇文章我也提到过,加载的具体实现还是ImageLoader,因此我们这里也应该去阅读ImageLoader的源码,具体的源码我也不贴了,实在是太长了,我直接说结论,ImageLoader有个内部类ImageContainer,这个我之前也提到了不少,它有一个字段mBitmap,通过阅读后文可知,从网络加载过来的图片就存放在mBitmap里,刚好ImageContainer提供了一个方法getBitmap()来返回mBitmap,现在我们要在NetworkImageView中拿到这个mBitmap,只需定义一个方法:

    public Bitmap returnBitmap() {        return mImageContainer.getBitmap();    }
这样就OK了,mImageContainer是NetworkImageView所持有的字段。现在我们要修改CircleImageView的drawableTOBitmap方法了,由于CircleImageView直接继承自NetworkImageView,所以我们可以直接在drawableToBitmap方法里调用returnBitmap()这个方法。

因此drawableToBitmap方法的代码被改成如下形势:

   private Bitmap drawableToBitmap(Drawable drawable) {        int w = drawable.getIntrinsicWidth();        int h = drawable.getIntrinsicHeight();        Bitmap bitmap;        if (w <= 0 || h <= 0) {            bitmap = returnBitmap();            }        } else {            bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);        }        Canvas canvas = new Canvas(bitmap);        drawable.draw(canvas);        return bitmap;    }

这样看起来就行了,于是我们打开app再测试一下。开始试了几次都成功通过测试,但是后面只要滑动RecyclerView的速度够快,程序一样会崩溃。

于是我们再来分析这次崩溃的原因,它说构建Canvas的时候的实参bitmap是个null。于是我仔细分析了自己的代码,第一次onDraw执行的时候w和h都是本地指定的src的,因此bitmap对象是通过Bitmap.createBitmap()方法构建的,重加载的时候,也就是if判断中w和h都不正常的时候我们通过returnBitmap把缓存的Bitmap拿出来直接用。那还有哪一种情况没有考虑到呢?我仔细分析之后终于知道了,当进入评论列表的界面的时候,由于滑动速度太快,有些item的图片还没有从网络加载完成,我们就把它滑出了屏幕,这时候它就被回收了,这种情况下,由于网络图片没有加载完成,所以缓存是不存在的,而CircleImageView显示的图片是本地的src指定的图片,所以这个图片在这里被回收了,但是重加载的时候我们在Adapter的onBindViewHolder方法中也没有关于指定本地src的代码,因此在这种情况下就造成了bitmap实参为null。于是我又想了个投机取巧的办法,把drawableToBitmap方法就被我改成了以下这样:

    private Bitmap drawableToBitmap(Drawable drawable) {        int w = drawable.getIntrinsicWidth();        int h = drawable.getIntrinsicHeight();        Bitmap bitmap;        if (w <= 0 || h <= 0) {            bitmap = returnBitmap();            if (bitmap == null) {                Resources resources = getResources();                Drawable mDrawable = resources.getDrawable(R.drawable.ic_face_grey600_48dp);                w = mDrawable.getIntrinsicWidth();                h = mDrawable.getIntrinsicHeight();                bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);            }        } else {            bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);        }        Canvas canvas = new Canvas(bitmap);        drawable.draw(canvas);        return bitmap;    }
我们在之前的if判断下又加了一个判断,如果returnBitmap返回的结果为null,则利用一张本地图片构建Drawable对象,然后在通过它的固定宽高来赋值给w和h,从而构建Bitmap对象。这样在任何情况下,程序都不会崩溃了。问题看起来是解决了。


解决了以上我踩的坑,我们就能利用Volley的NetworkImageView以及自定义的CircleImageView来实现一个圆形的网络图片请求控件。

但是说实话,这个控件有很多缺点,因为无论NetworkImageView还是CircleImageView在设计之初都是没有为配合对方考虑的,所以强行让CircleImageView继承NetworkImageView,在用的时候才会出现这么多坑,虽然我们把这些坑一个一个解决了,但是不得不承认有些解决方法还用上了例如本地的一些其他本来毫不相干的图片作为解决的跳板,这样做其实是很不好的,如果想让这个控件更完美,我们应该彻底分析NetworkImageView和CircleImageView两个类的源码,然后把他们以更优雅和聪明的方式结合在一起,例如在不指定本地src的时候控件也能正常运行,就像单独使用NetworkImageView的时候那样,并且最好不要用本地图片当一个中间跳板。如果大家有兴趣也许可以试一试,不过为了直接方便,我还是推荐大家用Glide和Fresco,这两个库的设计比Volley的图片加载要高明的多,功能也强劲不少。


10月24日更新:

过了这么久又陆陆续续遇到了各种使用情况,再加上通过之前的反思,我把这个方案又重新修改了许多地方。这次更新,我把我最新的解决方案记录下来。

首先,不指定src的问题。不指定src时,可能会引起崩溃的所有地方,无非是NetCicleImageView中的getDrawable()方法获取不到Drawable对象,那我们就做个判定,一旦获取不到时,我们通过另外的方案让代码执行下去。
第一处要修改的地方就是onMeasure()方法:
@Override    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        int intrinsicWidth;        int intrinsicHeight;        if (getDrawable() != null) {            intrinsicWidth = getDrawable().getIntrinsicWidth();            intrinsicHeight = getDrawable().getIntrinsicHeight();        } else {            intrinsicWidth = getWidth();            intrinsicHeight = getHeight();        }        //Log.d(TAG, "intrinsicWidth = " + intrinsicWidth + " intrinsicHeight = " + intrinsicHeight);        if (isCircle) {            /**             *1、如果圆形半径设置为0,则使用图片中宽高之间的最小值作为圆形的半径             *2、如果圆形半径不为0,则取半径,宽,高之间的最小值作为半径             *3、此处代码与2016年10月6日修改,改为只要半径不为0,直接取半径作为最终半径             **/            int width = resolveAdjustedSize(radius == 0 ? intrinsicWidth : radius * 2, Integer.MAX_VALUE, widthMeasureSpec);            int height = resolveAdjustedSize(radius == 0 ? intrinsicHeight : radius * 2, Integer.MAX_VALUE, heightMeasureSpec);            int border = Math.min(width, height);            radius = border / 2;            Log.d(TAG, "isCircle border = " + border + " radius = " + radius);            setMeasuredDimension(border, border);

可以看到,如果drawable是null,则固定宽高固然获取不到,这里就用控件设定的宽高的值赋给固定宽高。

接下来就是drawByXfermode()方法的代码:
int width = getWidth();        int height = getHeight();        int restore = canvas.saveLayer(0, 0, width, height, null,                Canvas.ALL_SAVE_FLAG);  //保存Layer        if (isCircle) {            canvas.drawCircle(radius, radius, radius, paint); //绘制圆形            paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); //设置Xfermode            canvas.saveLayer(0, 0, width, height, paint, Canvas.ALL_SAVE_FLAG); //二次保存Layer            Bitmap bitmap;            if (getDrawable() == null) {                bitmap = BitmapCache.decodeSampledBitmapFromResource(getResources(),                        R.drawable.ic_face_grey600_48dp, radius * 2 , radius * 2);            } else {                bitmap = drawableToBitmap(getDrawable());            }
原本的代码我就不贴了,大致思想就是如果getDrawable() == null,则我们用一张自己的图片把它压缩后解析成一个Bitmap对象,BitmapCache.decodeSampleBitmapFromResourece()是我自定义的一个静态方法,用来压缩,具体实现就不贴了,官方文档和网上的博客基本都有具体实现的教程。
但是注意,由于在我的app中,圆形图片控件只在显示头像的时候调用,所以可以固定只显示这一张图片,如果需求和我不同,这里的代码还是要自己修改下。
然后onDraw()方法也要改下,把里面和drawable是否为空的判断去掉。
再往下就是,上面调用的drawableToBitmap()方法,也做下修改:
    private Bitmap drawableToBitmap(Drawable drawable) {        int w = drawable.getIntrinsicWidth();        int h = drawable.getIntrinsicHeight();        Bitmap bitmap;        if (w <= 0 || h <= 0) {            bitmap = BitmapCache.decodeSampledBitmapFromResource(getResources(),                    R.drawable.ic_face_grey600_48dp, radius * 2, radius * 2);        } else {            bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);            Canvas canvas = new Canvas(bitmap);            drawable.draw(canvas);        }        return bitmap;    }
这里可以看到,比之前的代码简单多了,之前我们对Volley本身的修改也可以复原了,之所以可以这样改是因为我们在这个方法调用之前就做了getDrawable()为空的判断,不会再像之前那样出现复杂的情况。这里还是做了个判断,一旦getDrawable()发生问题的时候,获取到的宽高不正常,我们就像上面一样自己用一张图片压缩后解析成bitmap。(虽然暂时还没有遇到过这种情况)

最后我的总结是,每个开源库,特别是那些比较有名的开源库,都是一个封装好的完整体系,虽然他们也会有不足。我们为了某一个需求,就把开源库的代码当做自己项目中的代码来随意更改其实不见得是个好事情,因为你为了片面的需求对代码的增删会破坏它原本的完整性,如果是在自己学习的项目中,随意改改能增加自己的经验,也许没什么大问题,但是如果做个正式一点的项目,可能就需要仔细斟酌,我们的修改是否符合这个库原本的设计思想,以及有没有破坏它的完整性,以及我们的修改是否是某一类需求中都可以使用的一种通用的做法,还是为了某一个需求而单纯增加的代码。


1 0
原创粉丝点击