iOS分片下载器(一)

因为目前项目里的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的介绍,有这么一段:

With the URLSession API, your app creates one or more sessions, each of which coordinates a group of related data transfer tasks. For example, if you’re creating a web browser, your app might create one session per tab or window, or one session for interactive use and another for background downloads. Within each session, your app adds a series of tasks, each of which represents a request for a specific URL (following HTTP redirects, if necessary).

所以,我们是不是就可以简单的设计为,每个下载操作(operation)都持有一个session实例?

对于这个问题,目前查到的资料里众说纷纭。有的认为,为了避免资源浪费,还是用单个的公有session实例为好。有的又认为,既然没有其他更加确切的理由,那么对于硬件资源很丰富的现在,不必去节省那么一丁点的资源,直接每个操作新建一个session实例就好了。

那么对于我们这个下载器,虽然因为下载任务本身频度低、耗时长的特性,即使每个下载操作(operation)都新建一个session实例,也并不会造成太多的时间、空间浪费。但考虑到session实例,同时在管理着cookie和cache,所以谨慎起见,我们还是使用唯一公用的session实例吧。

3.3 如何实现多任务并发

首先,因为我们现在需要做的,是一个分片下载器,所以此时的多任务并发,就存在着两个维度,即多个资源对应的下载操作(operation)并发进行,和同个下载操作(operation)中,并发多个分片的下载。因此毫无疑问的,我们得用到多线程。于是,我们就有个下面几个选择:

  1. 每个URL对应的下载操作拥有一个自己的串行operationQueue,同时拥有一个私有的并行netRequestQueue执行,用于执行分片下载操作。
  2. 每个URL对应的下载操作拥有一个自己的串行operationQueue,而其分片下载操作,在另一个公用的并行netRequestQueue内执行。
  3. 每个URL对应的下载操作,都在一个公用的串行operationQueue内执行,而其分片下载操作,在另一个公用的并行netRequestQueue内执行。

其中方案1是最为简单直接的,不过也存在着可能导致线程数过多的问题。

至于方案2,因为所有的分片下载操作,都放入了一个公用的netRequestQueue内执行,所以想要实现暂停操作就相对比较复杂。

还有方案3,虽然能解决方案1中存在的线程数过多的问题,但还是无法规避实现暂停操作相对比较复杂的问题。

不过因为我们也刚好需要实现并发数控制的能力,所以,我们最终还是选择了方案1的线程设计方式。最后,简单计算下采用这种方案的最大并发能力:如果限制下载操作的最大并发量为5,那么所有分片下载的最大并发量就是5*4=20。

同时也计算下最大线程数:5*4=20。

3.4 如何实现暂停/继续下载

因为支持了整体和分片下载两种能力,所以暂停/继续操作,就需要不仅仅是暂停NSURLSessionDownloadTask,同时也要暂停下还没有执行的分片下载操作。这一点,可以通过直接暂停netRequestQueue实现。至此,想要暂停一个下载任务,具体的操作流程,就是:

  1. 首先不再向netRequestQueue中增加新的operation
  2. 然后再挂起netRequestQueue,使得已经存在于队列中的,处于等待状态的operation不再执行
  3. 最后,我们还需要拿到正在运行中的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大小了。