一个系统BUG引发的血案 -- FKDownloader

3,186 阅读11分钟

接触 BUG

  前几天突然收到一朋友发来的消息, 说是在 iOS 12 上遇到了一个新的 BUG, 问我怎么看? 我说新系统遇到 BUG 不是很正常吗? 大概是个什么情况?
  经过朋友说明, 大概是这么个现象: 他用了一个第三方下载管理器进行视频下载, 明明是设置了后台下载的, 但 App 一推到后台再回到前台, 下载进度就不动了, 但任务依然还在继续下载. 系统是 iOS 12, 手机是 iPhone 7.

BUG 详情

  刚一开始还以为第三方在进度处理方面写的有问题, 但我把这个第三方的 Demo 下载运行后, 发现这根本不是第三方问题, 而是系统问题, 系统代理 -[NSURLSessionDownloadTask URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:] 根本没有被调用, 所以下载进度根本就无法继续计算.
  然后我改为使用 KVO 监听 NSURLSessionDownloadTaskcountOfBytesReceivedcountOfBytesExpectedToReceive 属性来计算当前下载进度, 但很遗憾, 这两个值在重回前台后就没在继续变化, 初步认定是系统在处理数据接收时出现了异常, 导致省略了值的改变, 还有顺便躺枪的进度代理.
  上一次遇到这种系统犯法失效的 BUG 还是在 iOS 11.1/11.2 上, 当时开发录屏直播, 系统方法 -[RPBroadcastSampleHandler processSampleBuffer:withType:] 没有被调用, 直接坑掉了一个大功能模块, 但幸好, 这一回遇到的 BUG 不算严重, 解决方法还是有的.

开始测试

  这回的进度 BUG 在虚拟机上是不会出现的, 必须真机, 而且经过测试, 发现只在 iOS 12/12.1, iPhone 8 以下才会出现.
  在测试时还发现 App 完全退出后, 后台下载任务会直接取消, 但是带有恢复数据.
  进入前台后, 手动进行 暂停->继续 操作后, 代理/KVO 就会继续工作.

尝试修复 BUG

  既然手动 暂停->继续 可以修复 BUG, 那只要用代码重现一遍就可以了吧? 别急, 事情没有那么简单.
  直接在 -[AppDelegate applicationWillEnterForeground:] 开始遍历所有下载任务, 都执行一遍 暂停->继续 操作, 这个方法很简单, 很粗暴, 但, 这不管用!
  那么使用 -[NSURLSessionDownloadTask cancelByProducingResumeData:] -> -[NSURLSession downloadTaskWithResumeData:] 代替 暂停->继续 呢? 不错, 意识到当前的 NSURLSessionDownloadTask 可能存在脏数据是个进步, 但, 这依然不管用!

系统的BUG

  最后的最后, 还是测试出来了, 必须在 [AppDelegate applicationDidBecomeActive:] 里面遍历使用 取消->恢复 才能成功

关于下载器的轮子

  朋友说你写一个下载第三方吧, 现在的下载器没几个好用的. 当时我还不以为然, 说是 GitHub 上那么多轮子, 不缺我这一个, 而且就算写了也不一定比热门的好, 实在不行还有 AFNetworking 当打底的.
  我在很久以前我就打算写一个下载器, 想要重点实现单文件多线程分片下载, 当时数据流下载已经写完了, 数据拼接也基本完成了, 准备支持后台下载才发现, NSURLSessionDataTask 不支持后台下载!!! 好吧, Apple🐂🍺🤪
  我也看了我朋友用的 XXDownload, 虽然 star 少了点, 但这个刚好符合需求. 虽然在实现中大范围使用下划线变量, 而且还在单例上使用代理, 感觉一口老血卡在喉咙里, 但至少改改还是能用的, 毕竟这种第三方也就是提供个框架而已.
  而在 GitHub 上, 已经有一堆项目停止维护了, 还在更新的, 因为任务持久化使用了数据库, 引用了其他第三方, 可能导致库冲突, 而那些还在持续维护的纯净版又无法适应一些需求场景.
  其中 HWIFileDownload 就属于一直在更新, 也很纯净的第三方, 一般项目使用足以胜任. 但在某些特殊需求上就有点相形见绌了, 比如支持时效性下载链接, 持久化任务列表, 文件校验, 对恢复数据深度处理等.
  当然, 这都不是重点, 重点是后台下载场景太稀少了, 自己随手写一个都可以勉强用, 还要什么第三方, 这种吃力不讨好, 还基本没有 Star 的操作我是不会做的.

