网络请求Android Architecture Components网络

OkHttp3使用解析:实现下载进度的监听及其原理简析

2017-02-21  本文已影响1306人  丶蓝天白云梦

前言

本篇文章主要介绍如何利用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的使用贯穿了整个流程,笔者后续文章会专门讲述上传进度的监听。谢谢你们的阅读!

上一篇 下一篇

猜你喜欢

热点阅读