URLSession詳解

這篇文章介紹了 URL Loading System 相關知識,涉及以下內容:

  • URLSession類型。
  • URLSessionTask類型
  • URLSessionDelegateURLSessionTaskDelegateURLSessionDataDelegateURLSessionDownloadDelegate四種協議。
  • 使用URLSessionDataTask請求數據。
  • 使用URLSessionDownloadTask下載、暫停、恢復下載視頻。支持斷點續傳、後臺下載,下載完成後自動保存到相冊。

URL 加載系統(URL Loading System)使用標準協議(如 https)或自定義協議提供對 URL 標識資源進行訪問。URL Loading System 是異步執行的,這樣 app 可以保持響應,並在 response 到達時處理數據或錯誤。

使用URLSession實例創建一個或多個URLSessionTask實例,URLSessionTask實例可以拉取數據並將數據返回到 app、下載文件,或將文件、數據上傳到遠程服務器。使用URLSessionConfiguration對象配置URLSession的實例 session(會話),URLSessionConfiguration對象可以配置 caches、cookies 策略,以及是否允許使用數據流量等。

可以使用一個 session 重複創建 task。例如,瀏覽器爲正常瀏覽和無痕模式使用單獨的 session,無痕瀏覽不會保存數據到磁盤。下圖顯示了具有這些配置的兩個 session 如何創建多個 task:

每個 session 關聯一個 delegate 以接收定期更新或錯誤。默認情況下,delegate 調用完成處理程序塊;如果提供了自定義的 delegate,則不會調用完成處理程序塊。

可以將 session 配置爲後臺會話,以便在 app 處於非活躍狀態時繼續下載數據,下載完成後喚醒 app 並提供結果。

1. URLSession

配置和創建 session,使用 session 創建 task 並與 URL 交互。

URLSession和相關類提供的 API 可以從指定 URL 下載,或上傳數據到指定 URL。該 API 允許 app 未運行時執行後臺下載。在 iOS 中,允許 app 處於 suspend 狀態時繼續下載。另外,還提供了一組豐富的委託方法以支持身份驗證、重定向通知等。

通過URLSession API,可以創建一個或多個 session,每個 session 協調一組數據傳輸任務。例如,如果你在開發瀏覽器,可以爲每個標籤或窗口創建一個會話,也可以一個 session 用於交互、一個 session 用於後臺下載。app 向一個 session 添加一系列 task,每個任務代表一個指向特定 URL 的 request。

2. URLSessionConfiguration

URLSessionConfiguration對象定義了使用URLSession上傳、下載時要使用的行爲和策略。使用URLSession時要先創建URLSessionConfigurationURLSessionConfiguration對象定義了單個主機最大同時連接數、是否允許通過蜂窩網絡進行連接、超時時長和緩存策略等。

在使用配置初始化會話前,必須配置好URLSessionConfiguration對象。使用URLSessionConfiguration初始化會話時,session 會複製一份URLSessionConfiguration對象。一旦配置完成,session 將忽略任務對URLSessionConfiguration對象的修改。如果需要改變傳輸策略,需要更新 session configuration 對象,並用更新後的 session configuration 創建一個新的 session。

某些情況下,configuration 指定的策略會被任務的URLRequest對象重寫。默認採用 request 指定的策略,除非 session 的策略更爲嚴格。例如,sesseion configuration 指定禁止使用蜂窩網絡,則URLRequest對象不能使用蜂窩網絡進行請求。

URL session 的行爲和能力很大程度上取決於創建會話的配置。

單例會話(singleton shared session)沒有配置對象,一般用於基本請求。單例會話不能像自己創建的會話一樣進行配置,但如果需求非常有限,其是一個很好的起點。通過調用shared類方法獲取單例會話。

2.1 default

Default session 和 shared session 類似,但允許自定義配置,且可以通過 delegate 獲取增量數據;默認使用基於磁盤的持久緩存(下載文件除外),並將憑據(credential)保存到用戶 keychain,還會將 cookie 保存到共享的 cookie store。通過調用URLSessionConfiguration類的default方法創建 default session 配置。

2.2 ephemeral

Ephemeral session 與 shared session 類似,但會將 cache、cookie 和 credential 等會話相關數據保存到 RAM,而非寫入磁盤。只有在告訴會話將數據寫入文件時,ephemeral 類型會話纔會將數據寫入磁盤。通過調用NSURLSessionConfiguration類的ephemeral方法創建 ephemeral session 配置。

也可以自定義 default configuration 以獲得與 ephemeral configuration 相同的功能,但直接使用 ephemeral configuration 更爲方便。

使用 ephemeral session 的主要優點在於保護隱私。通過將敏感數據保存到 RAM 取代寫入磁盤,避免數據被攔截、它用。因此,ephemeral session 非常適合瀏覽器無痕瀏覽模式。

由於 ephemeral session 不會將緩存數據保存到磁盤,緩存大小會受限於可用 RAM。這一限制決定了可緩存數據大小,也會影響 app 性能。用戶退出並重新啓動 app,所有緩存會被清空。

App 使會話無效時,將自動清除所有臨時會話數據。此外,在 iOS 中,app 處於 suspend 狀態時緩存不會被清空;app 終止或內存不足時,可能會清空緩存數據。

2.3 background

Background session 允許 app 未活躍時執行 HTTP 和 HTTPS 上傳、下載任務。Background session 將下載任務提交給系統,下載會在單獨進程執行。

通過調用URLSessionConfiguration類的backgroundSessionConfiguration(_:)方法創建 background session,Session identifier 需要在 app 內唯一。

