URLSession — 背景狀態下繼續傳送資料
先來個破題,只有 UploadTask 和 DownloadTask 才能使用
使用 App 情境百百種,有時候 API 打出去時,因為有電話打進來,或多工處理滑去別的 App,我們就接不到伺服器的回覆,這時就很適合背景狀態下繼續傳送及接收資料,減少因外界干擾而沒收到回覆,或根本沒打出去的狀況。別再怪罪伺服器是渣男已讀不回了,是我們沒有拿出最好的自己(咦?
首先要遵守兩件事
- 千萬別把 call API 這件事跟 UIViewController 綁在一起
- 不要用 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: )
建立可以在背景繼續執行的 URLSessionConfigurationisDiscretionary
設置為 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