真香

FKDownloader -- 最终还是写了

既然都写出来了, 那就必须尽量完美, 除了修复/规避 iOS 的 BUG, 当然还需要支持一些特别的需求.
先列一下 FKDownloader 的整体结构:

  • 主类

    • FKDownloadManager

      • 自加载, 不必显式调用创建单例
      • 不可继承, 唯一存在
      • 管理 Task, 进行增删查操作
      • 开始/暂停/恢复/取消 Task, 但实现与状态过滤全权由 Task 实现
      • 所有任务下载进度
      • 在 AppDelegate 处理部分功能, 如后台下载, 加载任务归档, 解决 iOS BUG 等
    • FKConfigure

      • 统一管理特殊配置
      • 设置 Session Identifier
      • 设置是否为后台下载
      • 设置是否自动清理已完成/失败任务
      • 设置是否自动开始任务, 针对载入本地归档任务时
      • 自定义请求超时时间
    • FKTask

      • 开始/暂停/恢复/取消的具体实现
      • Block/Delegate/Notification 的发起者
      • 校验文件
      • 下载速度/预计剩余时间
      • 可添加附带信息, 包括保存文件名, 校验信息, 自定义请求头等信息
  • 辅类

    • FKResumeHelper
      • 解包/封包恢复数据
      • 修复 iOS 特定版本中错误的恢复数据
      • 更新恢复数据的 URL
  • 其他

    • FKDefine: 声明枚举, C 方法, 字符串常量
    • FKReachability: 网络状态检测与监听
    • FKDownloadExecutor: 统一处理系统代理
    • FKTaskStorage: 管理任务的归解档
    • FKHashHelper: 计算 Hash
    • FKSystemHelper: 获取设备版本, 系统版本

FKDownloader 不依赖其他任何第三方, 保持纯净性, 其中的方法大部分都偏向于对外简单, 对内复杂, 而且尽量避免高耦合.

FKDownloader 支持与安装

必须 iOS 8 以上, 使用 ARC. 支持 CocoaPodsCarthage 安装. 如有其他需求, 可直接将 FKDownloader 文件夹直接放入项目中.