iOS app 被系統終止並再次啓動後,app 可以使用同一 identifier 創建 configuration、session,用來獲取 app 終止時數據傳輸進度,但只適用於系統終止 app 運行;如果用戶從多任務中心終止 app,系統會取消該 app 的所有後臺任務,且不會自動喚醒應用。用戶手動打開 app 後纔可以進行傳輸任務。

3. URLSessionTask

URLSessionTask類是 URL 會話任務的基類,task 始終是 session 的一部分。URLSessionTask共有以下四個具體類:

  • URLSessionDataTask:使用dataTask(with:)方法創建URLSessionDataTask實例,data task 用於請求資源,將服務器的響應作爲一個或多個NSData對象返回到內存中。Default、ephemeral、shared session 支持URLSessionDataTask,background session 不支持URLSessionDataTask

  • URLSessionUploadTask:使用uploadTask(with:from:)方法創建URLSessionUploadTask實例,URLSessionUploadTask繼承自URLSessionDataTask。使用URLSessionUploadTask可以很方便爲 request 提供 body(例如,POST 或 PUT),還可以在收到 response 前上傳數據。此外,upload task 支持後臺會話。

    在 iOS 中,爲 background session 創建 upload task 時,系統會將文件複製到臨時目錄,然後從臨時目錄上傳。

  • URLSessionDownloadTask:使用downloadTask(with:)方法創建URLSessionDownloadTask實例,download task 將資源直接下載到磁盤上的文件。Download task 支持任何類型的會話。

  • URLSessionStreamTask:使用streamTask(withHostName:port:)streamTask(with:)方法創建URLSessionStreamTask實例。流任務(stream task)從主機、端口或網絡服務建立 TCP/IP連接。

創建任務後,調用resume()方法啓動任務。在任務完成或失敗前,session 會強引用 task。如果沒有特別用途,不需要維護對任務的引用。

Task 還有progresscountOfBytesReceivedcurrentRequestresponse等屬性,且所有屬性支持KVO。

如果你對觀察者還不熟悉,可以查看我的另一篇文章:KVC和KVO學習筆記

4. URLSessionDelegate

URLSessionDelegate協議定義了 URL session 實例調用 delegate 處理 session 級事件的方法。例如,session 生命週期改變。

除實現URLSessionDelegate協議內方法,大部分 delegate 還需要實現URLSessionTaskDelegateURLSessionDataDelegateURLSessionDownloadDelegate中的一個或多個協議,以便處理 task 級事件,例如,task 開始或結束,data task、download task 定期進度更新。

urlSession(_:didBecomeInvalidWithError:)方法用以通知 URL session 該 session 已失效。如果通過調用finishTasksAndInvalidate()方法使會話無效,會話會在最後一個 task 完成或失敗後調用該方法;如果通過調用invalidateAndCancel()方法使會話無效,會話立即調用該方法。

urlSession(_:didReceive:completionHandler:)方法響應來自遠程服務器的會話級身份驗證請求。遇到以下兩種情況時會調用該方法:

  • 遠程服務器請求客戶端證書,或 Windows NT LAN Manager(NTLM)認證時會調用該方法以提供適當的憑據。
  • 當 session 與使用 SSL 或 TLS 的遠程服務器首次建立連接時,使用該方法驗證服務器的證書鏈。

如果未實現該方法,session 會調用URLSessionTaskDelegate協議中urlSession(_:task:didReceive:completionHandler:)方法,採用 task 級認證。

5. URLSessionTaskDelegate

URLSessionTaskDelegate協議定義了 URL Session 實例調用 delegate 處理 task 級事件的方法。URLSessionTaskDelegate繼承自URLSessionDelegate

如果你在使用 download task,同時需要實現URLSessionDownloadDelegate協議內方法;如果你在使用data task 或 upload task,同時需要實現URLSessionDataDelegate協議內方法。

5.1 處理 task 任務生命週期變化

Task 數據傳輸完成時會調用urlSession(_:task:didCompleteWithError:)方法,如果發生錯誤,則 error 參數會包含失敗原因。Error 參數不包含服務端錯誤,只包含客戶端錯誤。例如無法解析主機名、無法連接主機。

5.2 處理重定向

遠程服務器請求 HTTP 重定向時會調用urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)方法。只有 default session 和 ephemeral session 中的 task 會調用該方法,background session 中 task 會直接重定向。

在該方法內必須調用 completion handler。如果允許重定向,爲 completion handler 傳入 request 參數;如果需要修改重定向,傳入修改後的 request 對象;如果禁止重定向,則參數傳 nil,此時得到的 response 就是重定向。

5.3 處理 upload task

上傳文件時會定期調用urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:)方法,以提供上傳進度。

URL loading system 通過以下三種方式獲取 totalBytesExpectedToSend:

  • Upload body 中的NSData的長度。
  • Upload task 中的 upload body 中磁盤文件的長度。
  • 如果爲 request 顯式設置了 Content-Length,則也可以從此獲取。

另外,totalBytesSend 和 totalBytesExpectedToSend 參數也可以從URLSessionTaskcountOfBytesSendcountOfBytesExpectedToSend屬性獲取。由於URLSessionTask支持ProgressReporting,還可以使用 task 的progress屬性,這樣更簡潔。

5.4 處理 authentication challenge

