iOS原生級別後臺下載詳解

初衷

好久之前,我發現了一個將要面對的問題:ios

怎樣才能併發地下載一堆文件,而且所有下載完成後再執行其餘操做?git

固然,這個問題其實很簡單,解決方案也有不少。但我第一時間想到的是,目前是否存一個具備任務組概念,很是權威,很是流行、穩定可靠,而且是用Swift寫的,Github上star很是多的下載框架?若是存在這樣的輪子,我就打算把它做爲項目裏專用的下載模塊。很惋惜,下載框架不少,也有不少這方面的文章和Demo,可是像AFNetworkingSDWebImage這種著名權威,star很是多的,真的一個都沒有,並且有一些仍是用NSURLConnection實現的,用Swift寫的就更少了,這讓我有了打算本身實現一個的想法。github

理想與現實

輪子這種東西,既然要本身擼,就不能隨便,並且下載框架這方面也沒權威著名的,因此一開始我打算知足本身需求的同時,儘可能能作更多的事情,爭取之後負責的項目均可以用得上。首先要知足的就是後臺下載,衆所周知iOS的App在後臺是暫停的,那麼要實現後臺下載,就須要按照蘋果的規定,使用URLSessionDownloadTaskswift

網上一搜就有大量的相關文章和Demo,而後我就開始愉快地擼代碼。結果擼到一半發現,真正實現起來而且沒有網上的文章說得那麼簡單,測試發現開源的輪子和Demo也有不少地方有Bug,不完善,或者說沒有完整地實現後臺下載。因而只能靠本身繼續深刻的研究,但當時確實沒有這方面研究地比較透徹文章,而時間方面也不容許,必須得儘快擼個輪子出來使用。因此最後我妥協了,我用了一個比較容易處理的辦法,改爲用URLSessionDataTask實現,雖然不是原生支持後臺下載,但我以爲總有一些邪門歪道能夠實現的,最後我寫出了Tiercel,一個對現實妥協的下載框架,不過已經知足了個人需求。api

勿忘初心

由於其實我並無遇到後臺下載硬性需求,因此我一直沒有尋找其餘辦法去實現,並且我以爲若是要作,就必須使用URLSessionDownloadTask,實現原生級別的後臺下載。隨着時間的推移,我內心一直都以爲沒有完成當初的想法是一個極大的遺憾,因而我最後下定決心,打算把iOS的後臺下載研究透徹。緩存

終於,完美支持原生後臺下載的Tiercel 2誕生了。下面我將詳細講解後臺下載的實現和注意事項,但願可以幫助有須要的人。服務器

後臺下載

關於後臺下載,其實蘋果有提供文檔---Downloading Files in the Background,但實現起來要面對的問題比文檔說的要多得多。session

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,實際上是__NSURLBackgroundSessionapp

  • 必須使用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

// 取消時保存resumeData
downloadTask.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:
// url
NSURLSessionDownloadURL
// 已經接受的數據大小
NSURLSessionResumeBytesReceived
// currentRequest
NSURLSessionResumeCurrentRequest
// Etag,下載文件的惟一標識
NSURLSessionResumeEntityTag
// 已經下載的緩存文件路徑
NSURLSessionResumeInfoLocalPath
// resumeData版本
NSURLSessionResumeInfoVersion = 1
// originalRequest
NSURLSessionResumeOriginalRequest

NSURLSessionResumeServerDownloadDate
複製代碼
  • 在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從新啓動後,建立對應的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從新啓動後,建立對應的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啓動的時候建立URLSession,即它的生命週期跟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.configuration.identifier ?? "" {
            // 這裏用做爲AppDelegate的屬性,保存completionHandler
            backgroundCompletionHandler = completionHandler
	    }
}
複製代碼

而後要在session的代理方法裏調用completionHandler,它的做用請看:application(_:handleEventsForBackgroundURLSession:completionHandler:)

// 必須實現這個方法,而且在主線程調用completionHandler
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
        let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else { return }
        
    DispatchQueue.main.async {
        // 上面保存的completionHandler
        backgroundCompletionHandler()
    }
}
複製代碼

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

下載錯誤

支持後臺下載的downloadTask失敗的時候,在urlSession(_:task:didCompleteWithError:)方法裏面的(error as NSError).userInfo可能會出現一個key爲NSURLErrorBackgroundTaskCancelledReasonKey的鍵值對,由此能夠得到只有後臺下載任務失敗時纔有相關的信息,具體請看:Background Task Cancellation

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    if let error = error {
        let backgroundTaskCancelledReason = (error as NSError).userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Int
    }
}
複製代碼

