0%

对 FKDownloader 的完全重构

前言

当初编写 0.x 版本时, 尚未考虑过多逻辑, 整体架构就是简单的封装系统逻辑, 导致在后期频频出问题, 而打补丁只会出更多的问题, 毕竟底子并没有打好, 所以就起了重构的心思.

灵感

刚一开始想参考一下 aria2 这个大佬级下载软件, 但奈何这软件涉及的下载方式过于复杂, 而且 iOS 的封闭式导致大部分逻辑不能使用, 强行借鉴是没有好下场的, 只好另作打算.

之前因为需要为自己的 App 编写后台, 就接触到了 Python, 当时为了将一些资源整合到自己的数据库中, 就发现了 Scrapy 这个大名鼎鼎的爬虫框架, 而这个框架的逻辑恰好符合我的想法.

根据 iOS 下载系统的特性, 下载框架会按下图的流程进行制作:

至于为什么要这么做, 主要还是得看制作一个后台下载框架都有什么难点.

后台下载框架主要难点与关键点

  1. 下载任务和下载流程全部归系统管理
    首先, iOS 后台下载的主要流程基本为: 创建 NSURL -> 创建 NSURLRequest -> 使用 SessionNSURLRequest 生成 NSURLSessionDownloadTask, 之后的下载信息, 如下载进度, 下载失败和下载成功等事件可以通过设置代理后获得.

    然后, 如何在重启 App 后获得下载任务? 可以通过 -[NSURLSession getTasksWithCompletionHandler:] 获取.

    但这个方法真的能获取所有请求吗? 官方文档有解释:

    The arrays passed to the completion handler contain any tasks that you have created within the session, not including any tasks that have been invalidated after completing, failing, or being cancelled.

    显而易见, 对于已经失效的任务是获取不到的, 所以拥有一套任务记录模块是必须的.

    还有一种情况, 链接重定向, 这会导致拿到 NSURLSessionDownloadTask 后, 不知道对应那个下载链接, 这种情况的解决方案有几种, 可以监听 NSURLSessionDownloadTaskcurrentRequest 属性来记录最终下载链接, 也可以使用 taskDescription 属性放置一个标记, 当然, 也许还有其他方法.

  2. 获取任务进度的自由度
    对于 NSURLSessionDownloadTask, 有 countOfBytesReceivedcountOfBytesExpectedToReceive 这两个属性可以获取已下载字节长度和预计全部数据长度, 还有一些新的属性也可以获取这些信息.

    显然, 对于单一任务来说, 这已经足够了, 但对于多个任务, 并且可以任意分组的情况来说, 那就需要一个统一记录进度的模块了.

  3. 奇奇怪怪得系统 BUG
    这些个 BUG 虽然都有应对方法, 但部分应对方法会影响一些逻辑.

    比如在 iOS 12/12.1, iPhone 8 以下机型会出现 NSURLSessionDownloadTaskcountOfBytesReceivedcountOfBytesExpectedToReceive 属性在进入后台, 再回到前台后, 不再变更数值的问题, 需要来一个暂停/恢复的改变才会继续工作, 这就需要框架有一个监听前/后台切换的逻辑, 去处理这个问题.

    还有那个恢复数据有问题导致恢复下载失败的问题, 需要在每次恢复下载时做一次修复处理, 对于一些需要将恢复数据保存到本地的逻辑来说, 就需要特殊逻辑来使这些逻辑共存.

    对于 Apple 来说, 后台下载这些逻辑明显是不想让开发者过多干涉, 所以在出现一些问题时, 开发者很被动, 只能找着法子各种规避了.

  4. 任务列表
    每个带有下载功能的 App 基本上都会有下载列表, 但在下载框架里, 最好不要现实下载列表功能, 这毕竟属于 App 的业务逻辑.

    而且因为业务影响, UI 影响, 会导致数据结构千奇百怪, 第三方框架没必要兼容, 也兼容不了.

    所以任务信息相关的模块最好保持懒加载模式, 需要的时候直接拿来用, UI 相关的数据只保留最基本的数据, 其他信息可以由 App 端实现.