urlSession(_:task:didReceive:completionHandler:)方法響應服務端身份驗證請求,該方法用於處理 task 級驗證請求。根據 authentication challenge 類型決定調用 session 級還是 task 級方法處理:

  • NSURLProtectionSpace常量類型爲NSURLAuthenticationMethodNTLMNSURLAuthenticationMethodNegotiateNSURLAuthenticationMethodClientCertificateNSURLAuthenticationMethodServerTrust類型時,URLSession實例調用會話級urlSession(_:didReceive:completionHandler:)方法響應;如果 app 沒有實現會話級 authentication challenge 方法,URLSession實例會調用URLSessionTaskDelegate協議的urlSessoin(_:task:didReceive:completionHandler:)方法處理挑戰(challenge)。
  • 對於非會話級 challenge,URLSession對象調用URLSessionTaskDelegate協議的urlSession(_:task:didReceive:completionHandler:)方法響應挑戰。如果 app 實現了該方法,則必須在 task 級處理 challenge,或提供一個顯式調用會話的任務級完成處理程序。對於非會話級挑戰,不會調用URLSessionDelegateurlSession(_:didReceive:completionHandler:)方法。

5.5 處理 delayed waiting

在 iOS 10 中,沒有網絡時URLSession請求會立即失敗。iOS 11 中 configuration 增加了 waitsForConnectivity屬性,其值爲 true 時會等有網絡了才發起連接。

        configuration.timeoutIntervalForResource = 300
        configuration.waitsForConnectivity = true

網絡連接可能由於多種原因不可用。例如,設備只有數據網絡,但allowsCellularAccess屬性爲NO;設備需要 VPN,但沒有可用 VPN。如果此屬性的值爲true,同時連接不可用,則會話會調用urlSession(_:taskIsWaitingForConnectivity:)方法,並等待網絡可用。網絡可用後任務像往常一樣執行。

如果waitsForConnectivity屬性爲false,且網絡不可用,連接會立即失敗,錯誤爲 NSURLErrorNotConnectedToInternet

waitsForConnectivity屬性只對建立連接過程有效。如果建立連接後失去網絡,則會立即失敗,錯誤爲 NSURLErrorNetworkConnectionLost

後臺會話會忽略waitsForConnectivity屬性,默認等待連接。

timeoutIntervalForResource默認爲7天,這裏將其設置爲5分鐘。使用此配置的會話內所有任務資源超時間隔均爲300秒。timeoutIntervalForResource資源超時間隔指從請求發起至請求完成或超時。

timeoutIntervalForRequest屬性決定使用此配置的會話中所有任務的請求超時間隔。請求超時間隔指任務在放棄前等待其他數據到達的時間,單位爲秒。當新數據到達時,與該值相關聯的定時器將被重置。當計時器達到指定時間間隔而沒有接收到任何新數據時觸發超時。timeoutIntervalForRequest屬性默認60秒。

    func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) {
        // Waiting for connectivity, update UI, etc.
        print(task.currentRequest?.url?.absoluteString ?? "")
    }

可以使用該方法更新 UI。例如,顯示呈現離線模式、僅蜂窩網絡模式。每個任務最多調用一次此方法,並且僅在建立連接不可用時調用。後臺會話不會調用該方法,因爲後臺會話會忽略waitsForConnectivity屬性。

5.6 採集數據

收集完畢 task 的指標(metrics)會調用urlSession(_:task:didFinishCollecting:)方法。該方法的 metrics 參數封裝了 session task 的指標。

每個URLSessionTaskMetrics對象都包含taskIntervalredirectCount,以及任務執行過程中進行的每個 request、response 交互。

URLSessionTaskMetrics類包含以下三個屬性:

  • taskInterval:任務發起至任務完成的時間。
  • redirectCount:任務執行過程中重定向次數。
  • transactionMetrics:數組內元素爲任務執行期間每個 request-response 事務度量標準。元素類型爲URLSessionTaskTransactionMetrics

URLSessionTaskTransactionMetrics對象封裝執行會話任務期間收集的性能指標。每個URLSessionTaskTransactionMetrics對象包含了一個 request 和 response 屬性,對應於 task 的 request 和 response。其也包含時間指標(temporal metrics),以fetchStartDate開始,以responseEndDate結束,以及其他特性,例如:networkProtocolNameresourceFetchType

下圖顯示了URL會話任務的事件序列,這些事件對應於URLSessionTaskTransactionMetrics捕獲的時間指標。

對於具有開始日期和結束日期的所有指標,如果任務的某個方面未完成,則相應指標結束日期爲 nil。在解析域名時,操作超時、失敗,或客戶端在解析成功前取消了任務,則可能發生這種情況。在此情況下,domainLookupEndDate屬性爲 nil,其後所有指標均爲 nil。

    func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
        print("metrics: \(metrics.transactionMetrics)")
    }

輸出如下:

