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