SwiftUI 异步编程:我如何在 .task 里处理超时、重试和错误恢复
SwiftUI 异步编程:我如何在 .task 里处理超时、重试和错误恢复
App 里最难处理的不是异步代码本身,而是异步代码在各种边界条件下的行为。弱网下超时怎么处理?请求失败后 UI 怎么保持可用?用户快速切换页面时旧请求的结果还会不会污染新状态?
年前我系统地梳理了一次 .task 修饰符在复杂场景下的用法,这里把完整方案讲一遍。
.task 修饰符的基本保障
.task 相比 onAppear + Task 的组合有三个优势:
- 自动取消:View 离开视图层级时自动取消任务
- 优先级管理:可以设置
priority - 可取消的悬停:iOS 17+ 支持
taskCancellationRule
struct DataView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { item in
Text(item.title)
}
.task {
do {
data = try await fetchData()
} catch {
print("Failed: \(error)")
}
}
}
}
但这个写法在真实项目里远远不够。
场景一:超时处理
网络请求可能因为各种原因 hang 住,最佳策略是主动超时:
struct ProfileView: View {
@State private var user: User?
@State private var error: Error?
var body: some View {
Group {
if let user {
UserProfileView(user: user)
} else if let error {
ErrorView(error: error)
} else {
ProgressView()
}
}
.task {
do {
user = try await withThrowingTaskGroup(of: User.self) { group in
group.addTask {
try await fetchUser()
}
group.addTask {
try await Task.sleep(nanoseconds: 10_000_000_000) // 10s
throw TimeoutError()
}
guard let result = try await group.next() else {
throw NetworkError.noData
}
return result
}
} catch {
self.error = error
}
}
}
}
不过 withThrowingTaskGroup 用法稍重,更常见的做法是用 Task.checkCancellation() 或超时 wrapper:
func fetchWithTimeout<T>(
timeout: TimeInterval,
operation: @escaping () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw TimeoutError()
}
guard let result = try await group.first() else {
throw NetworkError.noData
}
return result
}
}
场景二:重试机制
接口偶发失败时,重试能大幅提高成功率。但重试要有策略:
actor RetryHandler {
private let maxAttempts: Int
private let baseDelay: TimeInterval
init(maxAttempts: Int = 3, baseDelay: TimeInterval = 1.0) {
self.maxAttempts = maxAttempts
self.baseDelay = baseDelay
}
func retry<T>(
operation: @escaping () async throws -> T
) async throws -> T {
var lastError: Error?
for attempt in 0..<maxAttempts {
do {
return try await operation()
} catch {
lastError = error
guard attempt < maxAttempts - 1 else { break }
let delay = baseDelay * pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
throw lastError ?? RetryError.unknown
}
}
使用方式:
.task {
let handler = RetryHandler(maxAttempts: 3, baseDelay: 1.0)
do {
data = try await handler.retry {
try await API.fetchData()
}
} catch {
self.error = error
}
}
这里用 actor 保证并发安全。
场景三:错误状态的可恢复 UI
请求失败后,UI 不能卡死,但也不能直接消失。用 ContentState 模式管理:
enum ContentState<T> {
case idle
case loading
case success(T)
case error(Error)
}
struct ArticleListView: View {
@State private var state: ContentState<[Article]> = .idle
var body: some View {
Group {
switch state {
case .idle, .loading:
ProgressView()
case .success(let articles):
List(articles) { article in
ArticleRow(article: article)
}
case .error(let error):
VStack(spacing: 16) {
Text("加载失败")
.font(.headline)
Text(error.localizedDescription)
.font(.caption)
.foregroundStyle(.secondary)
Button("重试") {
Task { await loadArticles() }
}
.buttonStyle(.bordered)
}
}
}
.task {
await loadArticles()
}
}
private func loadArticles() async {
state = .loading
do {
let articles = try await API.fetchArticles()
state = .success(articles)
} catch {
state = .error(error)
}
}
}
场景四:竞态条件处理
用户快速切换筛选条件时,旧请求结果可能比新请求结果更晚返回,导致 UI 显示错误数据。
iOS 15+ 的 task(id:) 解决了这个问题:
struct FilterableListView: View {
@State private var selectedFilter: Filter = .all
@State private var items: [Item] = []
var body: some View {
List(items) { item in
ItemRow(item: item)
}
.task(id: selectedFilter) {
// selectedFilter 变化时,上一个任务自动取消
items = await fetchItems(filter: selectedFilter)
}
}
private func fetchItems(filter: Filter) async -> [Item] {
// 离开时 check cancellation
guard !Task.isCancelled else { return [] }
return await API.fetchItems(filter: filter)
}
}
但有些场景下 cancellation 不够及时(比如网络库底层没有检查 Task.isCancelled)。需要双重保险:
private func fetchItems(filter: Filter) async -> [Item] {
let requestId = UUID() // 请求版本号
let results = await API.fetchItems(filter: filter)
guard requestId == currentRequestId else {
// 这个请求已经过时,丢弃结果
return []
}
return results
}
场景五:依赖数据的并行加载
一个页面需要同时加载多个不相关的数据源:
struct DashboardView: View {
@State private var user: User?
@State private var stats: Stats?
@State private var notifications: [Notification] = []
var body: some View {
List {
if let user { UserSection(user: user) }
if let stats { StatsSection(stats: stats) }
if !notifications.isEmpty { NotificationSection(notifications: notifications) }
}
.task {
// 顺序执行
user = try? await API.fetchUser()
stats = try? await API.fetchStats()
notifications = (try? await API.fetchNotifications()) ?? []
}
}
}
改成并行加载更快:
.task {
async let userTask: User? = API.fetchUser()
async let statsTask: Stats? = API.fetchStats()
async let notifTask: [Notification]? = API.fetchNotifications()
let results = await (user: try? await userTask,
stats: try? await statsTask,
notifs: try? await notifTask)
user = results.user
stats = results.stats
notifications = results.notifs ?? []
}
但要注意:如果三个请求里有一个失败了,async let 整体还是会抛出异常。如果需要部分成功,用 try? 单独包:
async let userTask = try? API.fetchUser() // 失败返回 nil
async let statsTask = try? API.fetchStats()
async let notifTask = try? API.fetchNotifications()
场景六:.task 的取消时机
.task 的取消时机不是"立即"的。当 View 从视图层级移除时,SwiftUI 会向 Task 发送取消信号,但 Task 是否立即响应取决于当前执行点:
.task {
for i in 0..<100 {
print("Processing \(i)")
try await Task.sleep(nanoseconds: 100_000_000)
// 每次循环开始时检查是否取消
try Task.checkCancellation()
}
}
所以循环里要主动检查 cancellation。如果你的循环里没有 await 点,取消检查不会发生:
.task {
var data = [Int]()
for i in 0..<1000000 {
data.append(i) // 这个循环永远不会响应取消
}
self.largeData = data
}
总结
.task 修饰符是 SwiftUI 异步编程的核心工具,但要用对它需要理解几个关键点:
- 超时用 TaskGroup 或 wrapper function,不要靠网络库默认超时
- 重试用指数退避,不要无限重试
- 错误状态用 ContentState 模式,区分 idle/loading/success/error
- 竞态条件用 task(id:) + 版本号双重保险
- 并行加载用 async let,但注意 try? 的使用
- 循环里主动检查 cancellation,否则不会响应取消
这些场景覆盖了 App 里 90% 的异步交互需求,把这些模式固化下来,代码会清晰很多。