metrics: [(Request) <NSURLRequest: 0x6000004e1730> { URL: https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview118/v4/ae/b9/f4/aeb9f43d-4bf2-3468-7163-d067ea0e38cb/mzaf_5189374696281070786.plus.aac.p.m4a }
(Response) <NSHTTPURLResponse: 0x60000066daa0> { URL: https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview118/v4/ae/b9/f4/aeb9f43d-4bf2-3468-7163-d067ea0e38cb/mzaf_5189374696281070786.plus.aac.p.m4a } { Status Code: 200, Headers {
    "Accept-Ranges" =     (
        bytes
    );
    "Access-Control-Allow-Origin" =     (
        "*"
    );
    "Cache-Control" =     (
        "public, max-age=1296000"
    );
    "Content-Length" =     (
        1134323
    );
    "Content-Type" =     (
        "audio/x-m4a"
    );
    Date =     (
        "Sat, 21 Sep 2019 03:34:49 GMT"
    );
    Etag =     (
        "\"4960EBB73736A6F72AF3281A6A757CE1\""
    );
    "Last-Modified" =     (
        "Tue, 30 Oct 2018 20:22:39 GMT"
    );
    "access-control-allow-credentials" =     (
        false
    );
    "access-control-allow-headers" =     (
        range,
        range
    );
    "access-control-allow-methods" =     (
        "HEAD, GET, PUT"
    );
    "access-control-max-age" =     (
        3000
    );
    cdnuuid =     (
        "bd23a6ea-a182-4f5c-b93c-92f2d7bd9b95-280232078"
    );
    "x-apple-ms-content-length" =     (
        1134323
    );
    "x-apple-request-uuid" =     (
        "e882fdc4-336e-4417-ac38-7c414c88d6c8",
        "e882fdc4-336e-4417-ac38-7c414c88d6c8"
    );
    "x-cache" =     (
        "TCP_MISS from a23-210-215-36.deploy.akamaitechnologies.com (AkamaiGHost/9.8.2-27247474) (-)"
    );
    "x-cache-remote" =     (
        "TCP_MISS from a23-210-215-166.deploy.akamaitechnologies.com (AkamaiGHost/9.8.0.1-27187836) (-)",
        "TCP_HIT from a23-210-215-166.deploy.akamaitechnologies.com (AkamaiGHost/9.8.0.1-27187836) (-)"
    );
    "x-icloud-availability" =     (
        "[DL, L, B]"
    );
    "x-icloud-content-length" =     (
        1134323
    );
    "x-icloud-versionid" =     (
        "8c4145e0-dc81-11e8-b031-248a071e6524"
    );
    "x-responding-server" =     (
        "massilia_protocol_020:620000704:qs36p01if-zteh13063901.qs.if.apple.com:8083:19R7:nocommit"
    );
} }
(Fetch Start) 2019-09-21 03:34:48 +0000
(Domain Lookup Start) 2019-09-21 03:34:48 +0000
(Domain Lookup End) 2019-09-21 03:34:48 +0000
(Connect Start) 2019-09-21 03:34:48 +0000
(Secure Connection Start) 2019-09-21 03:34:49 +0000
(Secure Connection End) 2019-09-21 03:34:49 +0000
(Connect End) 2019-09-21 03:34:49 +0000
(Request Start) 2019-09-21 03:34:49 +0000
(Request End) 2019-09-21 03:34:49 +0000
(Response Start) 2019-09-21 03:34:50 +0000
(Response End) 2019-09-21 03:34:53 +0000
(Protocol Name) h2
(Proxy Connection) NO
(Reused Connection) NO
(Fetch Type) Network Load
]

可以使用上述方法查看請求各階段所佔用的時間,優化性能。

6. URLSessionDataDelegate

URLSessionDataDelegate協議定義了 URL session 實例處理 data task、upload task 任務級事件方法。URLSessionDataDelegate繼承自URLSessionTaskDelegate協議。

如果需要處理所有 task 類型共有的 task 級事件,還需要實現URLSessionTaskDelegate協議內方法;如果需要處理 session 級事件,則需要實現URLSessionDelegate協議內方法。

Data task 接收到服務器的初始回覆(header)時,會調用urlSession(_:dataTask:didReceive:completionHandler:)方法,該方法可選實現,只有在接收到 response header 後需要取消任務,或將任務轉變爲 download task 時才需要實現該方法。未實現該方法時,默認允許繼續傳輸數據。

如果需要支持相當複雜的 multipart / x-mixed-replace 內容類型,則需實現urlSession(_:dataTask:didReceive:completionHandler:)方法。在該方法內,爲 completionHandler 傳入URLSession.ResponseDisposition常量。該常量有以下三個值:

  • URLSession.ResponseDisposition.allow:任務繼續作爲 data task 執行。
  • URLSession.ResponseDisposition.cancel:取消任務。
  • URLSession.ResponseDisposition.becomeDownload:調用urlSession(_:dataTask:didBecome:)方法,創建一個 download task 取代當前的 data task。

Data task 接收到數據時會調用urlSession(_:dataTask:didReceive:)方法。該方法可能被調用多次,每次調用提供上次調用後的數據,你的 app 負責將所需數據拼接起來。

Data task 或 upload task 在接收完所有數據後會調用urlSession(_:dataTask:willCacheResponse:completionHandler:)方法,以決定是否將響應存儲到緩存中。如果沒有實現該方法,則根據會話的 configuration 決定是否保存。該方法的主要用途在於阻止指定 URL 緩存響應,或修改緩存的 userInfo 字典。實現該方法後必須調用 completionHandler,傳入 proposed response 或修改後的 response 緩存數據,或nil禁止緩存 response。

只有在URLProtocol協議允許緩存 response 時,纔會調用該方法。下面所有條件均成立時纔會緩存響應:

  • 請求是 HTTP 或 HTTPS 類型,也可以是支持緩存的自定義網絡協議。
  • 請求成功,即狀態碼在200至299區間。
  • response 來自服務器,而非緩存。
  • 會話配置允許緩存。
  • URLRequest緩存策略允許緩存。
  • 服務器響應中與緩存相關的 header 允許緩存。
  • 響應大小足夠小,能夠進行緩存。例如,如果提供磁盤緩存,則響應不得大於磁盤緩存大小的5%。

7. URLSessionDownloadDelegate

URLSessionDownloadDelegate協議定義了 URL session 實例處理 download task 任務級事件方法。URLSessionDownloadDelegate繼承自URLSessionTaskDelegate協議。

8. 使用完成處理程序接收數據

獲取數據最簡單的方法是創建 data task,並用 completion handler 處理數據。task 會將服務器的 response、data及可能的錯誤傳遞給 completion handler。

下圖顯示了 session 與 task 關係,以及如何將結果傳遞給 completion handler。

使用dataTask(with:)方法創建使用完成處理程序的 data task。完成處理程序需要處理以下三件事情:

  1. 驗證 error 參數是否爲 nil。如果不爲 nil,則傳輸時發生錯誤。此時應處理錯誤並退出。
  2. 檢查響應的狀態碼(status code)是否指示成功,以及 MIME 類型是否爲預期值。如果不符合,處理服務器錯誤並退出。
  3. 根據需要使用返回的 data。

