一個系統BUG引起的血案 -- FKDownloader

接觸 BUG

  前幾天忽然收到一朋友發來的消息, 說是在 iOS 12 上遇到了一個新的 BUG, 問我怎麼看? 我說新系統遇到 BUG 不是很正常嗎? 大概是個什麼狀況?
  通過朋友說明, 大概是這麼個現象: 他用了一個第三方下載管理器進行視頻下載, 明明是設置了後臺下載的, 但 App 一推到後臺再回到前臺, 下載進度就不動了, 但任務依然還在繼續下載. 系統是 iOS 12, 手機是 iPhone 7.html

BUG 詳情

  剛一開始還覺得第三方在進度處理方面寫的有問題, 但我把這個第三方的 Demo 下載運行後, 發現這根本不是第三方問題, 而是系統問題, 系統代理 -[NSURLSessionDownloadTask URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:] 根本沒有被調用, 因此下載進度根本就沒法繼續計算.
  而後我改成使用 KVO 監聽 NSURLSessionDownloadTaskcountOfBytesReceivedcountOfBytesExpectedToReceive 屬性來計算當前下載進度, 但很遺憾, 這兩個值在重回前臺後就沒在繼續變化, 初步認定是系統在處理數據接收時出現了異常, 致使省略了值的改變, 還有順便躺槍的進度代理.
  上一次遇到這種系統犯法失效的 BUG 仍是在 iOS 11.1/11.2 上, 當時開發錄屏直播, 系統方法 -[RPBroadcastSampleHandler processSampleBuffer:withType:] 沒有被調用, 直接坑掉了一個大功能模塊, 但幸虧, 這一回遇到的 BUG 不算嚴重, 解決方法仍是有的.ios

開始測試

  這回的進度 BUG 在虛擬機上是不會出現的, 必須真機, 並且通過測試, 發現只在 iOS 12/12.1, iPhone 8 如下才會出現.
  在測試時還發現 App 徹底退出後, 後臺下載任務會直接取消, 可是帶有恢復數據.
  進入前臺後, 手動進行 暫停->繼續 操做後, 代理/KVO 就會繼續工做.git

嘗試修復 BUG

  既然手動 暫停->繼續 能夠修復 BUG, 那隻要用代碼重現一遍就能夠了吧? 別急, 事情沒有那麼簡單.
  直接在 -[AppDelegate applicationWillEnterForeground:] 開始遍歷全部下載任務, 都執行一遍 暫停->繼續 操做, 這個方法很簡單, 很粗暴, 但, 這無論用!
  那麼使用 -[NSURLSessionDownloadTask cancelByProducingResumeData:] -> -[NSURLSession downloadTaskWithResumeData:] 代替 暫停->繼續 呢? 不錯, 意識到當前的 NSURLSessionDownloadTask 可能存在髒數據是個進步, 但, 這依然無論用!github

系統的BUG

  最後的最後, 仍是測試出來了, 必須在 [AppDelegate applicationDidBecomeActive:] 裏面遍歷使用 取消->恢復 才能成功數據庫

關於下載器的輪子

  朋友說你寫一個下載第三方吧, 如今的下載器沒幾個好用的. 當時我還不覺得然, 說是 GitHub 上那麼多輪子, 不缺我這一個, 並且就算寫了也不必定比熱門的好, 實在不行還有 AFNetworking 當打底的.
  我在好久之前我就打算寫一個下載器, 想要重點實現單文件多線程分片下載, 當時數據流下載已經寫完了, 數據拼接也基本完成了, 準備支持後臺下載才發現, NSURLSessionDataTask 不支持後臺下載!!! 好吧, Apple🐂🍺🤪
  我也看了我朋友用的 XXDownload, 雖然 star 少了點, 但這個恰好符合需求. 雖然在實現中大範圍使用下劃線變量, 並且還在單例上使用代理, 感受一口老血卡在喉嚨裏, 但至少改改仍是能用的, 畢竟這種第三方也就是提供個框架而已.
  而在 GitHub 上, 已經有一堆項目中止維護了, 還在更新的, 由於任務持久化使用了數據庫, 引用了其餘第三方, 可能致使庫衝突, 而那些還在持續維護的純淨版又沒法適應一些需求場景.
  其中 HWIFileDownload 就屬於一直在更新, 也很純淨的第三方, 通常項目使用足以勝任. 但在某些特殊需求上就有點相形見絀了, 好比支持時效性下載連接, 持久化任務列表, 文件校驗, 對恢復數據深度處理等.
  固然, 這都不是重點, 重點是後臺下載場景太稀少了, 本身隨手寫一個均可以勉強用, 還要什麼第三方, 這種吃力不討好, 還基本沒有 Star 的操做我是不會作的.緩存

真香

FKDownloader -- 最終仍是寫了

既然都寫出來了, 那就必須儘可能完美, 除了修復/規避 iOS 的 BUG, 固然還須要支持一些特別的需求.
先列一下 FKDownloader 的總體結構:bash

  • 主類網絡

    • FKDownloadManagersession

      • 自加載, 沒必要顯式調用建立單例
      • 不可繼承, 惟一存在
      • 管理 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

相關文章
相關標籤/搜索