OkHttp3使用解析:實現下載進度的監聽及其原理簡析

前言

本篇文章主要介紹如何利用OkHttp3實現下載進度的監聽。其實下載進度的監聽,在OkHttp3的官方源碼中已經有了相應的實現(傳送門),我們可以參考它們的實現方法,並談談它們的實現原理,以便我們更好地理解。

引入依賴

筆者在寫下這篇文章的時候,OkHttp已經更新到了3.6.0:

dependencies {
    compile 'com.squareup.okhttp3:okhttp:3.6.0'
}

下載進度監聽的實現

我們知道,OkHttp把請求和響應分別封裝成了RequestBody和ResponseBody,舉例子來說,ResponseBody內部封裝了響應的Head、Body等內容,如果我們要獲取當然的下載進度,即傳輸了多少字節,那麼我們就要對ResponseBody做出某些修改,以便能讓我們知道傳輸的進度以及設置相應的回調函數供我們使用。因此,我們先來了解一下ResponseBody這個類(RequestBody同理),它是一個抽象類,有着三個抽象方法:

public abstract class ResponseBody implements Closeable {
    //返回響應內容的類型,比如image/jpeg
    public abstract MediaType contentType();
    //返回響應內容的長度
    public abstract long contentLength();
    //返回一個BufferedSource
    public abstract BufferedSource source();
    
    //...
}

前面兩個方法容易理解,那麼第三個方法怎樣理解呢?其實這裏的BufferedSource用到了Okio,OkHttp的底層流操作實際上是Okio的操作,Okio也是square的,主要簡化了Java IO操作,有興趣的讀者可以查閱相關資料,這裏不詳細說明,只做簡單分析。BufferedSource可以理解爲一個帶有緩衝區的響應體,因爲從網絡流讀入響應體的時候,Okio先把響應體讀入一個緩衝區內,也即是BufferedSource。知道了這三個方法的用處後,我們還應該考慮的是,我們需要一個回調接口,方便我們實現進度的更新。我們繼承ResponseBody,實現ProgressResponseBody:

public class ProgressResponseBody extends ResponseBody {
    
    //回調接口
    interface ProgressListener{
        /**
         * @param bytesRead 已經讀取的字節數
         * @param contentLength 響應總長度
         * @param done 是否讀取完畢
         */
        void update(long bytesRead,long contentLength,boolean done);
    }

    private final ResponseBody responseBody;
    private final ProgressListener progressListener;
    private BufferedSource bufferedSource;

    public ProgressResponseBody(ResponseBody responseBody,ProgressListener progressListener){
        this.responseBody = responseBody;
        this.progressListener = progressListener;
    }

    @Override
    public MediaType contentType() {
        return responseBody.contentType();
    }

    @Override
    public long contentLength() {
        return responseBody.contentLength();
    }

    //source方法下面會繼續說到.
    @Override
    public BufferedSource source() {
    
    }
}

通過構造方法,把真正的ResponseBody傳遞進來,並且在contentType()和contentLength()方法返回真正的ResponseBody相應的參數。我們來看source()方法,這裏要返回BufferedSource對象,那麼這個對象如何獲取呢?答案是利用Okio.buffer(Source)方法來獲取一個BufferedSource對象,但該方法則要接受一個Source對象作爲參數,那麼Source又是什麼呢?其實Source相當於一個輸入流InputStream,即響應的數據流。Source可以很輕易獲得,通過調用responseBody.source()方法就能獲得一個Source對象。那麼,到現在爲止,source()方法看起來應該是這樣的: bufferedSource = Okio.buffer(responseBody.source());
顯然,這樣直接返回了一個BufferedSource對象,那麼我們的ProgressListener並沒有在任何地方得到設置,因此上面的方法是不妥的,解決方法是利用Okio提供的ForwardingSource來包裝我們真正的Source,並在ForwardingSource的read()方法內實現我們的接口回調,具體看如下代碼:

    @Override
    public BufferedSource source() {
        if (bufferedSource == null){
            bufferedSource = Okio.buffer(source(responseBody.source()));
        }
        return bufferedSource;
    }

    private Source source(Source source){
        return new ForwardingSource(source) {
            long totalBytesRead = 0L;
            @Override
            public long read(Buffer sink, long byteCount) throws IOException {
                long bytesRead = super.read(sink,byteCount);
                totalBytesRead += bytesRead != -1 ? bytesRead : 0;   //不斷統計當前下載好的數據
                //接口回調
                progressListener.update(totalBytesRead,responseBody.contentLength(),bytesRead == -1);
                return bytesRead;
            }
        };
    }

經過上面一系列的步驟,ResponseBody已經包裝成我們想要的樣子,能在接受數據的同時回調接口方法,告訴我們當前的傳輸進度。那麼,在業務邏輯層我們該怎樣利用這個ResponseBody呢?OkHttp提供了一個Interceptor接口,即攔截器來幫助我們實現對請求的攔截、修改等操作。我們簡單看看Interceptor接口:

public interface Interceptor {
  Response intercept(Chain chain) throws IOException;

  interface Chain {
    Request request();
    Response proceed(Request request) throws IOException;
    Connection connection();
  }
}

這裏通過intercept(Chain)方法進行攔截,返回一個Response對象,那麼我們可以在這裏通過Response對象的建造器Builder對其進行修改,把Response.body()替換成我們的ProgressResponseBody即可,說的有點抽象,我們還是直接看代碼吧,在MainActivity中(佈局文件很簡單,只有ImageView和ProgressBar):

