SwiftUI .refreshable 會 cancel 你的 async Task:SSO 多步驟登入的踩坑記
症狀
SwiftUI App,pull to refresh 每次都失敗,顯示「查詢失敗:cancelled」。但初次載入(.task)正常、從設定頁切回來也正常,就只有下拉重新整理不行。
App 的背景:一個大學簽到退提醒 App,需要走 SSO 五步驟認證流程(5 個 HTTP 請求)才能查詢差勤系統。
走過的死胡同
- 以為是 SSO session cookie 殘留——把
URLSession從 class property 改成每次authenticate()都建新的。問題沒解。 - 以為是並行呼叫互相干擾——
.task和.onChange(of: scenePhase)可能同時觸發。加了isCheckingAttendanceflag 防止並行。問題沒解。 - 以為是打了兩次 API——
checkTodayStatus()內部已經呼叫queryTodayRecords(),外面又呼叫一次,第二次可能因 session 管理失敗。合併成單次呼叫。問題沒解。
以上三個確實都是問題,但都不是根因。
如何找到根因
把 catch block 裡的固定錯誤訊息改成顯示實際的 error.localizedDescription:
1
2
3
4
} catch let retryError {
print("[Attendance] 重試也失敗: \(retryError.localizedDescription)")
status = .error("查詢失敗:\(retryError.localizedDescription)")
}
Xcode Console 輸出:
1
2
[Attendance] 第一次查詢失敗: cancelled
[Attendance] 重試也失敗: cancelled
錯誤是 cancelled——不是認證失敗、不是網路問題、不是 JSON 解碼錯誤。是 Task 被取消了。
根因:.refreshable 會 cancel async Task
SwiftUI 的 .refreshable 修飾器在使用者放開手指後,會對它的 async closure 設定一個超時。如果在超時前沒完成,SwiftUI 會自動 cancel 這個 Task。
被 cancel 的 Task 裡,所有 await 的操作(包括 URLSession.data(for:))都會立即拋出 CancellationError。
SSO 認證流程需要 5 個 HTTP 請求串聯:
1
GET /index.do → GET /ntnu/ → POST /login.do → GET /ssoIndex.do → POST /Login
這 5 步加起來可能需要 2-5 秒。.refreshable 的超時可能比這短,所以查詢還沒完成就被 cancel 了。
而 .task 不會主動 cancel(除非 View 消失),所以初次載入正常。從設定頁切回來也正常,因為 .task 不會重新觸發、而 .onChange(of: scenePhase) 也不走 .refreshable 的 cancel 機制。
修法
在 .refreshable 的 closure 裡用 withCheckedContinuation + Task {} 包裝,建立一個不受 cancellation 影響的新 Task:
1
2
3
4
5
6
7
8
.refreshable {
await withCheckedContinuation { continuation in
Task {
await viewModel.checkAttendance()
continuation.resume()
}
}
}
Task {} 建立的是一個新的、獨立的 unstructured task。即使外層的 .refreshable task 被 cancel,裡面的 Task {} 不受影響,會繼續執行到完成。
withCheckedContinuation 的作用是讓 .refreshable 的 async closure 等到查詢真正完成才 return——這樣下拉的轉圈動畫會一直顯示到查詢結束。
同場加映:其他踩到的坑
1. Sendable class 不能有 mutable stored property
NTNUAuthService 標記為 nonisolated final class: Sendable,原本 URLSession 是 let property(init() 時建立)。為了每次認證都用乾淨的 session,改成 var,結果 Swift 6 模式下出 warning:
1
Stored property 'session' of 'Sendable'-conforming class 'NTNUAuthService' is mutable
修法:不存為 class property,改在 authenticate() 裡建立 local variable,透過參數傳給各步驟方法。所有 property 維持 let,完全符合 Sendable。
2. Live Activity 重複建立
App 被系統終止再啟動後,currentActivity(in-memory)是 nil,但之前的 Live Activity 還在系統裡。再次呼叫 Activity.request() 就會多一個。
修法:啟動前先用 Activity<T>.activities 檢查系統中的殘留 Activity。有的話恢復追蹤第一個、結束其餘的。這是 Apple 建議的 Live Activity 生命週期管理方式。
3. 同一個 endpoint 打兩次導致第二次失敗
checkTodayStatus() 內部呼叫 queryTodayRecords(),然後外面又呼叫一次 queryTodayRecords()。某些伺服器的 session 管理策略會在第一次回應後刷新 cookie,導致第二次請求用舊 cookie 而失敗。
修法:合併為單次 API 呼叫,從同一份 response 同時判斷狀態和取得紀錄列表。
心得
- 永遠不要吞掉錯誤訊息。
catch { status = .error("固定訊息") }讓你完全看不到真正的問題。至少要print實際的 error。 .refreshable的 cancellation 行為在文件裡不明顯,但在 Apple 開發者論壇和 WWDC 相關討論中有提到。處理需要多步 async 操作的場景時要特別注意。CancellationError的localizedDescription只有一個字:cancelled。不看 error type 只看 description 很容易誤判為其他問題。Sendable合規不是只有讓 compiler 不報錯。如果你繞過檢查(@unchecked Sendable、nonisolated(unsafe)),在 concurrent context 下真的會出 data race。正確的做法是重新設計 ownership——能不存 state 就不存。