下面的代碼使用 iTunes Search API 搜索音樂:

    func getSearchResult(searchTerm: String, completion: @escaping QueryResult) {
        dataTask?.cancel()
        
        if var urlComponents = URLComponents(string: "https://itunes.apple.com/search") {
            urlComponents.query = "media=music&entity=song&term=\(searchTerm)"
            
            guard let url = urlComponents.url else {
                return
            }
            
            dataTask = URLSession.shared.dataTask(with: url, completionHandler: { [weak self] (data, response, error) in
                defer {
                    self?.dataTask = nil
                }
                
                if let error = error {
                    print("DataTask error: " + error.localizedDescription + "\n")
                    return
                }
                
                guard let httpResponse = response as? HTTPURLResponse,
                    (200...299).contains(httpResponse.statusCode),
                    let mimeType = httpResponse.mimeType,
                    mimeType == "text/javascript" else {
                        print("Status code or mime type error \n")
                        return
                }
                
                if let data = data {
                    // 處理數據
                    ...
                    
                    DispatchQueue.main.async {
                        // 更新UI
                        ...
                    }
                }
            })
            
            dataTask?.resume()
        }
    }

Completion handler 在 Grand Central Dispatch 其他隊列調用,與創建 task 隊列不同。如果需要更新 UI,需切換至主隊列。

可以在github.com/pro648/BasicDemos-iOS下載這篇文章的demo。運行後如下:

9. 通過 delegate 接收數據傳遞詳情和結果

爲了更細粒度獲取任務執行信息,在創建 task 時可以爲 session 設置 delegate,而非使用完成處理程序。

使用上述方法,數據到達時會傳遞給URLSessionDataDelegate協議的urlSession(_:dataTask:didReceive:)方法,直到傳輸完成或失敗。傳輸過程中 delegate 也會收到其他類型事件。

下面的代碼使用URLSessionDataDelegate接收數據,且只緩存 itunes.apple.com 相關域名的 response。

    var receivedData: Data?
    
    func getSearchResult(searchTerm: String) {
        if var urlComponents = URLComponents(string: "https://itunes.apple.com/search") {
            urlComponents.query = "media=music&entity=song&term=\(searchTerm)"
            
            guard let url = urlComponents.url else {
                return
            }
            
            receivedData = Data()
            let task = session.dataTask(with: url)
            task.resume()
        }
    }
    
    // delegate methods
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
                    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        guard let response = response as? HTTPURLResponse,
            (200...299).contains(response.statusCode),
            let mimeType = response.mimeType,
            mimeType == "text/html" else {
                completionHandler(.cancel)
                return
        }
        completionHandler(.allow)
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        self.receivedData?.append(data)
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        DispatchQueue.main.async {
            if let error = error {
                handleClientError(error)
            } else if let receivedData = self.receivedData,
                // 處理接收到的數據
            }
        }
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) {
        if proposedResponse.response.url?.host == "itunes.apple.com" {  // 只緩存itunes.apple.com 相關域名的 response
            completionHandler(proposedResponse)
        } else {
            completionHandler(nil)
        }
    }

在實踐中切勿使用一個 session 對應一個 task 的模型,應該使用一個 session 多個 task。這樣有助於提高性能,更好管理內存使用。

10. 將數據下載到文件系統

對於已存儲爲文件(如圖片和文稿)的網絡資源,可以使用 download task 直接將這些資源提取到本地文件系統。

10.1 簡單下載使用完成處理程序接收數據

要下載文件,從NSURLSession創建NSURLSessionDownloadTask對象。如果下載過程中不需要接收下載進度,也無需處理委託回調,則可以使用完成處理程序。任務下載完成或失敗時會調用完成處理程序。

完成處理程序可能收到客戶端錯誤,用以指示本地問題。如果沒有收到 client error,則會收到URLResponse,此時應檢查確認是否爲成功的請求,且內容類型符合預期。

如果下載成功,completion handler 會提供下載的文件在文件系統的臨時路徑。該存儲是臨時的,如果需要保存文件,則必須將文件複製、移動到其它目錄。

如果你對文件系統還不瞭解,可以查看我的另一篇文章:使用NSFileManager管理文件系統

下面的代碼創建了一個 download task,使用 completion handler 接收數據。下載成功後,將文件移動到 cacheDirectory 目錄。

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        download(remoteURL: URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8")!)
    }
    
    func download(remoteURL: URL) {
        let downloadTask = URLSession.shared.downloadTask(with: remoteURL) { (location, response, error) in
            if let error = error {
                print("error" + error.localizedDescription)
                return
            }
            
            guard let httpURLResponse = response as? HTTPURLResponse,
                (200...299).contains(httpURLResponse.statusCode) else {
                    print("server error")
                    return
            }
            
            guard let mimeType = httpURLResponse.mimeType,
                mimeType == "audio/mpegurl" else {
                    print("mimeType is not audio/mpegurl")
                    return
            }
            
            guard let location = location else {
                return
            }
            
            do {
                let documentsURL = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
                let savedURL = documentsURL.appendingPathComponent(location.lastPathComponent)
                try FileManager.default.moveItem(at: location, to: savedURL)
            } catch {
                print("file error: \(error)")
            }
        }
        
        downloadTask.resume()
    }

10.2 使用 delegate 接收下載進度更新

如果想要接收進度更新,必須使用 delegate,實現URLSessionTaskDelegateURLSessionDownloadDelegate協議內方法。

