URLSession — 背景狀態下繼續傳送資料

先來個破題,只有 UploadTask 和 DownloadTask 才能使用

Photo by Rahul Chakraborty on Unsplash

使用 App 情境百百種,有時候 API 打出去時,因為有電話打進來,或多工處理滑去別的 App,我們就接不到伺服器的回覆,這時就很適合背景狀態下繼續傳送及接收資料,減少因外界干擾而沒收到回覆,或根本沒打出去的狀況。別再怪罪伺服器是渣男已讀不回了,是我們沒有拿出最好的自己(咦?

首先要遵守兩件事

  1. 千萬別把 call API 這件事跟 UIViewController 綁在一起
  2. 不要用 lazy var 宣告 URLSession

一切都是跟每個物件的生命週期有關

Step1 建立 URLSessionUploadTask

  • 建立 URLSessionConfiguration
  • 建立 URLSession
  • 建立 URLSessionUploadTask
// APIManager.swift

private func urlSessionTask(id: String, fromFile: URL) -> URLSessionUploadTask {
// 建立可以在背景繼續執行的 URLSessionConfiguration
let config = URLSessionConfiguration.background(withIdentifier: paymentId + id)
config.isDiscretionary = false // 預設 false
config.allowsCellularAccess = true // 預設 true
config.timeoutIntervalForResource = 30 // 設定 timeout 秒數

let urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let urlRequest = createUrlRequest()

let task = urlSession.uploadTask(with: urlRequest, fromFile: fromFile)
return task
}

private func createUrlRequest() -> URLRequest {
var urlRequest = URLRequest(url: URL(string: "https://apitest.com/test")!)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

return urlRequest
}

URLSessionConfiguration.background(withIdentifier: ) 建立可以在背景繼續執行的 URLSessionConfiguration
isDiscretionary 設置為 true 時,大流量的上傳與下載會在適合的時機才執行 ex: 連接 wifi 時 or 充電時
allowsCellularAccess 設置為 true 時,允許使用手機電信網路
timeoutIntervalForResource 重打 API timeout 預設為 60 秒

通常傳送資料會放進 urlRequest.httpbody 內,但現在我們要將資料存在本機內,因為 background session 需要使用文件來保存上傳的數據,以便在 App 被縮小至背景時能夠持久保存,接著再使用uploadTask(with:fromFile:)方法進行上傳。

切記一定要用uploadTask(with:fromFile:)才能支援背景上傳

Step2 設定 delegate function

接收 API 回覆或是錯誤訊息都會進到 delegate 內的方法,將物件遵循 URLSessionDelegate, URLSessionTaskDelegate, URLSessionDataDelegate

  • didCompleteWithError 收到錯誤訊息的方法
  • didReceive data 收到資料的方法
// APIManager.swift

extension APIManager: URLSessionDelegate, URLSessionTaskDelegate, URLSessionDataDelegate {

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error {
let nsError = error as NSError
guard nsError.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Int == NSURLErrorCancelledReasonUserForceQuitApplication || nsError.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Int == NSURLErrorCancelledReasonBackgroundUpdatesDisabled || nsError.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Int == NSURLErrorCancelledReasonInsufficientSystemResources || nsError.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Int == NSURLErrorCancelled else { return }

resend(id: paymentId)
} else {
if let response = task.response as? HTTPURLResponse {
if response.statusCode != 200 {
// HTTP 連線異常處理
}
}
}
}

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
if let responseDataString = String(data: data, encoding: .utf8) {
print("Response Data: \(responseDataString)")

clearUnfinishedSession()
}
}
}

錯誤訊息的方法中可以針對特定的錯誤去做重打,可以參考上面 Apple 官方文件,有非常多種 NSURLError

Step3 發送URLSession任務

// APIManager.swift

func startRequestAPI(id: String) async throws {
// 這裡傳送的資料是文字 將文字轉成 data 寫入文件內
if let paramData = dataStr.data(using: .utf8),
let url = writeDataToFile(data: paramData, filename: "param.txt") {
let task = urlSessionTask(id: id, fromFile: url)
task.resume()
}
}

前面有提到要將資料存在本機內,用 FileManager 建立路徑位置並存取,如果成功打出 API 後,要記得做刪除文件的處理

// APIManager.swift

private func writeDataToFile(data: Data, filename: String) -> URL? {
// 取得應用程序的文件目錄路徑
if let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
// 在文件目錄下建立文件路徑
let fileURL = documentsDirectory.appendingPathComponent(filename)
// 寫入數據到文件
do {
try data.write(to: fileURL)

saveUnfinishedSession(path: fileURL)

return fileURL
} catch {
print("寫入文件時發生錯誤:\(error)")
}
} else {
print("無法取得文件目錄路徑")
}
return nil
}

private func deleteDataFile(at url: URL) {
do {
try FileManager.default.removeItem(at: url)
print("文件刪除成功")
} catch {
print("刪除文件時發生錯誤: \(error)")
}
}

Step4 App LifeCycle 起始檢查有無未發送的資料

// AppDelegate.swift

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if let url = APIManager.shared.getUnfinishedSession() {
APIManager.shared.resend(id: APIManager.shared.paymentId)
}

return true
}

測試情境

Senario1

已進入 function 但 API 未打出 → 縮小 App
過幾分鐘後點開 APP → API 打出 → 接收response

Senario2

API 打出 → 縮小 App → 接收response

Senario3

API 打出 → 殺掉 App → 沒接收到任何東西

Senario4

已進入 function 但 API 未打出 → 殺掉 App → 沒接收到任何東西

測試下來就是縮小 App 但 App 沒被殺死的狀態下,都可以自動重打 API 及接收,持續網路異常狀況則會進到 delegate error 的方法中。
前面提及 timeout 秒數設定就是決定在網路異常狀態下會重打的時間長度,即便重打了五次 delegate error 方法中也只會吐出一次訊息,非常智慧

測試的時候推薦在專案內 import OSLog ,因為要測試殺掉 App 之類的各種情況,使用內建的 系統監視器 ,OSLog 可以出現在系統監視器內,非常方便

專案內用 os_log(“====== 數據已成功寫入文件: \(fileURL)”)印出 log

--

--