Android OkHttp + Retrofit 下載文件與進度監聽

  • 2019 年 10 月 19 日
  • 筆記

本文鏈接

下載文件是一個比較常見的需求。給定一個url,我們可以使用URLConnection下載文件
使用OkHttp也可以通過流來下載文件。
給OkHttp中添加攔截器,即可實現下載進度的監聽功能。

使用流來實現下載文件

代碼可以參考:https://github.com/RustFisher/android-Basic4/tree/master/appdowloadsample

獲取並使用位元組流,需要注意兩個要點,一個是服務接口方法的 @Streaming 註解,另一個是獲取到ResponseBody。

獲取流(Stream)。先定義一個服務ApiService。給方法添加上@Streaming的註解。

    private interface ApiService {          @Streaming          @GET          Observable<ResponseBody> download(@Url String url);      }

初始化OkHttp。記得填入你的baseUrl。

    OkHttpClient okHttpClient = new OkHttpClient.Builder()              .connectTimeout(8, TimeUnit.SECONDS)              .build();        retrofit = new Retrofit.Builder()              .client(okHttpClient)              .addCallAdapterFactory(RxJava2CallAdapterFactory.create())              .baseUrl("https://yourbaseurl.com")              .build();

發起網絡請求。獲取到ResponseBody。

    String downUrl = "xxx.com/aaa.apk";      retrofit.create(ApiService.class)              .download(downUrl)              .subscribeOn(Schedulers.io())              .observeOn(Schedulers.io())              .doOnNext(new Consumer<ResponseBody>() {                  @Override                  public void accept(ResponseBody responseBody) throws Exception {                      // 處理 ResponseBody 中的流                  }              })              .doOnError(new Consumer<Throwable>() {                  @Override                  public void accept(Throwable throwable) throws Exception {                      Log.e(TAG, "accept on error: " + downUrl, throwable);                  }              })              .observeOn(AndroidSchedulers.mainThread())              .subscribe(new Observer<ResponseBody>() {                  @Override                  public void onSubscribe(Disposable d) {                    }                    @Override                  public void onNext(ResponseBody responseBody) {                    }                    @Override                  public void onError(Throwable e) {                      Log.e(TAG, "Download center retrofit onError: ", e);                  }                    @Override                  public void onComplete() {                    }              });

通過ResponseBody拿到位元組流 body.byteStream()。這裡會先創建一個臨時文件tmpFile,把數據寫到臨時文件里。
下載完成後再重命名成目標文件targetFile。

    public void saveFile(ResponseBody body) {          state = DownloadTaskState.DOWNLOADING;          byte[] buf = new byte[2048];          int len;          FileOutputStream fos = null;          try {              Log.d(TAG, "saveFile: body content length: " + body.contentLength());              srcInputStream = body.byteStream();              File dir = tmpFile.getParentFile();              if (dir == null) {                  throw new FileNotFoundException("target file has no dir.");              }              if (!dir.exists()) {                  boolean m = dir.mkdirs();                  onInfo("Create dir " + m + ", " + dir);              }              File file = tmpFile;              if (!file.exists()) {                  boolean c = file.createNewFile();                  onInfo("Create new file " + c);              }              fos = new FileOutputStream(file);              long time = System.currentTimeMillis();              while ((len = srcInputStream.read(buf)) != -1 && !isCancel) {                  fos.write(buf, 0, len);                  int duration = (int) (System.currentTimeMillis() - time);                    int overBytes = len - downloadBytePerMs() * duration;                  if (overBytes > 0) {                      try {                          Thread.sleep(overBytes / downloadBytePerMs());                      } catch (Exception e) {                          e.printStackTrace();                      }                  }                  time = System.currentTimeMillis();                  if (isCancel) {                      state = DownloadTaskState.CLOSING;                      srcInputStream.close();                      break;                  }              }              if (!isCancel) {                  fos.flush();                  boolean rename = tmpFile.renameTo(targetFile);                  if (rename) {                      setState(DownloadTaskState.DONE);                      onSuccess(url);                  } else {                      setState(DownloadTaskState.ERROR);                      onError(url, new Exception("Rename file fail. " + tmpFile));                  }              }          } catch (FileNotFoundException e) {              Log.e(TAG, "saveFile: FileNotFoundException ", e);              setState(DownloadTaskState.ERROR);              onError(url, e);          } catch (Exception e) {              Log.e(TAG, "saveFile: IOException ", e);              setState(DownloadTaskState.ERROR);              onError(url, e);          } finally {              try {                  if (srcInputStream != null) {                      srcInputStream.close();                  }                  if (fos != null) {                      fos.close();                  }              } catch (IOException e) {                  Log.e(TAG, "saveFile", e);              }              if (isCancel) {                  onCancel(url);              }          }      }  

每次讀數據的循環,計算讀了多少數據和用了多少時間。超過限速後主動sleep一下,達到控制下載速度的效果。
要注意不能sleep太久,以免socket關閉。
這裡控制的是網絡數據流與本地文件的讀寫速度。

下載進度監聽

OkHttp實現下載進度監聽,可以從位元組流的讀寫那裡入手。也可以使用攔截器,參考官方的例子
這裡用攔截器的方式實現網絡下載進度監聽功能。

定義回調與網絡攔截器

先定義回調。

public interface ProgressListener {      void update(String url, long bytesRead, long contentLength, boolean done);  }

自定義ProgressResponseBody。

public class ProgressResponseBody extends ResponseBody {        private final ResponseBody responseBody;      private final ProgressListener progressListener;      private BufferedSource bufferedSource;      private final String url;        ProgressResponseBody(String url, ResponseBody responseBody, ProgressListener progressListener) {          this.responseBody = responseBody;          this.progressListener = progressListener;          this.url = url;      }        @Override      public MediaType contentType() {          return responseBody.contentType();      }        @Override      public long contentLength() {          return responseBody.contentLength();      }        @Override      public BufferedSource source() {          if (bufferedSource == null) {              bufferedSource = Okio.buffer(source(responseBody.source()));          }          return bufferedSource;      }        private Source source(final 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);                  // read() returns the number of bytes read, or -1 if this source is exhausted.                  totalBytesRead += bytesRead != -1 ? bytesRead : 0;                  progressListener.update(url, totalBytesRead, responseBody.contentLength(), bytesRead == -1);                  return bytesRead;              }          };      }  }

定義攔截器。從Response中獲取信息。

public class ProgressInterceptor implements Interceptor {        private ProgressListener progressListener;        public ProgressInterceptor(ProgressListener progressListener) {          this.progressListener = progressListener;      }        @NotNull      @Override      public Response intercept(@NotNull Chain chain) throws IOException {          Response originalResponse = chain.proceed(chain.request());          return originalResponse.newBuilder()                  .body(new ProgressResponseBody(chain.request().url().url().toString(), originalResponse.body(), progressListener))                  .build();      }  }

添加攔截器

在創建OkHttpClient時添加ProgressInterceptor。

    OkHttpClient okHttpClient = new OkHttpClient.Builder()              .connectTimeout(8, TimeUnit.SECONDS)              .addInterceptor(new ProgressInterceptor(new ProgressListener() {                  @Override                  public void update(String url, long bytesRead, long contentLength, boolean done) {                      // tellProgress(url, bytesRead, contentLength, done);                  }              }))              .build();

值得注意的是這裡的進度更新非常頻繁。並不一定每次回調都要去更新UI。