iOS原生級別後臺下載詳解

Danie1s發表於2019-01-28

初衷

很久以前,我發現了一個可能要面對的問題:

怎樣才能併發地下載一堆檔案,並且全部下載完成後再執行其他操作?

當然,這個問題其實很簡單,解決方案也有很多。但是,我第一時間想到的是,目前是否存一個具有任務組概念,非常權威,非常流行、穩定可靠,並且是用Swift寫的,Github上star非常多的下載框架?我考慮的是如果存在這樣的輪子,我就打算把它作為專案裡專用的下載模組。很可惜,下載框架很多,也有很多這方面的文章和demo,但是像AFNetworkingSDWebImage這種著名,star非常多的,真的一個都沒有,並且有一些還是用NSURLConnection實現的,用Swift寫的就更少了,這讓我有了打算自己擼一個的想法。

理想與現實

輪子這種東西,既然要自己擼,就不能隨便,而且下載框架這方面也沒權威著名的,所以一開始我打算滿足自己需求的同時,儘量能做更多的事情,爭取以後負責的專案都可以用得上。首先要滿足的就是後臺下載,眾所周知iOS的App在後臺是暫停的,那麼要實現後臺下載,就需要按照蘋果的規定,使用URLSessionDownloadTask

網上一搜就有大量的相關文章和demo,然後我就開始愉快地擼程式碼。結果擼到一半發現,真正實現起來並且沒有網上的文章說得那麼簡單,測試發現開源的輪子和demo也有很多地方有Bug,不完善,或者說沒有完整地實現後臺下載。於是只能靠自己繼續深入的研究,但當時確實沒有這方面研究地比較透徹文章,而時間方面也不允許,必須得儘快擼個輪子出來使用。所以最後我妥協了,我用了一個比較容易處理的辦法,改成用URLSessionDataTask實現,雖然不是原生支援後臺下載,但我覺得總有一些邪門歪道可以實現的,最後我寫出了Tiercel,一個對現實妥協的下載框架,但也滿足了我的需求,除了不支援後臺下載。

勿忘初心

因為其實我並沒有遇到後臺下載硬性需求,所以我一直沒有去尋找其他辦法實現,而且我覺得如果要做,就必須使用URLSessionDownloadTask,實現原生級別的後臺下載。但我心裡一直都覺得沒有實現當初的想法是一個極大的遺憾,於是我最後下定決心,打算把iOS的後臺下載研究透徹。

終於,完美支援原生後臺下載的Tiercel 2誕生了。下面我將詳細講解後臺下載的實現和注意事項,希望能夠幫助有需要的人。

後臺下載

關於後臺下載,其實蘋果有提供文件—Downloading Files in the Background,但還是那句話,實現起來要面對的問題比文件說的要多得多。

URLSession

首先,如果需要實現後臺下載,就必須建立Background Sessions

private lazy var urlSession: URLSession = { 
let config = URLSessionConfiguration.background(withIdentifier: "com.Daniels.Tiercel") config.isDiscretionary = true config.sessionSendsLaunchEvents = true return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()複製程式碼

通過這種方式建立的URLSession,其實是__NSURLBackgroundSession

  • 必須使用background(withIdentifier:)方法建立URLSessionConfiguration,其中這個identifier必須是固定的,而且為了避免跟其他App衝突,建議這個identifier跟App的Bundle ID相關
  • 建立URLSession的時候,必須傳入delegate
  • 必須在App啟動的時候建立Background Sessions,即它的生命週期跟App幾乎一致,為方便使用,最好是作為AppDelegate的屬性,或者是全域性變數,原因在後面會有詳細說明。

URLSessionDownloadTask

只有URLSessionDownloadTask才支援後臺下載

let downloadTask = urlSession.downloadTask(with: url)downloadTask.resume()複製程式碼

通過Background Sessions建立出來的downloadTask,其實是__NSCFBackgroundDownloadTask

到目前為止,已經建立並且開啟了支援後臺下載的任務,但真正的難題,現在才開始

斷點續傳

蘋果的官方文件—-Pausing and Resuming Downloads

URLSessionDownloadTask 的斷點續傳依靠的是resumeData

// 取消時儲存resumeDatadownloadTask.cancel { 
resumeDataOrNil in guard let resumeData = resumeDataOrNil else {
return
} self.resumeData = resumeData
}// 或者是在session delegate 的 urlSession(_:task:didCompleteWithError:) 方法裡面獲取func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error, let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
self.resumeData = resumeData
}
}// 用resumeData恢復下載guard let resumeData = resumeData else {
// inform the user the download can't be resumed return
}let downloadTask = urlSession.downloadTask(withResumeData: resumeData)downloadTask.resume()複製程式碼

