因为目前项目里的HTTP下载器太过简陋,所以想要封装固化一个稍微设计、性能好些的自用,然后就想不如干脆一步到位,整个支持断点续传、支持分片下载的下载器得了。
而至于什么是断点续传,什么是分片下载,举两个例子:
当你下载一个100MB大小的文件时,这次下载了50MB以后暂停了,等到下次再开始下载任务的时候,可以继续接着下载后面的50MB,而不用从头开始,这就叫断点续传。
而如果对于这个100MB大小的文件下载,我们不是整个的去单次下载整100MB,却是把它分成100个1MB大小的任务,最后在所有任务都完成以后,把这些1MB大小的文件,都拼凑到一起得到最终的100MB大小文件,这就叫做分片下载。
说到这里,就出现了个问题。即,如果一个下载器支持了分片下载,那它不就等于是支持了断点续传吗?对于这一点,可以尝试把分片下载中的分片大小,设置成1~10KB左右以后去理解。
因此,最后这个分片下载器的设计目标,就从支持断点续传和分片下载,变成了只支持分片下载。
1. 原理
既然我们做的是HTTP分片下载器,那么HTTP协议本身支不支持分片,或者说是带有range的请求呢?当然是支持的。下面是HTTP/1.1协议中对于Range
参数的说明。
因此,我们就可以通过在HTTP请求中,添加Range
参数,来进行对某一分片数据的下载。不过需要注意的是,文档中也说明了,并没有强制要求所有服务器都支持Range
参数,所以我们还得判断当前服务器是否支持Range
参数。而判断方法,协议中同样也给出了答案。
2. 设计目标
在开始设计之前,我们得先定下我们的设计目标,也就是这个下载器需要支持的功能:
- 多任务并发
- 最大并发任务数控制
- 整体/分片下载
- 分片大小可调
- MD5校验
- 下载状态回调
- 下载进度回调
- 暂停/继续下载
- 重复任务合并
- 缓存管理
- 失败重试
- 后台下载
3. 代码设计
针对上一节的设计目标,我们先进行概要设计。下面是概要的流程图:
在进行模块划分之前,我们需要回顾一下上一节中的设计目标。
3.1 如何实现重复任务合并
多任务的合并的意思,是指多个相同URL的下载任务,合并为同一个,这样就可以避免重复下载的问题。那么,为了能够实现这种能力,我们可以把下载任务划分为两部分:下载任务(task)
和下载操作(operation)
,其中每个URL对应一个下载操作(operation)
,而每个下载操作(operation)
又持有并管理多个下载任务(task)
。
而至于如何让每个下载任务(task)
,都能接收到下载状态、下载进度变化的事件消息,我们则可以在下载操作(operation)
中,向自己所持有的下载任务(task)
进行转发。
3.2 一个还是多个session
因为我们现在的实现,是基于NSURLSession
来做的,那么不可避免的,就遇到这么一个问题:到底该持有几个session实例?
苹果的开发文档中,对于NSURLSession
的介绍,有这么一段:
所以,我们是不是就可以简单的设计为,每个下载操作(operation)
都持有一个session实例?
对于这个问题,目前查到的资料里众说纷纭。有的认为,为了避免资源浪费,还是用单个的公有session实例为好。有的又认为,既然没有其他更加确切的理由,那么对于硬件资源很丰富的现在,不必去节省那么一丁点的资源,直接每个操作新建一个session实例就好了。
那么对于我们这个下载器,虽然因为下载任务本身频度低、耗时长的特性,即使每个下载操作(operation)
都新建一个session实例,也并不会造成太多的时间、空间浪费。但考虑到session实例,同时在管理着cookie和cache,所以谨慎起见,我们还是使用唯一公用的session实例吧。
3.3 如何实现多任务并发
首先,因为我们现在需要做的,是一个分片下载器,所以此时的多任务并发,就存在着两个维度,即多个资源对应的下载操作(operation)
并发进行,和同个下载操作(operation)
中,并发多个分片的下载。因此毫无疑问的,我们得用到多线程。于是,我们就有个下面几个选择:
- 每个URL对应的下载操作拥有一个自己的串行
operationQueue
,同时拥有一个私有的并行netRequestQueue
执行,用于执行分片下载操作。 - 每个URL对应的下载操作拥有一个自己的串行
operationQueue
,而其分片下载操作,在另一个公用的并行netRequestQueue
内执行。 - 每个URL对应的下载操作,都在一个公用的串行
operationQueue
内执行,而其分片下载操作,在另一个公用的并行netRequestQueue
内执行。
其中方案1是最为简单直接的,不过也存在着可能导致线程数过多的问题。
至于方案2,因为所有的分片下载操作,都放入了一个公用的netRequestQueue
内执行,所以想要实现暂停操作就相对比较复杂。
还有方案3,虽然能解决方案1中存在的线程数过多的问题,但还是无法规避实现暂停操作相对比较复杂的问题。
不过因为我们也刚好需要实现并发数控制的能力,所以,我们最终还是选择了方案1的线程设计方式。最后,简单计算下采用这种方案的最大并发能力:如果限制下载操作的最大并发量为5,那么所有分片下载的最大并发量就是5*4=20。
同时也计算下最大线程数:5*4=20。
3.4 如何实现暂停/继续下载
因为支持了整体和分片下载两种能力,所以暂停/继续操作,就需要不仅仅是暂停NSURLSessionDownloadTask
,同时也要暂停下还没有执行的分片下载操作。这一点,可以通过直接暂停netRequestQueue
实现。至此,想要暂停一个下载任务,具体的操作流程,就是:
- 首先不再向
netRequestQueue
中增加新的operation - 然后再挂起
netRequestQueue
,使得已经存在于队列中的,处于等待状态的operation不再执行 - 最后,我们还需要拿到正在运行中的operation对应的
NSURLSessionDownloadTask
实例,进行暂停操作
3.5 如何得知下载进度
对于整体下载的资源,NSURLConnectionDataDelegate
中已经提供了URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:
回调,可以方便的获取总的、已下载的字节数。而对于分片下载的资源,就需要我们自己记录下已经下载完成的分片数,以及总共的字节大小,然后就能加上回调结果,得到准确的下载进度。
3.6 如何实现失败重试
无论是对于整体还是分片下载的资源,目前的失败重试方案,都是等所有分片都下载结束后,重新下载一遍刚才被标注了下载失败状态的分片。至于整体下载的资源,则就当成整个下载操作中,只包含一个分片来处理。
3.7 模块划分
经过上面几节,我么就可以结合流程图中,划分出来几大模块,也就是相对应的几个类:
BAFileDownloader
: 总的下载器入口BAFileDownloadTask
: 下载任务BAFileDownloadOperation
: 下载操作,一个下载操作可以绑定多个相同URL的下载任务,以进行重复任务合并BAFileDownloadSession
: 下载会话,用来管理唯一的NSURLSession
,以及真正的下载工作BAFileDownloadThreads
: 管理下载器需用到的所有线程BAFileDownloadCache
: 管理本地磁盘中的文件缓存BAStreamFileMerger
: 合并多个分片文件以得到最终结果BAStreamFileMD5
: 计算文件MD5值
相应的类图及依赖关系如下所示:
时序图如下所示:
4. 代码实现
具体的代码实现,已经放到了git开源库BAFileDownloader,目前已经完成了第一版,实现了部分的设计目标:
- 多任务并发
- 整体/分片下载
- 分片大小可调
- MD5校验
- 下载状态回调
- 下载进度回调
- 重复任务合并
- 失败重试
因为是初版的原因,其中部分代码并未完全按照设计来实现,后期再进行调整。同时现在的设计,也可能会在后期的实现过程中,根据遇到的问题,进行适当的修改。
5. 遭遇的问题
在第一版的实现过程中,遇到的问题主要是性能方面的问题,存在于这几个操作中:
- 分片缓存管理
- 分片文件合并
- 分片大小的选择
5.1 分片缓存管理
在最初的代码里,分片缓存的管理,是通过一个plist文件,来记录所有分片的状态信息。但由于plist文件整体读取的特性,会导致大文件的分片状态信息文件太大,进而因此内存占用过高的问题。所以后期就弃用了plist的方式,转而通过SQLite进行记录和管理。
5.2 分片文件的合并
当所有的分片文件都下载完成以后,最简单的合并方式,就是新建一个NSData
实例,然后再把每个分片文件读取到内存中,并追加进刚才的NSData
实例。这种方法毫无疑问,会导致大文件的合并操作时,占用内存过高的问题。
所以就使用了NSFileHandle
来进行合并操作。但最终的实际测试发现,NSFileHandle
方式,依然存在内存占用过高的问题,可能其底层实现,依然还是简陋的NSData
吧。
不过最后还是找到了解决办法,即仿照网络底层的方式,采用NSStream
,以I/O口的高占用,来换取内存空间的低占用。最终的测试结果表明,这种方案,可以把内存占用控制在极低的水平。
5.3 分片大小的选择
虽然设计目标中,包含有分片大小可调的能力,但我们还是需要设定一个默认大小。而这个大小该怎么定,就是个问题了。首先,我们希望每个分片能尽可能的小,这样单个分片下载的失败,对整体下载进度的影响就会尽可能的小。同时,尽可能小的分片,也能够更好的兼容后期可能扩展的QOS能力。但是,能不能就这么直接把分片设置的特别小,1KB每片甚至更小呢?当然是不可以的,因为我们还得考虑到,HTTP链接的建立和断开操作耗时。下面是一个简要的HTTP链接建立、数据传输、断开流程。
可以看出,每个HTTP链接的建立和断开,都需要经过三次、四次握手操作,因此如果我们把分片大小设置的特别小,那么相较于整体下载时只需要建立一次链接,分片下载时,浪费在建立、断开链接操作上的时间是非常巨大的。
所以,分片既不能过大也不能过小。那么到底这个分片得多大?确切的答案肯定不是一个简单的写死不变的大小,而应该是根据网络质量动态变化着的。那么这个动态智能调整分片大小的策略,暂时还没有定下来,所以目前的代码里,就默认设置成10KB大小了。