創建URLSession實例,設置 delegate。下面代碼顯示了一個懶惰實例化的 downloadsSession 屬性,該屬性將 self 設置爲其委託。

    lazy var downloadsSession: URLSession = {
        let configuration = URLSessionConfiguration.default
        return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
    }()

想要開始下載文件,使用downloadsSession創建URLSessionDownloadTask,調用resume()開始下載。

    func startDownload(_ track: Track) {
        let download = MusicItem(track: track)
        download.task = downloadsSession.downloadTask(with: track.previewURL)
        download.task?.resume()
        download.isDownloading = true
        activeDownloads[download.track.previewURL] = download
    }
10.2.1 接收進度更新

下載開始後,通過URLSessionDownloadDelegate中的urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesWritten:totalBytesExpectedToWrite:)方法獲取進度更新,可以使用該函數中的回調更新下載進度的UI。

下面的代碼演示瞭如何實現該回調方法。在該方法內計算進度百分比,用以更新 UI。需要注意的是,在未知的 Grand Central Dispatch 隊列中調用該方法,更新 UI 時必須切換到主隊列:

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        guard let url = downloadTask.originalRequest?.url,
            let download = downloadService.activeDownloads[url] else {
                return
        }
        
        download.progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
        let totalSize = ByteCountFormatter.string(fromByteCount: totalBytesExpectedToWrite, countStyle: .file)
        
        DispatchQueue.main.async {
            if let trackCell = self.tableView.cellForRow(at: IndexPath(row: download.track.index,
                                                                       section: 0)) as? TrackCell {
                trackCell.updateDisplay(progress: download.progress, totalSize: totalSize)
            }
        }
    }

如果下載期間需要執行的唯一 UI 更新是UIProgressView,則可以直接使用 task 的progress屬性,而非自行計算。progress屬性是Progress的實例。在創建 task 任務時,將 task 的progress屬性分配給UIProgressView對象的observedProgress屬性,任務下載過程中將會自動更新下載進度 UI。

        let downloadTask = URLSession.shared.downloadTask(with: remoteURL)
        progressView.observedProgress = downloadTask.progress;
        downloadTask.resume()
10.2.2 處理下載完成或失敗

使用urlSession(_:downloadTask:didFinishDownloadingTo:)方法處理下載完成或失敗。先檢查 downloadTask 的response屬性的狀態碼,確認下載成功。下載成功後該方法提供的 location 參數提供了文件存儲的位置。此位置只在回調完成前有效,這意味着必須立即讀取文件,或在回調完成前將其移動到另一個位置。

下面代碼顯示瞭如何保存下載的文件:

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        guard let httpURLResponse = downloadTask.response as? HTTPURLResponse,
            (200...299).contains(httpURLResponse.statusCode) else {
                print("Status Code")
                return
        }
        
        guard let sourceURL = downloadTask.originalRequest?.url else { return }
        
        let download = downloadService.activeDownloads[sourceURL]
        downloadService.activeDownloads[sourceURL] = nil
        
        let destinationURL = localFilePath(for: sourceURL)
        print(destinationURL)
        
        let fileManager = FileManager.default
        try? fileManager.removeItem(at: destinationURL)
        
        do {
            try fileManager.copyItem(at: location, to: destinationURL)
            download?.track.downloaded = true
        } catch let error {
            print("Could not copy file to disk: \(error.localizedDescription)")
        }
        
        if let index = download?.track.index {
            DispatchQueue.main.async { [weak self] in
                self?.tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
            }
        }
    }

如果發生 client 錯誤,會回調urlSession(_:task:didCompleteWithError:)方法。如果下載成功,則會先調用urlSession(_:downloadTask:didFinishDownloadingTo:)方法,後調用urlSession(_:task:didCompleteWithError:)方法,且此方法的 error 爲 nil。

更新後運行如下:

10.2.3 暫停下載

用戶有時需要取消正在下載的任務並在稍後恢復下載。通過支持斷點續傳,可以節省用戶時間和寬帶。

還可以使用此技術恢復由於暫時失去網絡連接導致的下載失敗。

通過調用cancelByProducingResumeData:方法取消URLSessionDownloadTask,取消完成後會調用該方法的完成處理程序。如果完成處理程序的 resumeData 參數不爲 nil,則稍後使用 resumeData 恢復下載。

下面代碼演示瞭如何取消下載,並存儲 resume data:

    func pauseDownload(_ track: Track) {
        guard let download = activeDownloads[track.previewURL],
            download.isDownloading else { return }
        
        download.task?.cancel(byProducingResumeData: { (data) in
            download.resumeData = data
        })
        
        download.isDownloading = false
    }

並非所有下載任務均可恢復,download task 需滿足以下條件纔可以恢復下載:

  • 自上次請求後,資源未發生改變。
  • Task 是 HTTP 或 HTTPS GET 請求。
  • 服務器的響應包含 ETag 或 Last-Modified header,也可以同時包含兩者。
  • 服務器支持 byte-range 請求。
  • 已下載的數據未被刪除。
10.2.4 下載失敗時,保存已下載的數據

還可以恢復因暫時失去網絡而失敗的下載。例如,用戶走出 Wi-Fi 覆蓋區域。

下載失敗時會調用urlSession(_:task:didCompleteWithError:)方法。如果 error 不爲 nil,讀取 error 的 userInfo 字典,查看字典NSURLSessionDownloadTaskResumeData鍵是否存在。如果 key 存在,保存其 value 用以恢復下載。如果 key 不存在,則不能恢復下載。

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error = error else {
            // Handle success case.
            return
        }
        
        let userInfo = (error as NSError).userInfo
        if userInfo[NSLocalizedDescriptionKey] as? String == "cancelled" {  // 手動取消的下載不需要保存
            return
        }
        
        if let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data,
            let sourceURL = task.currentRequest?.url,
            let download = downloadService.activeDownloads[sourceURL]
            {
            download.resumeData = resumeData
            download.isDownloading = false
            
            DispatchQueue.main.async { [weak self] in
                self?.tableView.reloadRows(at: [IndexPath(row: download.track.index, section: 0)], with: .automatic)
            }
        }
    }

