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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容