正常情況下,這樣就已經可以恢復下載任務,可是現實很殘酷,resumeData就是需要解決的第一個大坑。

ResumeData

在iOS中,這個resumeData簡直就是奇葩的存在,如果你有去研究過它,你會覺得不可思議,因為這個東西一直在變,而且經常有Bug,似乎蘋果就是不想讓我們去操作它。

ResumeData 的結構

在iOS12之前,直接把resumeData儲存為resumeData.plist到本地,可以看出裡面的結構。

  • 在iOS 8,resumeData的key:
// urlNSURLSessionDownloadURL// 已經接受的資料大小NSURLSessionResumeBytesReceived// currentRequestNSURLSessionResumeCurrentRequest// tagNSURLSessionResumeEntityTag// 已經下載的快取檔案路徑NSURLSessionResumeInfoLocalPath// resumeData版本NSURLSessionResumeInfoVersion = 1// originalRequestNSURLSessionResumeOriginalRequestNSURLSessionResumeServerDownloadDate複製程式碼
  • 在iOS 9 – iOS 10,改動如下:

    • NSURLSessionResumeInfoVersion = 2resumeData版本升級
    • NSURLSessionResumeInfoLocalPath改成NSURLSessionResumeInfoTempFileName,快取檔案路徑變成了快取檔名
  • 在iOS 11,改動如下:

    • NSURLSessionResumeInfoVersion = 4resumeData版本再次升級,應該是直接跳過3了
    • 如果是多次對downloadTask進行 取消 - 恢復 操作,生成的resumeData會多出一個key為NSURLSessionResumeByteRange的鍵值對
  • 在iOS 12,resumeData編碼方式改變,需要用NSKeyedUnarchiver來解碼,結構沒有改變

瞭解resumeData結構對解決它引起的Bug,實現離線斷點續傳,起到關鍵作用。

ResumeData 的Bug

resumeData不但結構一直變化,而且也一直存在各種各樣的Bug

  • 在iOS 10.0 – iOS 10.1:
    • Bug:使用系統生成的resumeData無法直接恢復下載,原因是currentRequestoriginalRequestNSKeyArchived編碼異常,iOS 10.2及以上會修復這個問題。
    • 解決方法:獲取到resumeData後,需要對它進行修正,使用修正後的resumeData建立downloadTask,再對downloadTask的currentRequestoriginalRequest賦值,Stack Overflow上面有具體說明。
  • 在iOS 11.0 – iOS 11.2:
    • Bug:由於多次對downloadTask進行 取消 - 恢復 操作,生成的resumeData會多出一個key為NSURLSessionResumeByteRange的鍵值對,所以會導致直接下載成功(實際上沒有),下載的檔案大小直接變成0,iOS 11.3及以上會修復這個問題。
    • 解決方法:把key為NSURLSessionResumeByteRange的鍵值對刪除。
  • 在iOS 10.3 – iOS 12.1:
    • Bug:從iOS 10.3開始,只要對downloadTask進行 取消 - 恢復 操作,使用生成的resumeData建立downloadTask,它的originalRequest為nil,到目前最新的系統版本(iOS 12.1)仍然一樣,雖然不會影響檔案的下載,但會影響到下載任務的管理。
    • 解決方法:使用currentRequest匹配任務,這裡涉及到一個重定向問題,後面會有詳細說明。

以上是目前總結出的resumeData在不同的系統版本出現的改動和Bug,具體程式碼可以參考Tiercel

具體表現

支援後臺下載的downloadTask已經建立,resumeData的問題也已經解決,現在已經可以愉快地開啟和恢復下載了,但接下來要面對的是,這個downloadTask的具體表現,這也是實現一個下載框架最重要的環節。

