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方法内部已经帮我们处理好了线程的切换问题。那么,我们来看看效果:
下载进度显示.gif
可以看到,结果还是不错的,进度条正常显示并根据下载情况来更新进度条的,下载完成后正常显示图片。
原理分析
在实现了下载进度的监听后,我们从源码的角度来分析以上实现的原理,其中会涉及到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的使用贯穿了整个流程,笔者后续文章会专门讲述上传进度的监听。谢谢你们的阅读!