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

Norld發表於2018-11-23

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

相關文章