框架模块

接下来就是根据架构流程图创建各种模块了, 在 FKDownloader 中有两种类型的模块, 一种公开类型, 一种私有类型.

公开模块为面向用户的模块, 包含 FKBuilder(构建任务), FKMessager(获取信息), FKConfigure(下载配置), FKControl(控制任务), FKMiddleware(中间件) 五个模块.

私有模块为框架内部使用模块, 包含主要模块: FKEngine, FKCache, FKObserver, FKScheduler, FKSessionDelegater.

还有辅助模块: FKSingleNumber, FKFileManager, FKLogger, FKCoder, FKMIMEType.

还有一些数据模型: FKObserverModel, FKCacheRequestModel, FKResponse.

它们将按照流程图来处理下载任务, 大体上来讲, FKBuilder 是输入, FKMessager 是输出, FKEngine 是处理, FKControl 是控制, FKCache 是储存.

框架模块详细讲解

首先, 是公开模块.

FKBuilder

该模块主要对应 创建NSURLRequest 阶段, 独立出来是为了更好的控制构建 NSURLReqest 的过程.

鉴于 NSURLReqest 的属性和方法会随着系统更新变得越来越多, 越来越复杂, 且自定义不会对生成 NSURLSessionDownloadTask 流程产生影响, 所以 FKBuilder 直接继承于 NSMutableURLRequest, 用户可以像操作 NSMutableURLRequest 一样操作 FKBuilder. 对于用户传入的 URL 是否合法, 也可在初始化时进行校验.

FKBuilder 需要显式执行预处理, 这样才能将任务加入队列中.

FKMessager

该模块对应请求信息收集逻辑, 比如进度、状态、错误等.

基本上所有有关下载的业务逻辑, 添加下载的界面和查看下载进度的是分开的, 所以, FKDownloader 就干脆将任务信息收集相关逻辑全部独立出来, 同时也可以更好的对应列表样式的信息获取.

FKConfigure

一个下载框架如果不能自定义配置, 那就没有灵魂.

查看 NSURLSessionConfiguration 的官方文档就会发现系统提供的参数巨多, 而且还包含了新版本特性, 这就导致对外开放什么属性/方法成了难题, 多了不可控, 少了又没有高度自定义那味, 所以 FKConfigure 直接提供了一个 NSURLSessionConfiguration 模版, 化为属性, 只把其中的能否使用蜂窝 allowsCellularAccess 默认设置为允许, 其他通通由用户自定义.

至于 NSMutableURLRequestNSURLSessionConfiguration 的部分属性冲突, 这部分官方已经在注释里讲的很明白了, 用户可自行斟酌.

FKControl

这就是个控制任务的, 激活、暂停、继续、停止、删除, 没什么好说的, 独立出来只是为了面向用户, 不用跟其他私有模块产生冲突, 而且多出一层, 可操作性也会多一层.

FKMiddleware

中间件模块, 我也就在爬虫框架和后端框架中见过, FKDownloader 中有这种模块主要是为了有一种可以统一处理的方式.

目前该模块只在生成 NSURLSessionDownloadTask 前, 下载成功/失败后会有介入, 前者是为了诸如请求统一加签、绕过浏览器限制等操作, 后者可以当成 NSURLSession 代理中下载成功/失败的回调即可.

更多的操作可根据业务自行调整.



再来是私有模块.

FKEngine

该模块基本就是框架运转的核心.

在一般下载逻辑中, 会为每个任务创建一个计时器, 实现进度信息分发逻辑, 但这一块儿其实用不了那么多, 还会因为管理不过来出现问题(0.x版本就有这类问题), 所以在 FKEngine 中只有两个个计时器, 只为了更简洁的操作, 毕竟在实际业务中, 这些进度条各走各还是一起走都无所谓,

