1. 程式人生 > >OkHttp3使用解析:實現下載進度的監聽及其原理簡析

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的使用貫穿了整個流程,筆者後續文章會專門講述上傳進度的監聽。謝謝你們的閱讀!