SwiftUI 异步编程:我如何在 .task 里处理超时、重试和错误恢复

SwiftUI 异步编程:我如何在 .task 里处理超时、重试和错误恢复

App 里最难处理的不是异步代码本身,而是异步代码在各种边界条件下的行为。弱网下超时怎么处理?请求失败后 UI 怎么保持可用?用户快速切换页面时旧请求的结果还会不会污染新状态?

年前我系统地梳理了一次 .task 修饰符在复杂场景下的用法,这里把完整方案讲一遍。

.task 修饰符的基本保障

.task 相比 onAppear + Task 的组合有三个优势:

  1. 自动取消:View 离开视图层级时自动取消任务
  2. 优先级管理:可以设置 priority
  3. 可取消的悬停: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 异步编程的核心工具,但要用对它需要理解几个关键点:

  1. 超时用 TaskGroup 或 wrapper function,不要靠网络库默认超时
  2. 重试用指数退避,不要无限重试
  3. 错误状态用 ContentState 模式,区分 idle/loading/success/error
  4. 竞态条件用 task(id:) + 版本号双重保险
  5. 并行加载用 async let,但注意 try? 的使用
  6. 循环里主动检查 cancellation,否则不会响应取消

这些场景覆盖了 App 里 90% 的异步交互需求,把这些模式固化下来,代码会清晰很多。

最后更新 4/20/2026, 6:02:32 AM