计时器主要完成以下任务:

  1. 检查任务队列, 进行下一个任务

首先, FKBuilder 的预处理会将生成的任务信息模型(FKCacheRequestModel) 存入 FKCache 的缓存队列中, 但不开始任务, 也不创建 NSURLSessionDownloadTask, 这是前提, 与计时器无关.

然后, 计时器被触发后就会检查队列中正在执行的任务是否超过设置, 超过了就什么也不做, 没超过就开始用任务信息创建 NSURLSessionDownloadTask, 进行下载, 期间走过中间件流程, 本地信息缓存流程, 监听信息流程等等流程.

执行任务计时器的间隔为 1s, 不可自定义.

  1. 分发任务信息

在使用 FKMessager 时, 回调会被缓存, 计时器被触发后将会轮询执行这些回调

以上任务都会在计时器触发后执行. 默认情况下, 这个计时器是停止的, 需要用 FKConfigure 来激活, 这是为了保证在 NSURLSession 被创建后再执行任务.

计时器重复时间默认设定为 1s, 这个时间刚刚好, 少了太频繁, 多了感觉慢.
也支持用户自定义速率, 目前 1 倍速率为 0.2 秒, 倍率在 1 ~ 10 倍区间可自定义.

除此之外, 应用启动后还会去查询后台已经存在的下载任务, 将这些任务添加到缓存中, 让它们和其他任务一致.

基本上其他模块只是制造信息, 输出信息和保存信息, 而 FKEngine 则是让整个框架活过来.

FKCache

主要负责信息缓存, 任务的信息, NSURLSessionDownloadTask 等等.

独立的缓存模块是必须的, 要缓存的信息有很多, 集中起来更容易管理.

FKDownloader 中, 对任务有一个主要理念: 任务即文件, 文件即任务, 每一个任务都有一个属于自己的唯一标识, 标识与用户输入的链接息息相关, 也和本地缓存有着千丝万缕的联系.

FKDownloader 中的文件就是 FKCacheRequestModel 对应的归档文件, 这个模型提供的信息有:

1
2
3
4
5
6
7
8
9
10
11
@property (nonatomic, strong) NSString *requestID; // 请求标识, SHA256(URL)
@property (nonatomic, strong) NSString *requestSingleID; // 唯一请求标识, SingleNumber_SHA256(URL)
@property (nonatomic, strong) NSString *idx; // 唯一顺序编码
@property (nonatomic, strong) NSString *url; // 原始请求链接
@property (nonatomic, strong) NSMutableURLRequest *request; // 请求
@property (nonatomic, assign) FKState state; // 请求状态
@property (nonatomic, assign) int64_t receivedLength; // 接收的数据长度
@property (nonatomic, assign) int64_t dataLength; // 数据长度
@property (nonatomic, strong) NSString *extension; // 文件后缀, `.*`
@property (nonatomic, strong, nullable) NSData *resumeData; // 恢复数据
@property (nonatomic, strong, nullable) NSError *error; // 错误

基本上可以构成/恢复任务的信息都在里面, 每一个任务都有自己的文件夹保存这些信息, 分而治之有利于管理, 0.x版本中都是所有任务都在一个文件中, 不管从性能上看, 还是管理上看, 都有很大的问题.

FKObserver

众多任务需要监听的流程太过繁杂也太过分散, 系统 BUG 还导致这些监听还需要重新添加, 这就更分散了. 而获取进度信息在业务上来讲并不频繁使用, 这些监听到的信息全放在任务信息模型里也不合适, 那么, 直接独立出来成模块岂不美哉.

FKObserver 以专门监听 NSURLSessionDownloadTask 而生, 所有任务的进度信息都在这里.

FKObserver 使用 FKObserverModel 保存进度信息, 基本信息如下:

1
2
3
4
5
6
7
8
@interface FKObserverModel : NSObject