下載過程中

為了測試downloadTask在不同情況下的表現,花費了大量的時間和精力,具體如下:

操作 建立 執行中 暫停(suspend) 取消(cancelByProducingResumeData) 取消(cancel)
立即產生的效果 在App沙盒的caches資料夾裡面建立tmp檔案 把下載的資料寫入caches資料夾裡面的tmp檔案 caches資料夾裡面的tmp檔案不會被移動 caches資料夾裡面的tmp檔案會被移動到Tmp資料夾,會呼叫didCompleteWithError caches資料夾裡面的tmp檔案會被刪除,會呼叫didCompleteWithError
進入後臺 自動開啟下載 繼續下載 沒有發生任何事情 沒有發生任何事情 沒有發生任何事情
手動kill App 關閉的時候caches資料夾裡面的tmp檔案會被刪除,重新開啟app後建立相同identifier的session,會呼叫didCompleteWithError(等於呼叫了cancel) 關閉的時候下載停止了,caches資料夾裡面的tmp檔案不會被移動,重新開啟app後建立相同identifier的session,tmp檔案會被移動到Tmp資料夾,會呼叫didCompleteWithError(等於呼叫了cancelByProducingResumeData) 關閉的時候caches資料夾裡面的tmp檔案不會被移動,重新開啟app後建立相同identifier的session,tmp檔案會被移動到Tmp資料夾,會呼叫didCompleteWithError(等於呼叫了cancelByProducingResumeData) 沒有發生任何事情 沒有發生任何事情
程式碼引起的crash或者被系統關閉 自動開啟下載,caches資料夾裡面的tmp檔案不會被移動,重新開啟app後,不管是否有建立相同identifier的session,都會繼續下載(保持下載狀態) 繼續下載,caches資料夾裡面的tmp檔案不會被移動,重新開啟app後,不管是否有建立相同identifier的session,都會繼續下載(保持下載狀態) caches資料夾裡面的tmp檔案不會被移動,重新開啟app後建立相同identifier的session,不會呼叫didCompleteWithError,session裡面還儲存著task,此時task還是暫停狀態,可以恢復下載 沒有發生任何事情 沒有發生任何事情

支援後臺下載的URLSessionDownloadTask,真實型別是__NSCFBackgroundDownloadTask,具體表現跟普通的有很大的差別,根據上面的表格和蘋果官方文件:

  • 當建立了Background Sessions,系統會把它的identifier記錄起來,只要App重新啟動後,建立對應的Background Sessions,它的代理方法也會繼續被呼叫
  • 如果是任務被session管理,則下載中的tmp格式快取檔案會在沙盒的caches資料夾裡;如果不被session管理,且可以恢復,則快取檔案會被移動到Tmp資料夾裡;如果不被session管理,且不可以恢復,則快取檔案會被刪除。即:
    • downloadTask執行中和呼叫suspend方法,快取檔案會在沙盒的caches資料夾裡
    • 呼叫cancelByProducingResumeData方法,則快取檔案會在Tmp資料夾裡
    • 呼叫cancel方法,快取檔案會被刪除
  • 手動Kill App會呼叫了cancelByProducingResumeData或者cancel方法
    • 在iOS 8 上,手動kill會馬上呼叫cancelByProducingResumeData或者cancel方法,然後會呼叫urlSession(_:task:didCompleteWithError:)代理方法
    • 在iOS 9 – iOS 12 上,手動kill會馬上停止下載,當App重新啟動後,建立對應的Background Sessions後,才會呼叫cancelByProducingResumeData或者cancel方法,然後會呼叫urlSession(_:task:didCompleteWithError:)代理方法
  • 進入後臺、crash或者App被系統關閉,系統會有另外一條程式對下載任務進行管理,沒有開啟的任務會自動開啟,已經開啟的會保持原來的狀態(繼續執行或者暫停),當App重新啟動後,建立對應的Background Sessions,可以使用session.getTasksWithCompletionHandler(_:)方法來獲取任務,session的代理方法也會繼續被呼叫(如果需要)
  • 最令人意外的是,只要沒有手動手動Kill App,就算重啟手機,重啟完成後原來在執行的下載任務還是會繼續下載,實在牛逼

