Post

SwiftUI .refreshable 會 cancel 你的 async Task:SSO 多步驟登入的踩坑記

SwiftUI .refreshable 會 cancel 你的 async Task:SSO 多步驟登入的踩坑記

症狀

SwiftUI App,pull to refresh 每次都失敗,顯示「查詢失敗:cancelled」。但初次載入(.task)正常、從設定頁切回來也正常,就只有下拉重新整理不行。

App 的背景:一個大學簽到退提醒 App,需要走 SSO 五步驟認證流程(5 個 HTTP 請求)才能查詢差勤系統。

走過的死胡同

  1. 以為是 SSO session cookie 殘留——把 URLSession 從 class property 改成每次 authenticate() 都建新的。問題沒解。
  2. 以為是並行呼叫互相干擾——.task.onChange(of: scenePhase) 可能同時觸發。加了 isCheckingAttendance flag 防止並行。問題沒解。
  3. 以為是打了兩次 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,原本 URLSessionlet 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 同時判斷狀態和取得紀錄列表。

心得

  1. 永遠不要吞掉錯誤訊息catch { status = .error("固定訊息") } 讓你完全看不到真正的問題。至少要 print 實際的 error。
  2. .refreshable 的 cancellation 行為在文件裡不明顯,但在 Apple 開發者論壇和 WWDC 相關討論中有提到。處理需要多步 async 操作的場景時要特別注意。
  3. CancellationErrorlocalizedDescription 只有一個字:cancelled。不看 error type 只看 description 很容易誤判為其他問題。
  4. Sendable 合規不是只有讓 compiler 不報錯。如果你繞過檢查(@unchecked Sendablenonisolated(unsafe)),在 concurrent context 下真的會出 data race。正確的做法是重新設計 ownership——能不存 state 就不存。
This post is licensed under CC BY 4.0 by the author.