FKDownloader 特点

  • 自加载
      使用 +[NSObject load] 加载单例, 不必再显式调用来创建单例. 因此可以提前监听 AppDelegate 通知, 修复进度 BUG 将可以自处理, 不必显示调用.

  • 重启 App 时恢复下载中任务进度
      也就是开始一个后台下载任务, 完全退出 App 后再次运行 App, 需要重新拿到下载任务的进度与状态, 以达到 UI 上显示任务还在运行中的效果.
      实现这个功能的第三方我只见到一两个, 这其中的重点是 -[NSURLSession getTasksWithCompletionHandler:] 这个系统方法, 它可以将带有 identifierNSURLSession 中所有的后台任务获取到.

  • 支持时效性 URL
      获取到 FKTask 后, 可直接通过 -[FKTask resumeFilePath] 获取 ResumeData 保存路径, 之后用 +[FKResumeHelper updateResumeData:url:] 拿到更新后的 ResumeData, 再保存后即可.
      也可以直接使用 -[FKTask updateURL:] 直接更新, 但对进行中的任务无效, 且必须已存在恢复数据.
      FKDownloader 只使用 URL 的 scheme://host/path 创建标识符, 所以参数可以随意修改, 如果是使用请求头完成过期操作的, 可使用自定义请求头.

  • 根据网络状态执行特定操作
      检测当前网络状态, 如果没有网络则暂停进行中任务, 取消等待中任务.
      当恢复网络时, 就会将因为无网络而中断的任务继续下载.

  • 使用 NSCoding 持久化下载任务, 不依赖数据库
      直接保存任务信息, 包括 URL, 任务状态, 保存文件名, 校验信息, 自定义请求头, 文件总大小, 已接收字节数等信息, 保证重启 App 后 UI 信息和退出 App 前保持一致.
      代价就是不能高度自定义要保存的数据, 但 FKTask 向外暴露的属性完全满足外接式数据处理需求, 也可以使用项目中已存在的数据库进行自定义管理.

  • 预见性处理状态/进度
      设置代理时会将当前所有协议方法触发一遍, 保证 UI 获取的信息为最新.

  • 任务状态/进度的监听
      可以自由使用 Block/Delegate/Notification 获取, 最大化覆盖应用场景.

  • 自定义任务附加信息
      目前支持保存文件名, 文件校验值, 自定义请求头.

  • 支持 URL 中参数可变
      FKTask 只使用 scheme://host/path 创建标识符, parameters 信息将直接忽略, 以识别时效性 URL 下载任务.

  • 精细任务状态
      无/预处理/等待/进行中/完成/取消/暂停/恢复/校验/错误, 基本上都有 willdid 双重级别.

  • 文件校验
      支持 MD5, SHA1, SHA256, SHA512, 但校验特大文件时, CPU占用过大, 所以默认配置为关闭验证.

  • 兼容 Swift   支持在 Swift 项目中进行使用.

FKDownloader 简单使用

  • 任务管理
// 添加任务, 但不执行, 适合批量添加任务的场景
[[FKDownloadManager manager] add:@“URL”];

// 添加任务, 并附加额外信息, 目前支持 URL, 自定义保存文件名, 校验值, 校验类型, 自定义请求头
[[FKDownloadManager manager] addInfo:@{FKTaskInfoURL: url,
                                       FKTaskInfoFileName: @"xCode7",
                                       FKTaskInfoVerificationType: @(VerifyTypeMD5),
                                       FKTaskInfoVerification: @"5f75fe52c15566a12b012db21808ad8c",
                                       FKTaskInfoRequestHeader: @{} }];

// 开始执行任务
[[FKDownloadManager manager] start:@“URL”];

// 根据 URL 获取任务
[[FKDownloadManager manager] acquire:@“URL”];

// 暂停任务
[[FKDownloadManager manager] suspend:@“URL”];

// 恢复任务
[[FKDownloadManager manager] resume:@“URL”];

// 取消任务
[[FKDownloadManager manager] cancel:@“URL”];

// 移除任务
[[FKDownloadManager manager] remove:@“URL”];

// 设置任务代理
[[FKDownloadManager manager] acquire:@“URL”].delegate = self;

// 设置任务 Block
[[FKDownloadManager manager] acquire:@“URL”].statusBlock = ^(FKTask *task) {
    // 状态改变时被调用
};
[[FKDownloadManager manager] acquire:@“URL”].speedBlock = ^(FKTask *task) {
    // 下载速度, 默认 1s 调用一次
};
[[FKDownloadManager manager] acquire:@“URL”].progressBlock = ^(FKTask *task) {
    // 进度改变时被调用
};
  • 支持的任务通知