既然已經總結出規律,那麼處理起來就簡單了:

  • 在App啟動的時候建立Background Sessions
  • 使用cancelByProducingResumeData方法暫停任務,保證可以恢復任務
    • 其實也可以使用suspend方法,但在iOS 10.0 – iOS 10.1 中暫停後如果不馬上恢復任務,會無法恢復任務,這又是一個Bug,所以不建議
  • 手動Kill App會呼叫了cancelByProducingResumeData或者cancel,最後會呼叫urlSession(_:task:didCompleteWithError:)代理方法,可以在這裡做集中處理,管理downloadTask,把resumeData儲存起來
  • 進入後臺、crash或者App被系統關閉,不影響原來任務的狀態,當App重新啟動後,建立對應的Background Sessions後,使用session.getTasksWithCompletionHandler(_:)來獲取任務

下載完成

由於支援後臺下載,下載任務完成時,App有可能處於不同狀態,所以還要了解對應的表現:

  • 在前臺:跟普通的downloadTask一樣,呼叫相關的session代理方法
  • 在後臺:當Background Sessions裡面所有的任務(注意是所有任務,不單單是下載任務)都完成後,會呼叫AppDelegateapplication(_:handleEventsForBackgroundURLSession:completionHandler:)方法,啟用App,然後跟在前臺時一樣,呼叫相關的session代理方法,最後再呼叫urlSessionDidFinishEvents(forBackgroundURLSession:)方法
  • 程式碼引起的crash或者App被系統關閉:當Background Sessions裡面所有的任務(注意是所有任務,不單單是下載任務)都完成後,會自動啟動App,呼叫AppDelegateapplication(_:didFinishLaunchingWithOptions:)方法,然後呼叫application(_:handleEventsForBackgroundURLSession:completionHandler:)方法,當建立了對應的Background Sessions後,才會跟在前臺時一樣,呼叫相關的session代理方法,最後再呼叫urlSessionDidFinishEvents(forBackgroundURLSession:)方法
  • crash或者App被系統關閉,開啟App保持前臺,當所有的任務都完成後才建立對應的Background Sessions:沒有建立session時,只會呼叫AppDelegateapplication(_:handleEventsForBackgroundURLSession:completionHandler:)方法,當建立了對應的Background Sessions後,才會跟在前臺時一樣,呼叫相關的session代理方法,最後再呼叫urlSessionDidFinishEvents(forBackgroundURLSession:)方法
  • 程式碼引起的crash或者App被系統關閉,開啟App,建立對應的Background Sessions後所有任務才完成:跟在前臺的時候一樣

總結:

  • 只要不在前臺,當所有任務完成後會呼叫AppDelegateapplication(_:handleEventsForBackgroundURLSession:completionHandler:)方法
  • 只有建立了對應Background Sessions,才會呼叫對應的session代理方法,如果不在前臺,還會呼叫urlSessionDidFinishEvents(forBackgroundURLSession:)

具體處理方式:

首先就是Background Sessions的建立時機,前面說過:

必須在App啟動的時候建立Background Sessions,即它的生命週期跟App幾乎一致,為方便使用,最好是作為AppDelegate的屬性,或者是全域性變數。

原因:下載任務有可能在App處於不同狀態時完成,所以需要保證App啟動的時候,Background Sessions也已經建立,這樣才能使它的代理方法正確的呼叫,並且方便接下來的操作。

根據下載任務完成時的表現,結合蘋果官方文件:

// 必須在AppDelegate中,實現這個方法////   - identifier: 對應Background Sessions的identifier//   - completionHandler: 需要儲存起來func application(_ application: UIApplication,                 handleEventsForBackgroundURLSession identifier: String,                 completionHandler: @escaping () ->
Void) {
if identifier == urlSession.session.configuration.identifier ?? "" {
// 這裡用作為AppDelegate的屬性,儲存completionHandler backgroundCompletionHandler = completionHandler
}
}複製程式碼

然後要在session的代理方法裡呼叫completionHandler,它的作用請看:application(_:handleEventsForBackgroundURLSession:completionHandler:)