重定向

支持後臺下載的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

最大併發數

URLSessionConfiguration裏有個httpMaximumConnectionsPerHost的屬性,它的做用是控制同一個host同時鏈接的數量,蘋果的文檔顯示,默認在macOS裏是6,在iOS裏是4。單從字面上來看它的效果應該是:若是設置爲N,則同一個host最多有N個任務併發下載,其餘任務在等待,而不一樣host的任務不受這個值影響。可是實際上又有不少須要注意的地方。

  • 沒有資料顯示它的最大值是多少,經測試,設置爲1000000都沒有問題,可是若是設置爲Int.Max,則會出問題,對於大多數URL都是沒法下載(應該跟目標url的服務器有關);若是設置爲小於1,對於大多數URL都沒法下載
  • 當使用URLSessionConfiguration.default來建立一個URLSession時,不管在真機仍是模擬器上
    • httpMaximumConnectionsPerHost設置爲10000,不管是否同一個host,均可以有多個任務(測試過180多個)併發下載
    • httpMaximumConnectionsPerHost設置爲1,對於同一個host只能同時有一個任務在下載,不一樣host能夠有多個任務併發下載
  • 當使用URLSessionConfiguration.background(withIdentifier:)來建立一個支持後臺下載的URLSession
    • 在模擬器上
      • httpMaximumConnectionsPerHost設置爲10000,不管是否同一個host,均可以有多個任務(測試過180多個)併發下載
      • httpMaximumConnectionsPerHost設置爲1,對於同一個host只能同時有一個任務在下載,不一樣host能夠有多個任務併發下載
    • 在真機上
      • httpMaximumConnectionsPerHost設置爲10000,不管是否同一個host,併發下載的任務數都有限制(目前最大是6)
      • httpMaximumConnectionsPerHost設置爲1,對於同一個host只能同時有一個任務在下載,不一樣host併發下載的任務數有限制(目前最大是6)
      • 即便使用多個URLSession開啓下載,能夠併發下載的任務數量也不會增長
      • 如下是部分系統併發數的限制
        • iOS 9 iPhone SE上是3
        • iOS 10.3.3 iPhone 5上是3
        • iOS 11.2.5 iPhone 7Plus上是6
        • iOS 12.1.2 iPhone 6s上是6
        • iOS 12.2 iPhone XS Max上是6

從以上幾點能夠得出結論,因爲支持後臺下載的URLSession的特性,系統會限制併發任務的數量,以減小資源的開銷。同時對於不一樣的host,就算httpMaximumConnectionsPerHost設置爲1,也會有多個任務併發下載,因此不能使用httpMaximumConnectionsPerHost來控制下載任務的併發數。Tiercel 2是經過判斷正在下載的任務數從而進行併發的控制。

先後臺切換

在downloadTask運行中,App進行先後臺切換,會致使urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)方法不調用

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

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

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

注意事項

  • 沙盒路徑:用Xcode運行和中止項目,能夠達到App crash的效果,可是不管是用真機仍是模擬器,每用Xcode運行一次,都會改變沙盒路徑,這會致使系統對downloadTask相關的文件操做失敗,在某些狀況系統記錄的是上次的項目沙盒路徑,最終致使出現沒法開啓任務下載、找不到文件夾等錯誤。我剛開始就是遇到這種狀況,我並不知道是這個緣由,因此以爲沒法預測,也沒法解決。各位在開發測試的時候,必定要注意。
  • 真機與模擬器:因爲iOS後臺下載的特性和注意事項實在太多,並且不一樣的iOS版本之間還存在必定的差異,因此使用模擬器進行開發和測試是一種很方便的選擇。可是有些特性在真機和模擬器上表現又會不同,例如在模擬器上下載任務的併發數是很大的,而在真機上則很小(在iOS 12上是6),因此必定要在真機上進行測試或者校驗,以真機的結果爲準。
  • 緩存文件:前面說了恢復下載依靠的是resumeData,其實還須要對應的緩存文件,在resumeData裏能夠獲得緩存文件的文件名(在iOS 8得到的是緩存文件路徑),由於以前推薦使用cancelByProducingResumeData方法暫停任務,那麼緩存文件會被移動到沙盒的Tmp文件夾,這個文件夾的數據在某些時候會被系統自動清理掉,因此爲了以防萬一,最好是額外保存一份。

最後

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

目前Tiercel 2已經發布,完美地支持後臺下載,還加入了文件校驗等功能,須要瞭解更多的細節,能夠參考代碼,歡迎各位使用,測試,提交Bug和建議。

相關文章
相關標籤/搜索