URLSessionTaskcurrentRequest表示 task 當前的 url request,originalRequest表示 task 的初始 url request。通常兩者相同,但服務器重定向了初始請求時兩者會不同。另外,如果任務是通過 resume data 恢復的,originalRequest爲 nil,currentRequest代表當前使用的 url request。

10.2.5 使用存儲的數據恢復下載

需要恢復下載時,調用URLSessiondownloadTask(withResumeData:)downloadTask(withResumeData:completionHandler:)方法,傳入上一部分保存的數據,並調用resume()方法,這樣即可實現斷點續傳。

func resumeDownload(_ track: Track) {
        guard let download = activeDownloads[track.previewURL] else { return }
        
        if let resumeData = download.resumeData {
            download.task = downloadsSession.downloadTask(withResumeData: resumeData)
        } else {
            download.task = downloadsSession.downloadTask(with: download.track.previewURL)
        }
        
        download.task?.resume()
        download.isDownloading = true
    }

恢復下載任務後會調用urlSession(_:downloadTask:didResumeAtOffset:expectedTotalBytes:)方法。如果文件的緩存策略、最近修改日期禁止使用已下載內容恢復下載任務,則 fileOffset 參數爲零;反之,fileOffset 參數爲無需下載的數據大小。

在某些情況下,恢復開始位置會在結束位置前面。

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
        print("fileOffset: \(fileOffset) \(expectedTotalBytes)")
    }

運行後如下:

11. 後臺下載

對於非緊急、需長時間傳輸的任務,可以創建後臺任務。即使應用程序處於非活躍狀態,下載也會繼續進行,從而允許 app 恢復、重啓時訪問下載的文件。

11.1 配置後臺會話

要在 iOS 中執行後臺下載,需要將URLSession配置爲後臺操作:

  1. 使用URLSessionConfiguration對象的background(withIdentifier:)類方法創建配置,提供 app 內唯一的標誌符。由於大多數 app 只需要幾個後臺會話(通常爲一個),因此可以使用固定字符串做爲 identifier,而非動態生成。
  2. 要讓系統在任務完成且 app 處於後臺時喚醒 app,請確保sessionSendsLaunchEvents屬性設置爲true。該屬性默認爲true
  3. 對於非緊急任務,將isDiscretionary屬性設置爲true,以便系統可以在最佳條件時執行傳輸。例如,設備插入電源、連接 Wi-Fi。
    • 該屬性只對後臺任務有效。
    • 傳輸大量數據時,推薦將該屬性設置爲ture,這樣系統將在合適時機執行任務。isDiscretionary屬性默認爲false
    • 只有 app 處於前臺發起的傳輸任務纔會採用isDiscretionary屬性。對於 app 處於後臺時發起的任務,系統假定此屬性爲true,並忽略你指定的任何值。
  4. 使用配置好的 configuration 創建URLSession對象,提供 delegate 以接收後臺傳輸事件。

創建後臺會話:

    lazy var downloadsSession: URLSession = {
        let configuration = URLSessionConfiguration.background(withIdentifier: "github.com/pro648")
        configuration.isDiscretionary = true
        configuration.sessionSendsLaunchEvents = true
        return URLSession(configuration: configuration,
                          delegate: self,
                          delegateQueue: nil)
    }()

11.2 創建並計劃下載任務

通過downloadTask(with:)創建 download task,還可以設置以下屬性以幫助系統優化其行爲:

  • 設置earliestBeginDate屬性將下載安排在將來特定時間開始。下載不會精確在這個時間開始,但不會早於這個時間。
  • 設置countOfBytesClientExpectsToSendcountOfBytesClientExpectsToReceice屬性可以幫助系統有效地調度網絡活動。屬性值是猜測預期字節數的上限,需要考慮 header 和 body。

爲了方便測試,下面的代碼將下載任務計劃到 15 秒後。計劃發送 3 KB數據,接收 60 MB數據。

        let task = backgroundDownloadSession.downloadTask(with: remoteURL)
        task.earliestBeginDate = Date().addingTimeInterval(15)  // Added a delay for demonstration purposes only
        task.countOfBytesClientExpectsToSend = 3 * 1024
        task.countOfBytesClientExpectsToReceive = 60 * 1024 * 1024
        task.resume()

設置earliestBeginDate屬性後,任務將要開始時會調用urlSession(_:task:willBeginDelayedRequest:completionHandler:)方法。completion handler 有以下兩個參數:

  • DelayedRequestDisposition:要採取的措施。
    • cancel:取消任務。傳遞 cancel 參數等效於 task 調用cancel()
    • continueLoading:繼續執行原來的 request。
    • useNewRequest:執行第二個參數提供的新 request。
  • URLRequest:只有在 disposition 爲 useNewRequest 時纔會使用該參數。

只有在等待下載的過程中連接可能失效,才需要實現該方法。

11.3 處理 app 處於後臺狀態

不同的 app 狀態會影響 app 與後臺任務的互動方式。在 iOS 中,app 可能處於前臺、後臺狀態,也可能已被系統終止。

如果 app 處於後臺狀態,系統在其他進程執行下載的過程中,app 可能已被系統掛起(suspend)。這種情況下,下載完成後系統會喚醒 app 並調用UIApplicationDelegate協議的application(_:handleEventsForBackgroundURLSession:completionHandler:)方法,該方法會提供創建該下載任務的 identifier。

