好久之前,我發現了一個將要面對的問題:ios
怎樣才能併發地下載一堆文件,而且所有下載完成後再執行其餘操做?git
固然,這個問題其實很簡單,解決方案也有不少。但我第一時間想到的是,目前是否存一個具備任務組概念,很是權威,很是流行、穩定可靠,而且是用Swift寫的,Github上star很是多的下載框架?若是存在這樣的輪子,我就打算把它做爲項目裏專用的下載模塊。很惋惜,下載框架不少,也有不少這方面的文章和Demo,可是像AFNetworking
、SDWebImage
這種著名權威,star很是多的,真的一個都沒有,並且有一些仍是用NSURLConnection
實現的,用Swift寫的就更少了,這讓我有了打算本身實現一個的想法。github
輪子這種東西,既然要本身擼,就不能隨便,並且下載框架這方面也沒權威著名的,因此一開始我打算知足本身需求的同時,儘可能能作更多的事情,爭取之後負責的項目均可以用得上。首先要知足的就是後臺下載,衆所周知iOS的App在後臺是暫停的,那麼要實現後臺下載,就須要按照蘋果的規定,使用URLSessionDownloadTask
。swift
網上一搜就有大量的相關文章和Demo,而後我就開始愉快地擼代碼。結果擼到一半發現,真正實現起來而且沒有網上的文章說得那麼簡單,測試發現開源的輪子和Demo也有不少地方有Bug,不完善,或者說沒有完整地實現後臺下載。因而只能靠本身繼續深刻的研究,但當時確實沒有這方面研究地比較透徹文章,而時間方面也不容許,必須得儘快擼個輪子出來使用。因此最後我妥協了,我用了一個比較容易處理的辦法,改爲用URLSessionDataTask
實現,雖然不是原生支持後臺下載,但我以爲總有一些邪門歪道能夠實現的,最後我寫出了Tiercel
,一個對現實妥協的下載框架,不過已經知足了個人需求。api
由於其實我並無遇到後臺下載硬性需求,因此我一直沒有尋找其餘辦法去實現,並且我以爲若是要作,就必須使用URLSessionDownloadTask
,實現原生級別的後臺下載。隨着時間的推移,我內心一直都以爲沒有完成當初的想法是一個極大的遺憾,因而我最後下定決心,打算把iOS的後臺下載研究透徹。緩存
終於,完美支持原生後臺下載的Tiercel 2誕生了。下面我將詳細講解後臺下載的實現和注意事項,但願可以幫助有須要的人。服務器
關於後臺下載,其實蘋果有提供文檔---Downloading Files in the Background,但實現起來要面對的問題比文檔說的要多得多。session
首先,若是須要實現後臺下載,就必須建立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
:app
background(withIdentifier:)
方法建立URLSessionConfiguration
,其中這個identifier
必須是固定的,並且爲了不跟其餘App衝突,建議這個identifier
跟App的Bundle ID
相關URLSession
的時候,必須傳入delegate
Background Sessions
,即它的生命週期跟App幾乎一致,爲方便使用,最好是做爲AppDelegate
的屬性,或者是全局變量,緣由在後面會有詳細說明。只有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
就存在各類各樣的問題。
在iOS中,這個resumeData
簡直就是奇葩的存在,若是你有去研究過它,你會以爲難以想象,由於這個東西一直在變,並且常常有Bug,彷佛蘋果就是不想咱們對它進行操做。
在iOS12以前,直接把resumeData
保存爲resumeData.plist
到本地,能夠看出裏面的結構。
// url
NSURLSessionDownloadURL
// 已經接受的數據大小
NSURLSessionResumeBytesReceived
// currentRequest
NSURLSessionResumeCurrentRequest
// Etag,下載文件的惟一標識
NSURLSessionResumeEntityTag
// 已經下載的緩存文件路徑
NSURLSessionResumeInfoLocalPath
// resumeData版本
NSURLSessionResumeInfoVersion = 1
// originalRequest
NSURLSessionResumeOriginalRequest
NSURLSessionResumeServerDownloadDate
複製代碼
NSURLSessionResumeInfoVersion = 2
,resumeData
版本升級NSURLSessionResumeInfoLocalPath
改爲NSURLSessionResumeInfoTempFileName
,緩存文件路徑變成了緩存文件名NSURLSessionResumeInfoVersion = 4
,resumeData
版本再次升級,應該是直接跳過3了取消 - 恢復
操做,生成的resumeData
會多出一個key爲NSURLSessionResumeByteRange
的鍵值對resumeData
編碼方式改變,須要用NSKeyedUnarchiver
來解碼,結構沒有改變瞭解resumeData
結構對解決它引發的Bug,實現離線斷點續傳,起到關鍵做用。
resumeData
不但結構一直變化,並且也一直存在各類各樣的Bug
resumeData
沒法直接恢復下載,緣由是currentRequest
和originalRequest
的NSKeyArchived
編碼異常,iOS 10.2及以上會修復這個問題。resumeData
後,須要對它進行修正,使用修正後的resumeData
建立downloadTask,再對downloadTask的currentRequest
和originalRequest
賦值,Stack Overflow上面有具體說明。取消 - 恢復
操做,生成的resumeData
會多出一個key爲NSURLSessionResumeByteRange
的鍵值對,因此會致使直接下載成功(實際上沒有),下載的文件大小直接變成0,iOS 11.3及以上會修復這個問題。NSURLSessionResumeByteRange
的鍵值對刪除。取消 - 恢復
操做,使用生成的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
管理,且不能夠恢復,則緩存文件會被刪除。即:
suspend
方法,緩存文件會在沙盒的caches文件夾裏cancelByProducingResumeData
方法,則緩存文件會在Tmp文件夾裏cancel
方法,緩存文件會被刪除cancelByProducingResumeData
或者cancel
方法
cancelByProducingResumeData
或者cancel
方法,而後會調用urlSession(_:task:didCompleteWithError:)
代理方法Background Sessions
後,纔會調用cancelByProducingResumeData
或者cancel
方法,而後會調用urlSession(_:task:didCompleteWithError:)
代理方法Background Sessions
,可使用session.getTasksWithCompletionHandler(_:)
方法來獲取任務,session的代理方法也會繼續被調用(若是須要)既然已經總結出規律,那麼處理起來就簡單了:
Background Sessions
cancelByProducingResumeData
方法暫停任務,保證能夠恢復任務
suspend
方法,但在iOS 10.0 - iOS 10.1 中暫停後若是不立刻恢復任務,會沒法恢復任務,這又是一個Bug,因此不建議cancelByProducingResumeData
或者cancel
,最後會調用urlSession(_:task:didCompleteWithError:)
代理方法,能夠在這裏作集中處理,管理downloadTask,把resumeData
保存起來Background Sessions
後,使用session.getTasksWithCompletionHandler(_:)
來獲取任務因爲支持後臺下載,下載任務完成時,App有可能處於不一樣狀態,因此還要了解對應的表現:
Background Sessions
裏面全部的任務(注意是全部任務,不僅僅是下載任務)都完成後,會調用AppDelegate
的application(_:handleEventsForBackgroundURLSession:completionHandler:)
方法,激活App,而後跟在前臺時同樣,調用相關的session代理方法,最後再調用urlSessionDidFinishEvents(forBackgroundURLSession:)
方法Background Sessions
裏面全部的任務(注意是全部任務,不僅僅是下載任務)都完成後,會自動啓動App,調用AppDelegate
的application(_:didFinishLaunchingWithOptions:)
方法,而後調用application(_:handleEventsForBackgroundURLSession:completionHandler:)
方法,當建立了對應的Background Sessions
後,纔會跟在前臺時同樣,調用相關的session代理方法,最後再調用urlSessionDidFinishEvents(forBackgroundURLSession:)
方法Background Sessions
:沒有建立session時,只會調用AppDelegate
的application(_:handleEventsForBackgroundURLSession:completionHandler:)
方法,當建立了對應的Background Sessions
後,纔會跟在前臺時同樣,調用相關的session代理方法,最後再調用urlSessionDidFinishEvents(forBackgroundURLSession:)
方法Background Sessions
後全部任務才完成:跟在前臺的時候同樣總結:
AppDelegate
的application(_: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的任務不受這個值影響。可是實際上又有不少須要注意的地方。
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
開啓下載,能夠併發下載的任務數量也不會增長從以上幾點能夠得出結論,因爲支持後臺下載的URLSession
的特性,系統會限制併發任務的數量,以減小資源的開銷。同時對於不一樣的host,就算httpMaximumConnectionsPerHost
設置爲1,也會有多個任務併發下載,因此不能使用httpMaximumConnectionsPerHost
來控制下載任務的併發數。Tiercel 2是經過判斷正在下載的任務數從而進行併發的控制。
在downloadTask運行中,App進行先後臺切換,會致使urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)
方法不調用
以上是我測試了一些機型後發現的問題,沒有覆蓋所有機型,更多的狀況可自行測試
解決辦法:使用通知監聽UIApplication.didBecomeActiveNotification
,延遲0.1秒調用suspend
方法,再調用resume
方法
resumeData
,其實還須要對應的緩存文件,在resumeData
裏能夠獲得緩存文件的文件名(在iOS 8得到的是緩存文件路徑),由於以前推薦使用cancelByProducingResumeData
方法暫停任務,那麼緩存文件會被移動到沙盒的Tmp文件夾,這個文件夾的數據在某些時候會被系統自動清理掉,因此爲了以防萬一,最好是額外保存一份。若是你們有耐心把前面的內容認真看完,那麼恭喜大家,大家已經瞭解了iOS後臺下載的全部特性和注意事項,同時大家也已經明白爲何目前沒有一款完整實現後臺下載的開源框架,由於Bug和要處理的狀況實在是太多。這篇文章只是我我的的一些總結,可能會存在沒有發現問題或者細節,若是有新的發現,請給我留言。
目前Tiercel 2已經發布,完美地支持後臺下載,還加入了文件校驗等功能,須要瞭解更多的細節,能夠參考代碼,歡迎各位使用,測試,提交Bug和建議。