// 必須實現這個方法,並且在主執行緒呼叫completionHandlerfunc urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { 
DispatchQueue.main.async {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else {
return
} // 上面儲存的completionHandler backgroundCompletionHandler()
}
}複製程式碼

至此,下載完成的情況也處理完

下載錯誤

支援後臺下載的downloadTask失敗的時候,在urlSession(_:task:didCompleteWithError:)方法裡面的(error as NSError).userInfo可能會出現一個key為NSURLErrorBackgroundTaskCancelledReasonKey的鍵值對,由此可以獲得只有後臺下載任務失敗時才有相關的資訊,具體請看:Background Task Cancellation

// 或者是在session delegate 的 urlSession(_:task:didCompleteWithError:) 方法裡面獲取func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 
if let error = error {
let backgroundTaskCancelledReason = (error as NSError).userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Data
}
}複製程式碼

重定向

支援後臺下載的downloadTask,由於App有可能處於後臺,或者crash,或者被系統關閉,只有當Background Sessions所有任務完成時,才會啟用或者啟動,所以無法處理處理重定向的情況。

蘋果官方文件指出:

Redirects are always followed. As a result, even if you have implemented urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:), it is not called.

意思是始終遵從重定向,並且不會呼叫urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)方法。

前面有提到downloadTask的originalRequest有可能為nil,只能用currentRequest來匹配任務進行管理,但currentRequest也有可能因為重定向而發生改變,而重定向的代理方法又不會呼叫,所以只能用KVO來觀察currentRequest,這樣就可以獲取到最新的currentRequest

前後臺切換

在downloadTask執行中,App進行前後臺切換,會導致urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)方法不呼叫

  • 在iOS 12 – iOS 12.1,iPhone8 以下的真機中,App進入後臺再回到前臺,進度的代理方法不呼叫,當再次進入後臺的時候,有短暫的時間會呼叫進度的代理方法
  • 在iOS 12.1,iPhone XS的模擬器中,多次進行前臺後臺切換,偶爾會出現進度的代理方法不呼叫,真機目測不會
  • 在iOS 11.2.2,iPhone 6真機中,進行前臺後臺切換,會出現進度的代理方法不呼叫,多次切換則有機會恢復

以上是我測試了一些機型後發現的問題,沒有覆蓋全部機型,更多的情況可自行測試

解決辦法:使用通知監聽UIApplication.didBecomeActiveNotification,延遲0.1秒呼叫suspend方法,再呼叫resume方法

注意事項

  • 沙盒路徑:用Xcode執行和停止專案,可以達到App crash的效果,但是無論是用真機還是模擬器,每用Xcode執行一次,都會改變沙盒路徑,這會導致系統對downloadTask相關的檔案操作失敗,在某些情況系統記錄的是上次的專案沙盒路徑,最終導致出現奇怪的錯誤。我剛開始就是遇到這種情況,我並不知道是這個原因,所以覺得無法預測,也無法解決。各位在開發測試的時候,一定要注意。

  • 快取檔案,前面說了恢復下載依靠的是resumeData,其實還需要對應的快取檔案,在resumeData裡可以得到快取檔案的檔名(在iOS 8獲得的是快取檔案路徑),因為之前推薦使用cancelByProducingResumeData方法暫停任務,那麼快取檔案會被移動到沙盒的Tmp資料夾,這個資料夾的資料在某些時候會被系統自動清理掉,所以為了以防萬一,最好是自己儲存一份。

最後

如果大家有耐心把前面的內容認真看完,那麼恭喜你們,你們已經瞭解了iOS後臺下載的所有特性和注意事項,同時你們也已經明白為什麼目前沒有一款完整實現後臺下載的開源框架,因為Bug和要處理的情況實在是太多。這篇文章只是我個人的一些總結,可能會存在沒有發現問題或者細節,如果有新的發現,請給我留言。

目前Tiercel 2已經發布,完美地支援後臺下載,還加入了檔案校驗等功能,需要了解更多的細節,可以參考程式碼,歡迎各位使用,測試,提交Bug和建議。

來源:https://juejin.im/post/5c4ed0b0e51d4511dc730799#comment

相關文章