該代理方法還會接收到 completion handler,將該 handler 存儲爲 app delegate 的屬性,或實現URLSessionDownloadDelegate協議類的屬性。在下面的代碼中,將 completion handler 存儲爲BackgroundDownloadService類的屬性。

    func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
        BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler
    }

當所有事件都已傳遞時,系統會調用URLSessionDelegate協議的urlSessionDidFinishEvents(forBackgroundURLSession:)方法。在該方法內,獲取在上一步保存的 backgroundCompletionHandler 並執行。

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        DispatchQueue.main.async {
            self.backgroundCompletionHandler?()
            self.backgroundCompletionHandler = nil
        }
    }

因爲urlSessionDidFinishEvents(forBackgroundURLSession:)方法是在輔助隊列調用,handler 是在 UIKit 獲取到的。因此,需要切換到主隊列執行 handler。

11.4 獲取下載的文件,並移動到永久位置

一旦喚醒的 app 調用了完成處理程序,download task 就會完成其工作並調用urlSession(_:downloadTask:didFinishDownloadingTo:)方法。此時,文件已完成下載,且在方法結束前均可以訪問該文件。這裏與 app 處於前臺時下載文件一致。

11.5 App 被終止後恢復會話

如果 app 在掛起時被系統終止,下載完成後系統會在後臺重新啓動應用程序。作爲啓動設置的一部分,使用相同的 identifier 重新創建後臺會話,以允許系統將後臺下載任務與會話重新關聯。這樣可以確保無論 app 是由用戶還是系統啓動的,後臺會話時刻準備就緒。一旦 app 重新啓動,一系列事件就像 app 掛起、恢復一樣。

更新AppDelegate.swift文件中application(_:handleEventsForBackgroundURLSession:completionHandler:)方法,如下所示:

    func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
        
        BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler
        
        _ = BackgroundDownloadService.shared.backgroundDownloadSession      // Make sure we have one
    }

11.6 用戶手動終止 app

如果正在進行後臺下載,用戶手動結束 app,則所有正在下載、已計劃的任務均會取消,且系統不會喚醒 app。用戶打開 app 再次執行後臺下載時,會調用urlSession(_session:task:didCompleteWithError:)方法,可以在 error 參數中提取已下載的數據,根據需要決定是否恢復下載。如下所示:

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error = error else {
            // Handle success case.
            return
        }
        
        let userInfo = (error as NSError).userInfo
        
        if let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data,
            let sourceURL = task.currentRequest?.url,
            let videoItem = context.loadVideoItem(withURL: sourceURL)
        {
            videoItem.resumeData = resumeData
            
            // 恢復上次手動取消的任務
            let task = backgroundDownloadSession.downloadTask(withResumeData: resumeData)
            task.resume()
        }
    }

11.7 遵守後臺下載的限制

後臺會話由獨立於 app 的單獨進程執行。由於啓動 app 的進程相當昂貴,因此某些功能不可用,從而導致以下限制:

  • 會話必須提供事件傳遞的 delegate。對於 download、upload,delegate 的行爲與進程內傳輸行爲相同。
  • 只支持 HTTP 和 HTTPS 協議,不支持自定義協議。
  • 始終允許重定向。因此,即便實現了urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)方法,也不會被調用。
  • Upload task 僅支持從文件上傳。從 data 或 stream 上傳會因 app 終止而失敗。

11.8 高效的使用後臺會話

當系統恢復或重啓應用時,其會使用速率限制器來防止濫用後臺會話。app 在後臺開啓的下載任務,需要經過一個延遲纔會開始下載;每次系統恢復或啓動應用時,延遲都會增加。

因此,如果 app 啓動單個後臺下載,在下載完成後系統喚醒時提交新的下載,會大大增加延遲。推薦使用少量後臺會話(通常只使用一個),一次創建許多下載任務。這樣允許系統一次執行多個下載,並在完成後恢復 app。

每個 task 都有自己的開銷(overhead)。如果需要啓動幾千個下載任務,請將方案更改爲更少次數、一次傳輸大量數據的方案。

用戶啓動 app 時,延遲會重置爲0;如果延遲時間已經過去,系統沒有恢復、重啓 app,延遲也會重置爲0。

12. Protocol Support

URLSession原生支持 data、file、ftp、http、https URL scheme,並且透明支持用戶偏好設置中的代理服務器、SOCKS 網關配置。

URLSession支持 HTTP/1.1 和 HTTP/2 協議,HTTP/2 需要服務器支持 Application-Layer Protocol Negotiation(ALPN)。

還可以通過繼承URLProtocol來添加自定義網絡協議和 URL scheme。

13. Thread Safety

URL session 自身 API 是線程安全的,可以在任意進程創建 session、task。當 delegate 調用完成處理程序時,其會自動在正確的隊列執行。

系統會在輔助線程調用urlSessionDidFinishEvents(forBackgroundURLSession:)方法。在 iOS 中,實現該方法時需要調用application(_:handleEventsForBackgroundURLSession:completionHandler:)中的完成處理程序,而UIApplicationDelegate中的方法必須在主線程調用。

Demo名稱:URLSession
源碼地址:https://github.com/pro648/BasicDemos-iOS/tree/master/URLSession

參考資料:

  1. URL Loading System

  2. URLSession Tutorial: Getting Started

  3. Programming-iOS-Book-Examples Background Download

  4. KEEPING THINGS GOING WHEN THE USER LEAVES

  5. URLSession Waiting For Connectivity

本文地址:https://github.com/pro648/tips/wiki/URLSession詳解
歡迎更多指正:https://github.com/pro648/tips/wiki

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章