自定義一個基於Volley NetworkImageView的圓形帶網絡請求功能的圖片控件

上一篇文章也是關於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。(雖然暫時還沒有遇到過這種情況)

最後我的總結是,每個開源庫,特別是那些比較有名的開源庫,都是一個封裝好的完整體系,雖然他們也會有不足。我們爲了某一個需求,就把開源庫的代碼當做自己項目中的代碼來隨意更改其實不見得是個好事情,因爲你爲了片面的需求對代碼的增刪會破壞它原本的完整性,如果是在自己學習的項目中,隨意改改能增加自己的經驗,也許沒什麼大問題,但是如果做個正式一點的項目,可能就需要仔細斟酌,我們的修改是否符合這個庫原本的設計思想,以及有沒有破壞它的完整性,以及我們的修改是否是某一類需求中都可以使用的一種通用的做法,還是爲了某一個需求而單純增加的代碼。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章