private void downloadProgressTest() throws IOException {
        //構建一個請求
        Request request = new Request.Builder()
        //下面圖片的網址是在百度圖片隨便找的
                .url("https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=2859174087,963187950&fm=23&gp=0.jpg")
                .build();
        //構建我們的進度監聽器
        final ProgressResponseBody.ProgressListener listener = new ProgressResponseBody.ProgressListener() {
            @Override
            public void update(long bytesRead, long contentLength, boolean done) {
                //計算百分比並更新ProgressBar
                final int percent = (int) (100 * bytesRead / contentLength);
                mProgressBar.setProgress(percent);
                Log.d("cylog","下載進度:"+(100*bytesRead)/contentLength+"%");
            }
        };
        //創建一個OkHttpClient,並添加網絡攔截器
        OkHttpClient client = new OkHttpClient.Builder()
                .addNetworkInterceptor(new Interceptor() {
                    @Override
                    public Response intercept(Chain chain) throws IOException {
                        Response response = chain.proceed(chain.request());
                        //這裏將ResponseBody包裝成我們的ProgressResponseBody
                        return response.newBuilder()
                                .body(new ProgressResponseBody(response.body(),listener))
                                .build();
                    }
                })
                .build();
        //發送響應
        Call call = client.newCall(request);
        call.enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {

            }

            @Override
            public void onResponse(Call call, Response response) throws IOException {
                //從響應體讀取字節流
                final byte[] data = response.body().bytes();      // 1
                //由於當前處於非UI線程,所以切換到UI線程顯示圖片
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        mImageView.setImageBitmap(BitmapFactory.decodeByteArray(data,0,data.length));
                    }
                });
            }
        });
    }

上面也是一般的OkHttp Get請求的構建過程,只不過是多了添加攔截器的步驟。關於攔截器的實現原理,讀者可以查閱相關的資料。細心的讀者可能會發現,筆者在ProgressResponseBody.ProgressListener#update(long bytesRead, long contentLength, boolean done)內,直接調用了mProgress.setProgress()方法,但是當前是在OkHttp的請求過程中的,即不是在UI線程,那麼爲什麼可以這樣做呢?這是因爲ProgressBar的setProgress方法內部已經幫我們處理好了線程的切換問題。那麼,我們來看看效果:



可以看到,結果還是不錯的,進度條正常顯示並根據下載情況來更新進度條的,下載完成後正常顯示圖片。

原理分析

在實現了下載進度的監聽後,我們從源碼的角度來分析以上實現的原理,其中會涉及到Okio的內容。首先看一個問題:如果把上面的①號代碼去掉,即我們不執行下面的設置圖片操作,只是單純地發送請求,那麼重新運行程序,我們會發現進度條不會更新了,也就是說我們的接口方法沒有得到調用,其實這和實現原理是有關聯的,爲了簡單起見,我們分析ResponseBody#string()方法(與bytes()方法類似):

public final String string() throws IOException {
    BufferedSource source = source();
    try {
      Charset charset = Util.bomAwareCharset(source, charset());
      return source.readString(charset);
    } finally {
      Util.closeQuietly(source);
    }
}

這裏調用了source()方法,即ProgressResponseBody#source()方法,拿到了一個BufferedSource對象,這個對象上面已經說過了。接着獲取字符集編碼Charset,下面調用了source.readString(charset)方法得到字符串並返回,從方法名字我們知道,這是一個讀取輸入流解析成字符串的一個方法,但BufferedSource是一個抽象接口,其實現類是RealBufferedSource,我們來看RealBufferedSource#readString(charset)

  @Override public String readString(Charset charset) throws IOException {
    if (charset == null) throw new IllegalArgumentException("charset == null");

    buffer.writeAll(source);  
    return buffer.readString(charset);
  }

首先調用了buffer.writeAll方法,在該方法內部,首先把輸入流的內容寫到了buffer緩衝區內,然後再從緩衝區讀取字符串返回。那寫入緩衝區具體實現是怎樣的呢?我們繼續看Buffer#writeAll(Source)方法:

@Override public long writeAll(Source source) throws IOException {
    if (source == null) throw new IllegalArgumentException("source == null");
    long totalBytesRead = 0;
    for (long readCount; (readCount = source.read(this, Segment.SIZE)) != -1; ) {
      totalBytesRead += readCount;
    }
    return totalBytesRead;
  }

重點關注其中的for循環,可以發現,這個循環結束的條件是source.read()方法返回-1,表示傳輸完畢,有沒有發現這個read()方法有點眼熟?這正是我們上面的ForwardingSource類實現的read()方法!也就是說,在for循環內,每次從輸入流讀取數據的時候,會回調到我們的ProgressListener#update方法。這也就解釋了,如果我們沒有調用Response.body().string()或bytes()方法的話,OkHttp壓根就沒有從輸入流讀取數據,哪怕響應已經返回。

結論:用以上方法實現的傳輸進度監聽,每一次接口方法的回調發生在OkHttp向緩衝區Buffer寫入數據的過程中。

總結

上面實現了下載進度的監聽,需要注意的是:我們在回調方法update()來更新進度條,但是該方法的環境是非UI線程的,用ProgressBar可以更新,如果換了別的View比如TextView顯示最新的進度,則會直接error,所以如果要在該處實現更新不同的View的狀態,應該切換到UI線程中執行,也可以封裝成Message,通過Handler來切換線程。至於上次進度的監聽,與下載進度的監聽是類似的,Okio與OkHttp的使用貫穿了整個流程,筆者後續文章會專門講述上傳進度的監聽。謝謝你們的閱讀!

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