前面两篇文章我们讲了项目整体的设计结构、入口类DownloadManager、下载类DownloadTask,这篇文章我们讲最重要的类DownLoadRequest。
由于离前两篇文章时间比较长了,感觉陌生的同学可以先回顾一下:
Retrofit2的再封装实战—多线程下载与断点续传(一)
Retrofit2的再封装实战—多线程下载与断点续传(二)
流程图
回忆之前文章提到的,我们将需要下载的任务构造成一个List传入DownLoadManager中,DownLoadManager调用方法downLoad生成DownLoadRequest对象,同时将List参数代入,最后调用downLoadRequest.start()方法。
一、Start
start
我们将下载的部分操作封装成DownLoadHandle对象,59行我们调用queryDownLoadData方法,对应上面结构图的查询下载总长度步骤,这是一个耗时操作,不用担心,我们在之前的DownLoadManager中已经创建线程了,这里面的所有操作都是在子线程中进行的,UI线程是不会被阻塞的。
queryDownLoadData:
//汇总所有下载信息
List<DownLoadEntity> queryDownLoadData(List<DownLoadEntity> list) {
final Iterator iterator = list.iterator();
while (iterator.hasNext()) {
DownLoadEntity downLoadEntity = (DownLoadEntity) iterator.next();
downLoadEntity.downed = 0;
Call<ResponseBody> mResponseCall = null;
List<DownLoadEntity> dataList = mDownLoadDatabase.query(downLoadEntity.url);
if (dataList.size() > 0) {
downLoadEntity.multiList = dataList;
if (!TextUtils.isEmpty(dataList.get(0).lastModify)) {
mResponseCall = NetWorkRequest.getInstance().getDownLoadService().getHttpHeaderWithIfRange(downLoadEntity.url, dataList.get(0).lastModify, "bytes=" + 0 + "-" + 0);
}
} else {
mResponseCall = NetWorkRequest.getInstance().getDownLoadService().getHttpHeader(downLoadEntity.url, "bytes=" + 0 + "-" + 0);
}
executeGetFileWork(mResponseCall, new GetFileCount(downLoadEntity, mResponseCall));
}
while (!mGetFileService.isShutdown() && getCount() != list.size()) {
}
return list;
}复制代码
迭代List,先在数据库中查询当前任务的url,如果查询结果大于0,说明我们曾经下载过此url,将dataList赋值给multList,下面介绍一个概念。如果我们下载过一个文件,但是服务器将这个文件的内容置换掉了,客户端如何判断下载文件的时效性?
request
http请求头中有个If-Range属性,下面摘自网络上解释:
If-Range是另一个起条件判断的请求头(我们之前讲过If-Match/If-None-Match,If-Modified-Since/If-Unmodified-Since).If-Range头用来避免客户端在下载了某资源(比如图片)的一部分后,下次重新下载又从头开始下载。使用If-Range之后,客户端每次可以从上次下载的部分之后继续开始下载。
If-Range的使用格式为:If-Range: Etag|Http-Date也就是说If-Range后面可以使用Etag或者Last-Modified返回的值:
If-Range: “df6b0-b4a-3be1b5e1”
If-Range: Tue, 8 Jul 2008 05:05:56 GMT
逻辑上来讲,上面2种方式分别和If-Match,If-Unmodified-Since的工作原理一样,他们的值正是服务器返回的Etag和Last-Modified值。
初次接触你可能是蒙圈的,没关系,这里举例来说明一下,我下载过一个文件A,这是http的response头信息:
response
Last-Modified,直观上很清晰他是一个关于时间戳的属性。他代表着文件最后修改时间,我们需要做的就是保持这个字段到本地,下次请求时候赋值给If-Range头信息,服务器会告诉你这文件是否更新过。怎么判断?
如果请求报文中的Last-Modified与服务器目标内容的Last-Modified相等,即没有发生变化,那么应答报文的状态码为206。如果服务器目标内容发生了变化,那么应答报文的状态码为200。
这里需要注意的是:If-Range首部行必须与Range首部行配套使用。如果请求报文中没有Range首部行,那么If-Range首部行就会被忽略。如果服务器不支持If-Range,那么Range首部行也会被忽略。
好了,理论具备,只欠代码了。继续看queryDownLoadData方法,如果我们下载过此url,并且Modified不为空,调用接口来看看他是否更新过
@GET
Call
getHttpHeaderWithIfRange(@Url String fileUrl, @Header("If-Range") String lastModify, @Header("Range") String range);
复制代码
和我们之前的downloadFile方法差不多,这里不多解释。继续看,如果没下载过,直接调用getHttpHeader方法,不需要If-Range头。
executeGetFileWork方法很简单只有两行代码:
private void executeGetFileWork(Call<ResponseBody> call, GetFileCountListener listener) {
GetFileCountTask getFileCountTask = new GetFileCountTask(call, listener);
mGetFileService.submit(getFileCountTask);
}复制代码
GetFileCountTask,看名字就知道了,创建获取文件长度的任务,然后加入线程池。
GetFileCountListener查询结果回调:
public interface GetFileCountListener {
void success(boolean isSupportMulti, boolean isNew, String modified, Long fileSize);
void failed()
}复制代码
很简单两个方法,成功和失败。GetFileCountTask中通过response的返回报文,判断是否支持多线程下载,是否更新过,modified值,下载长度,代码很简单这里就不贴了,感兴趣的同学自己撸代码看吧。下面看GetFileCountListener回调:
GetFileCountListener回调
先看失败 如果重试次数小于0,停止所有任务,如果未到0,则重新尝试获取长度,重复次数默认为3次。
成功后赋值mDownLoadEntity相关属性,93-108行,如果未更换文件,判断下载文件还是否存在,存在说明只要下载剩余任务就可以了,不存在,当新任务对待。
setCount方法结合queryDownLoadData最后的while循环看,有个全局变量记录任务的完成数,每个url任务完成或者失败后count +1,如果未完成任务,或者线程池未被关闭则一直循环等待。
这里提醒下:尤其每个task都是一个线程,所以这里的计数,必须要考虑线程同步问题!这里我们选择使用synchronized。
整个queryDownLoadData就结束了,再回到start方法继续看,60-86行遍历所有下载任务,如果其中有total未获取到的任务(对应前面获取长度失败),那么直接返回错误,终止下载任务。如果都正常,叠加获得总下载值,如果总下载值=已经下载值,直接回调UI线程,已经下载结束了。88行,onStart()这时就已经回调给主线程下载百分比了,细心的朋友可能发现了,这是使用mMainThread回调UI线程,mMainThread是什么?看过Retrofit源码的朋友肯定不陌生,他的实现原理其实就是运用了拥有MainLooper的hander,因为我们的操作都是在异步线程中进行的,所以需要mMainThread是什么回调主线程(这个在之前已经讲过了),87行生成下载总回调,一个url是一个下载线程,一个下载线程对应一个自己的回调,那么每个线程的回调,统一汇聚到下载总回调,只有这个回调负责和UI接口通信。
一张图可能更能说明:
回调结构图
从下向上看,UI回调和总回调1对1关系,总回调里有UI回调引用,总回调和每个Task的回调,1对多关系,每个Listener中有总回调引用。
现在从上向下看,Listener下载了1MB,告诉总回调:“你可以给UI回调了”,UI回调就老老实实告诉UI我下载了1MB了。简单的说,总回调就是一个代理类。
二、AddDownLoadTask
我们还差什么?入口类完成了,真正的下载类完成了,下载之前的巴拉巴拉已经完成了,那就只差下载任务了对不对?下面就真的easy了。
private void addDownLoadTask(DownLoadEntity downLoadEntity) {
Map<Integer, Future> downLoadTaskMap = new ConcurrentHashMap<>();
MultiDownLoaderListener multiDownLoaderListener = new MultiDownLoaderListener(mDownCallBackListener);
if (downLoadEntity.multiList != null && downLoadEntity.multiList.size() != 0) {
for (int i = 0; i < downLoadEntity.multiList.size(); i++) {
DownLoadEntity entity = downLoadEntity.multiList.get(i);
//当前分支是否下载完成
if (entity.downed + entity.start > entity.end) { continue;
}
DownLoadTask downLoadTask = new DownLoadTask.Builder().downLoadModel(entity).downLoadTaskListener(multiDownLoaderListener).build();
executeNetWork(entity, downLoadTask, downLoadTaskMap);
}
} else {
//文件不存在 直接下载
createDownLoadTask(downLoadEntity, NEW_DOWN_BEGIN, downLoadTaskMap, multiDownLoaderListener);
}
}复制代码
map是内存缓存,之前就提过了,我们用Taskprivate Map<String, Map<Integer, Future>> mUrlTaskMap = new ConcurrentHashMap<>();
保存缓存信息,String是url,Map 是当前url下的任务,为啥又用个Map?因为可能是多线程啊!Integer,下载任务的唯一ID,这里是数据库主键,Future不了解的同学请自行百度,这就是下载任务。
如果有下载记录,就找未完成的生成DownLoadTask, executeNetWork就是加入线程池。如果没有下载记录,就是新文件,createDownLoadTask创建下载任务。
createDownLoadTask
127-141 如果下载任务大于多线程下载的分割值,切成多段进行下载。else 单线程下载。
好了 大概的流程到这里就结束了,还差什么?Task任务回调,主线程回调,这些代码没有贴出来,大家自己去发现吧。这里用了代理模式,还有很多的多线程数据安全方面的代码。下载Error重置下载机制,判断下载是否真正结束机制。对缓存的操作,map套map的增删改查。
总结
到这所有的多线程下载和断点续传就结束了,其实写作过程是痛苦的,但是到结束还是很欣慰的,相信您从开始看到这篇结束,整个项目的流程您是了解的,怎么定制,怎么修改bug应该也没有问题了,毕竟思路有了,就差不停的实践了,对吗?
我希望这篇文章再思路上可以帮助到您,那也是我的初衷啊!
下篇文章我会整理封装的支持上拉,下拉,可以添加Head的RecycleView。
最后,感谢私信过我,鼓励过我,打赏过我的朋友,谢谢你们的支持。
GitHub地址
我希望大家可以积极fork,一起修改,如发现问题,欢迎反馈。
微信:hly1501