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

接触 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,265评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,078评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,852评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,408评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,445评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,772评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,921评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,688评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,130评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,467评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,617评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,276评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,882评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,740评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,967评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,315评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,486评论 2 348

推荐阅读更多精彩内容

  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    阳明先生_X自主阅读 15,969评论 3 119
  • 曾经,耳听过,眼见过,经历过,很多人生。大家都是芸芸众生里的小人物,然而对于身边的人来说,却是了不起的光亮。 ...
    愈0423阅读 651评论 0 0
  • 有谁是二十多岁才学做饭的? 求教克服做饭的心理障碍~ 如果有人看见。
    甘愿化身为红蝴蝶阅读 182评论 0 0
  • 俺的厨房原先有8根T12荧光灯管,镇流器都先后坏掉了,去店里看了下,镇流器也不便宜,索性还是上LED灯管了。 目前...
    石头_老吴阅读 1,890评论 0 0
  • 在生活的道路中,最大的绊脚石就是没作为。纵然有千般想法,万般支援,你不行动,不去使用这些都不是自己的。
    七月shu阅读 184评论 0 0