// 与代理同价, 可按照代理的使用方式使用通知.
extern FKNotificationName const FKTaskPrepareNotification;
extern FKNotificationName const FKTaskDidIdleNotification;
extern FKNotificationName const FKTaskWillExecuteNotification;
extern FKNotificationName const FKTaskDidExecuteNotication;
extern FKNotificationName const FKTaskProgressNotication;
extern FKNotificationName const FKTaskDidResumingNotification;
extern FKNotificationName const FKTaskWillChecksumNotification;
extern FKNotificationName const FKTaskDidChecksumNotification;
extern FKNotificationName const FKTaskDidFinishNotication;
extern FKNotificationName const FKTaskErrorNotication;
extern FKNotificationName const FKTaskWillSuspendNotication;
extern FKNotificationName const FKTaskDidSuspendNotication;
extern FKNotificationName const FKTaskWillCancelldNotication;
extern FKNotificationName const FKTaskDidCancelldNotication;
extern FKNotificationName const FKTaskSpeedInfoNotication;
  • 需要在 AppDelegate 中调用的
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler {
    
    // 保存后台下载所需的系统 Block, 区别 identifier 以防止与其他第三方冲突
    if ([identifier isEqualToString:[FKDownloadManager manager].configure.sessionIdentifier]) {
        [FKDownloadManager manager].configure.backgroundHandler = completionHandler;
    }
}

FKDownloader 处理的一些细节

  • ResumeData
      恢复数据在 iOS 10.0/10.1 中出现了格式错误, 官方在 iOS 10.2 中修复成功, 但为了兼容, 还是需要修复一番的, 具体解决方案在这里.
      而在 iOS 11 中, 因为多出了 NSURLSessionResumeByteRange 字段导致一些奇怪的问题, 可以使用 FKResumeHelper 先读取, 在删除字段, 然后封包, 也可自己进行删除, 目前 FKDownloader 已自行处理.
      虽然没有出错, 但在 iOS 12 中, ResumeData 的封包格式发生了改变, 现在可使用 +[NSKeyedUnarchiver unarchiveObjectWithData:] 直接进行解包, 现在可以使用 -[NSKeyedUnarchiver decodeTopLevelObjectForKey:error:] 方法, keyNSKeyedArchiveRootObjectKey 来进行解包(而系统默认的 keyroot, Apple 我不是很懂你啊😂), 但之前版本需要使用 +[NSPropertyListSerialization propertyListWithData:roptions:format:error:] 进行解包, 封包时也要注意区分.
      在 iOS 8 中, 因为 NSURLSessionResumeInfoVersion 版本过旧, 新版本的 NSURLSessionResumeInfoTempFileName 会被 NSURLSessionResumeInfoLocalPath 代替, 缓存文件路径将不再只是文件名, 而是文件路径, 需要注意, 但影响不大, 运行并无问题.
      
    Apple 就是可以为所欲为

  

  • 文件校验
      在下载一些大文件时, 为了保证文件完整性而需要进行文件校验, FKDownloader 可配置是否开启文件校验.
      其中, 使用 NSDataReadingMappedIfSafe 选项进行初始化 NSData, 以防止超大文件导致内存溢出.
      经过测试, 6G 大小的文件算出 MD5 需要 4~5秒, 内存占用 < 1M, 但因为 Hash 操作为计算密集型, 导致 CPU 占用 > 90%, 所以一般情况下, 下载小型文件时可开启文件校验, 但超大文件请酌情处理.

  • NSURLSessionDownloadTask
      在调用 -[NSURLSessionDownloadTask cancelByProducingResumeData:] 后, 虽然任务状态改变为 NSURLSessionTaskStateCanceling, 但在之后代理 -[URLSession URLSession:task:didCompleteWithError:] 中获取, 状态为 NSURLSessionTaskStateCompleted, 差点被坑的不轻, 所以目前状态管理完全由 FKTaskstatus 属性代劳.

  • 网络可达性 Network Reachability
      使用 官方文件 处理网络状态的检测与监听, 但官方的方式只适合真机运行, 在虚拟机中只可监听到失去网络的状态, 而再次连接网络的状态无法获取, 但在真机中所有状态都可监听, 所以测试网络状态时请使用真机测试.

FKDownloader 最佳实践

请查看运行 Demo