App网络请求实战三:下载文件以及断点续载
瓜皮已上线,嘿嘿。想反杀,不存在的!
小老板,多捞哦。
还是原来的配方,无图言diao
本篇记录的是如何将下载功能集成到网络框架中。
解题步骤
1.大的方面来了解okhttp、retrofit、rxjava三者之间的关系
a.首先okhttp是一个Http底层请求库,square公司开发已被谷歌Android采用。和okhttp对应的有:httpurlconnection和httpClient。其中httpClient已经被弃用了。
b.然后retrofit是一个Http高层请求库,按理说它应该不关心底层的http请求库是什么。不信你可以注意到我们每次写retrofit有这么一句
new Retrofit.Builder()
.client(client);
按照扩展性原则来说,这个client可以是httpurlconnection或者是okhttp或者是httpClient。但是retrofit2.0之后貌似直接将其定位okhttp了,也就是说retrofit只能配置okhttp了。也可以从retrofit源码中看出的:
/**
* The HTTP client used for requests.
* <p>
* This is a convenience method for calling {@link #callFactory}.
*/
public Builder client(OkHttpClient client) {
return callFactory(checkNotNull(client, "client == null"));
}
/**
* Specify a custom call factory for creating {@link Call} instances.
* <p>
* Note: Calling {@link #client} automatically sets this value.
*/
public Builder callFactory(okhttp3.Call.Factory factory) {
this.callFactory = checkNotNull(factory, "factory == null");
return this;
}
和retrofit对应的框架有volley、async-http-client。
c.最后rxjava其实跟网络没什么关系,rxjava在这里面其的作用就是响应式编程和链式编程范式,以及更加轻易的处理异步问题。在项目里,凡是涉及到异步的操作都可以使用rxjava来处理,不一定是网络请求。
2.retrofit下载文件
明白了上面的关系后,讲道理我们可以知道最终实现下载的还是okhttp,只是说现在套了一层retrofit。所以呢,胸低们,我们首先必须要了解retrofit下载文件的方式。两种方式如下:
//方式一
//下载文件
//如果文件非常大,必须要使用Streaming注解。否则retrofit会默认将整个文件读取到内存中
//造成OOM
@Streaming
@GET
Observable<ResponseBody> downLoadFile(@Url String fileUrl);
//方式二
@Streaming
@GET("tools/test.apk")
Observable<ResponseBody> downLoadFile();
从上往下看,注意下载文件必须要添加Steaming注解。因为如果不添加这个注解,如果文件非常大,retrofit会默认将整个文件读取到内存中,造成OOM。两种方式的区别:方式一可以更灵活的更改下载的url,方式二只能下载baseUrl下的文件。懂了不,一个灵活,一个死板,所以果断选择灵活的那个。其次期望返回的是okhttp的ResponseBody对象,这个对象有个方法:
public final InputStream byteStream() {
return source().inputStream();
}
兄弟们,懂了不,拿到输入流了,至此我们可以为所欲为了。后面的操作和httpurlConnection、httpClient甚至是利用okhttp下载是一样一样儿滴~~下面赶紧试验下,不会打我脸吧,擦,好怕怕哦。
String url = "http://imtt.dd.qq.com/16891/8EE1D586937A31F6E0B14DA48F8D362E.apk?fsname=com.dewmobile.kuaiya_5.4.2(CN)_216.apk&csr=1bbd";
Observable<ResponseBody> observable = apiService.downLoadFile(url)
.compose(RxSchedulers.<ResponseBody>io_main());
observable.subscribe(new DownLoadObserver()
{
@Override
public void onNext(ResponseBody responseBody)
{
//拿到输入流后,我们就可以终极为所欲为至为所欲为!
InputStream inputStream = responseBody.byteStream();
}
@Override
public void onError(Throwable e)
{
Toast.makeText(MainActivity.this, "下载失败", Toast.LENGTH_SHORT).show();
}
});
看我多贴心的给大伙儿找到一个下载apk的链接地址。注意,这里就没必要用map来转换返回类型了哦,只需要添加线程切换转换即可。这里再次强调下什么时候用map,咳咳,注意了大胸弟:
首先
@Streaming
@GET
Observable<ResponseBody> downLoadFile(@Url String fileUrl);
表示apiService调用这个downLoadFile方法,我们期望返回的是一个ResponseBody对象对吧?对呀,我们本来就是要这个对象来生成输入流,所以还转个屁呀。之前要map转换类型是因为:
//获取token过期的http
@GET("tools/mockapi/440/token_expired")
Observable<BaseResponse<ResEntity1.DataBean>> getExpiredHttp();
这里我们期望的是BaseResponse对象,而我们实际要的是ResEntity1.DataBean对象,所以需要转换。擦,好累,懂得都懂,还不懂别问,往下看吧。
其实到这里只是剩下java的文件io读写了,只是要注意的是io操作是需要开启子线程的,可以说已经完成了下载功能了。但是有个大胸弟不乐意地说:特么你这里既不能显示下载进度,又不能控制开始下载、暂停下载、取消下载啊。我特么跳起来打你膝盖哦:老铁,能别bb了不?
3.显示下载进度、控制开始、暂停、取消下载
其实下载进度这个功能还算好实现,只需要用已经下载文件的长度比上总文件的长度即可
int progress = (int) (downloadedLength * 100 / contentLength);
但是我们假设啊,只是假设,现在有个老铁在外面用着4g网络,不小心点击了下载。完犊子了,不能暂停、取消下载,大骂是那个**开发的程序。
胸低们我怕被跨省千里追sa,只有想想办法怎么实现暂停下载呢,想想还有点小委屈呢。首先分析上面的需求就是:假设下载文件10M,我能不能先下载1M,然后点击暂停,然后出去high了,high完再继续从1M那里继续把流写入文件。那java里有木有这样一个类,可以控制文件指针长度偏移量的类呢,就是说可以跳到这个文件1M的那个地方的类。是有的,是RandomAccessFile类。这个类就不详细介绍了,反正他有实现我们这个需求的功能。其次是下载一半5M后,发现擦下错文件了,就要取消下载,注意哦,取消下载伴随这有个隐藏操作就是删除掉已经下好的5M文件。切记切记,不然也会被跨省的(这里其实也可以用FileOutputStream流来操作)。
但是但是,重点来了,貌似rxjava和retrofit配合无法像下面这样同步返回:
Response<BaseResponse<ResEntity1.DataBean>> tokenRes = call.execute();
(我实在不知道怎么用rxjava做出这样的,有木有大佬知道的哦)
而异步回调又做不到既能提供下载进度,又能暂停下载,取消下载。所以必须得想另一个方法。提供一种okhttpi下载的代码(引自郭霖第一行代码):
try
{
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
.addHeader("RANGE", "bytes=" + downloadedLength + "-")
.url(downloadUrl)
.build();
Response response = client.newCall(request).execute();
if (response != null)
{
Log.e("DownLoadTask", "(DownLoadTask.java:78)" + response.body().contentLength());
is = response.body().byteStream();
savedFile = new RandomAccessFile(file, "rw");
savedFile.seek(downloadedLength);
byte[] bytes = new byte[1024];
int total = 0;
int len;
while ((len = is.read(bytes)) != -1)
{
if (isCanceled)
{
return TYPE_CANCELED;
} else if (isPaused)
{
return TYPE_PAUSE;
} else
{
total += len;
savedFile.write(bytes, 0, len);
int progress = (int) ((total + downloadedLength) * 100 / contentLength);
publishProgress(progress);
}
}
}
response.body().close();
return TYPE_SUCCESS;
} catch (IOException e)
{
e.printStackTrace();
} finally
{
try
{
if (is != null)
{
is.close();
}
if (savedFile != null)
{
savedFile.close();
}
if (isCanceled && file != null)
{
file.delete();
}
} catch (IOException e)
{
e.printStackTrace();
}
}
其实这里涉及到两个问题:
- 下载进度
- 断点续载
4.下载进度
关键在于自定义ResponseBody,注意里面的source方法,我们可以在这里面对source进行提前操作,感觉跟okhttp的拦截器的思想很像。
首先定义一个下载接口:
/**
* <pre>
* 作者 : 肖坤
* 时间 : 2018/04/20
* 描述 :
* 版本 : 1.0
* </pre>
*/
public interface DownLoadListener
{
void onProgress(int progress, boolean downSuc, boolean downFailed);
}
3个参数分别是下载进度,是否下载完成,是否下载失败;
其次看下自定义的ResponseBody代码如下:
@Override
public BufferedSource source()
{
if (bufferedSource == null)
{
bufferedSource = Okio.buffer(source(mResponseBody.source()));
}
return bufferedSource;
}
private Source source(Source source)
{
return new ForwardingSource(source)
{
@Override
public long read(Buffer sink, long byteCount) throws IOException
{
long bytesRead = super.read(sink, byteCount);
totalBytesRead += bytesRead != -1 ? bytesRead : 0;
if (bytesRead == -1)
{
//下载完成
sHandler.post(new Runnable()
{
@Override
public void run()
{
mListener.onProgress(100, true, false);
}
});
} else
{
//正在下载中,更新进度
sHandler.post(new Runnable()
{
@Override
public void run()
{
int progress = (int) (totalBytesRead * 100 / contentLength);
mListener.onProgress(progress, false, false);
}
});
}
saveToFile(sink);
return bytesRead;
}
};
}
//写入文件
private void saveToFile(Buffer buffer)
{
InputStream inputStream = buffer.inputStream();
RandomAccessFile saveFile = null;
try
{
saveFile = new RandomAccessFile(file, "rw");
saveFile.seek(getDownloadedLength());
byte[] bytes = new byte[1024];
int len;
while ((len = inputStream.read(bytes)) != -1)
{
saveFile.write(bytes, 0, len);
}
} catch (FileNotFoundException e)
{
e.printStackTrace();
} catch (IOException e)
{
e.printStackTrace();
} finally
{
try
{
if (inputStream != null)
{
inputStream.close();
}
if (saveFile != null)
{
saveFile.close();
}
} catch (IOException e)
{
e.printStackTrace();
}
}
}
就是那个ForwardingSource类,真的感觉这个东西有点像okhttp中的拦截器,就是把那个source拦下来,然后就可以做一些不可描述的事情,嘿嘿嘿。这里为了后面的断点续载功能,用到了RandomAccessFile。其实也是可以用FileOutputStream的两个参数的构造函数,如下
//if <code>true</code>, then bytes will be written to the end of the file rather than the //beginning
public FileOutputStream(String name, boolean append)
throws FileNotFoundException
{
this(name != null ? new File(name) : null, append);
}
第二个参数true也是可以继续写入的。总体来说下载进度还是比较简单的。
5.断点续载
其实关键就是,玛德文字说不清楚。看图:
Http请求头中有一个属性如下:
request = request.newBuilder()
.header("RANGE", "bytes=" + downloadedLength + "-")
.build();
这个属性的作用就是可以更改请求域,比如说我想从第500个字节处开始下载。那么如何动态修改它呢,没错还是用到Interceptor拦截器:
//下载文件拦截器
static Interceptor downloadInterceptor = new Interceptor()
{
@Override
public Response intercept(Chain chain) throws IOException
{
Request request = chain.request();
//记录已写入的文件的长度
long downloadedLength = DownloadManager.dSp.getLong(downloadEntity.getFileName(), 0);
Response proceed = chain.proceed(request);
//保存源文件的总长度(关键)
DownloadManager.dSp.edit().putLong(downloadEntity.getFileName() + "content_length", proceed.body().contentLength()).commit();
request = request.newBuilder()
.header("RANGE", "bytes=" + downloadedLength + "-")
.build();
Response response = chain.proceed(request);
response = response.newBuilder()
.body(new ProgressResponseBody(response.body(), downloadEntity))
.build();
return response;
}
};
所有进度progress = (已写入文件的长度+正在下载的长度)/源文件的总长度,代码在项目中有我就不贴了。还有一个要注意就是暂停下载和取消下载,我利用的是rxjava中的Disposable,我特意写了一个下载管理类,如下所示:
/**
* Created by 肖坤 on 2018/4/22.
*
* @author 肖坤
* @date 2018/4/22
*/
public class DownloadManager
{
public static SharedPreferences dSp;
/**
* 初始化DownloadManager
*
* @param context
*/
public static void initDownManager(Context context)
{
dSp = context.getSharedPreferences("download_file", Context.MODE_PRIVATE);
}
/**
* 暂停下载
*
* @param disposable 控制rxjava的开关
* @param fileName 下载的文件名,必须包含后缀
*/
public static void pauseDownload(Disposable disposable, String fileName)
{
if (disposable == null || TextUtils.isEmpty(fileName))
{
return;
}
if (!disposable.isDisposed())
{
disposable.dispose();
}
if (dSp == null)
{
throw new NullPointerException("必须首先初始化DownloadManager");
}
File file = initFile(fileName);
if (file.exists() && dSp != null)
{
dSp.edit().putLong(file.getName(), file.length()).commit();
}
}
/**
* 取消下载
*
* @param disposable 控制rxjava的开关
* @param fileName 下载的文件名,必须包含后缀
*/
public static void cancelDownload(Disposable disposable, String fileName)
{
if (disposable == null || TextUtils.isEmpty(fileName))
{
return;
}
if (!disposable.isDisposed())
{
disposable.dispose();
}
File file = initFile(fileName);
if (file.exists() && dSp != null)
{
dSp.edit().putLong(file.getName(), 0).commit();
}
//取消下载,最后一步记得删除掉已经下载的文件
if (file.exists())
{
file.delete();
}
}
public static File initFile(String fileName)
{
String directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getPath();
File file = new File(directory + File.separator + fileName);
return file;
}
}
注意哦,我在取消下载的时候删除了写入的文件,没毛病,老铁。整的明明白白的,德云社有牌面!
最后我们在项目里下载也相当简单,代码这样写:
//测试下载文件哦
private void downloadFile()
{
fileName = "httpTest.apk";
downloadEntity = new DownloadEntity(loadListener, fileName);
ApiService apiService = RetrofitHelper.createService(ApiService.class,
RetrofitHelper.getDownloadRetrofit(downloadEntity));
Observable<ResponseBody> observable = apiService.downLoadFile(url)
.subscribeOn(Schedulers.io());
observable.subscribe(new DownLoadObserver()
{
@Override
public void onSubscribe(Disposable d)
{
disposable = d;
}
});
}
然后暂停:
//暂停下载
DownloadManager.pauseDownload(disposable, fileName);
然后取消下载:
//取消下载
DownloadManager.cancelDownload(disposable, fileName);
github链接:Demo
以上。