@property (nonatomic, strong) NSString *requestID; // SHA256(Request.URL)
@property (nonatomic, assign) int64_t countOfBytesReceived;
@property (nonatomic, assign) int64_t countOfBytesPreviousReceived;
@property (nonatomic, assign) int64_t countOfBytesExpectedToReceive;

@end

简约而不简单, 并且和 FKMessager 配合完美, 一个对外, 一个对内.

FKScheduler

FKBuilderFKEngine 之间的模块, FKControl 的实现, 主要任务如下:

  1. FKBuilder 的预处理逻辑进行了更细节的处理. 如创建任务信息文件, 添加内存/本地缓存, 忽略已存在任务等.
  2. 实现 FKControl 的操作, 激活、暂停、继续、取消、删除.
FKSessionDelegater

实现 NSURLSession 的代理, 没啥好说, 单独摘出来是因为代理方法还是很多很复杂的, 为了之后更好的扩展, 这样更好一些.



还有一些辅助用模块

FKSingleNumber

在执行下一个任务时, 哪个才是下一个? 按添加顺序可不一定准, 所以 FKDownloader 直接使用 stdatomic.h 中的 atomic_ullong 来创建一个不受线程影响的原子数, 再让它被获取时自增.

FKCacheRequestModelrequestSingleID 就是原子数和下载链接的哈希值拼接出来的.

当然, 从业务上来讲, 任务的执行顺序是否按照列表所示顺序依次进行好像并不怎么重要.

FKFileManager

文件管理的封装, 主要负责创建/删除任务对应的文件/文件夹.

FKLogger

辅助信息日志, 这倒是没啥好说的, 只是为了更方便调试, 信息只会在 DEBUG 环境下执行.

FKCoder

URL 编解码.

先说编码, 有一个问题便是用户传入的下载链接是否已经编码过, 这个可以循环解码至和上一个结果一致时停下, 这个问题不算太大. 但 URL 可能有带有 emoji, fragment 的情况, emoji 可以用系统的 URLQueryAllowedCharacterSet 直接处理, 但 fragment 就会编码错误, 这时就需要分段处理.

再说解码, 这个就简单了, 直接 stringByRemovingPercentEncoding 走起.

FKMIMEType.

既然是文件下载, 那基本上都有后缀吧, 直接从 URL 里获取是不现实的, 毕竟有的链接是加签的, 后缀是不存在的, 所以要用 Response 中的 Content-Type 也就是 MIMEType 来转为后缀名.

系统可以讲 MIMEType 转为后缀, 但并不全面, 所以需要将其他常用的加入转换列表中, 如果实在没有对应后缀名, 就用 unknown 为后缀名.

关于后台下载的 Tips

后台下载功能中也存在一些需要知道的东西.

后台任务由系统启动后的各种限制

先说结论, 目前没有什么好的方法去绕过. 系统的限制基本上有以下几种:

  1. 限制下载速度
  2. 限制何时启动下一个任务的时间
  3. 限制任务启动数量
  4. ….

总的来说, iOS 为了达到完美的运行并且不会影响系统的稳定性, 后台下载的内核做了非常多的限制, 而且为什么有这些限制, 又是怎样做到的, 官方并没有明说, 只能从这里看出一个重点信息, NSURLSession Background Download 是系统包揽的, 开发者最好不要深入研究.

测试后台下载流程

这里可以看出, 测试时一定要严格遵守以下几点:

  1. Test on a real device, not the simulator. 在真机上测试, 而不是模拟器.
  2. Run your app from the Home screen rather than running it from Xcode. 从主屏幕上运行, 而不是 Xcode 直接运行.
  3. Do not use force quit to test the ‘relaunch in the background case’. 不要从任务管理中强制退出 App 来模拟后台下载流程中的强制中断 App 逻辑, 而是在合适的地方使用 exit() 来退出 App.

参考

  1. MIMEType IANA
  2. MIMEType to Extension
  3. NSURLSession’s Resume Rate Limiter
  4. Testing Background Session Code