You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI首次更新状态变量时异步任务被取消的问题排查

SwiftUI异步任务因状态更新被取消的原因及解决办法

问题描述

我是SwiftUI和Swift Concurrency的新手,正在编写处理加载、错误和内容状态的UI抽象逻辑。不管是用ObservableObject实现ViewModel,还是直接在SwiftUI View里写逻辑,只要把状态变量更新为.loading,异步任务就会被取消而非执行;但移除这个状态更新后任务能正常运行,而且首次尝试后点击重试按钮时,即使设置.loading也能正常工作。想知道为什么uiState = FetchState.loading会导致任务取消,以及如何正确更新状态避免这个问题。

相关代码

状态枚举

enum FetchState<T> {
    case none, loading, success(T), failure
}

ViewModel实现(导致任务取消的代码行已标注)

@MainActor class FetchVM<T> : ObservableObject {

    private var fetch: () async throws -> T?

    init(fetch: @escaping () async throws -> T?) {
        self.fetch = fetch
    }
    @Published var uiState: FetchState<T> = FetchState.none
    public func fetch() async {
        uiState = FetchState.loading // 这行导致任务被取消
        do {
            guard let response = try await fetch() else {return}
            uiState = FetchState.success(response)
        } catch let error {
            print(error.localizedDescription)
            uiState = FetchState.failure
        }
    }
}

SwiftUI客户端代码

struct CategoriesScreen: View {
    @StateObject var vm = FetchVM(fetch: Api().fakeProductCategories)
    var body: some View {
    VStack(alignment: .leading) {
        switch (vm.uiState){
            case .none:
                Color.clear.task {
                    await vm.fetch()
                }
            case .loading:
                ProgressView()
            case .failure:
                Text("There was an unknown error.  Please check your internet and try again.")
                Spacer()
                Button("Retry") {
                    Task {
                        await vm.fetch()
                    }
                }
            case .success(let data):
                Text("Display data")
        }
    }
}

模拟API代码

public struct Api {
    public func fakeProductCategories() async throws -> [CategoryItem]? {
         try await Task.sleep(nanoseconds: 1_000_000_000)
         return [
            CategoryItem(id: UUID())
         ]
    }
}

无ObservableObject的View实现

struct ViewStateCoordinator<T, Content: View>: View {

@State var uiState: FetchState<T> = FetchState.none
private var fetch: () async throws -> T?
private var content: (T) -> Content

init(
    fetch: @escaping () async throws -> T?,
    @ViewBuilder content: @escaping (T) -> Content
) {
    self.fetch = fetch
    self.content = content
}

var body: some View {

    switch (uiState){
        case .none:
            Color.clear.task {
                await callServer()
            }
        case .loading:
            ProgressView()
        case .failure:
                Text("There was an unknown error.  Please check your internet and try again.")
                Spacer()
                Button("Retry") {
                    Task {
                        await callServer()
                    }
                }
        case .success(let data):
            content(data)
    }

}

private func callServer() async {
    uiState = FetchState.loading // 同样导致首次任务被取消,但重试正常
    do {
        guard let response = try await fetch() else {return}
        uiState = FetchState.success(response)
    } catch let error{
        print(error.localizedDescription)
        uiState = FetchState.failure
    }
}
}

问题原因

根本原因是SwiftUI的task修饰符会随着关联视图的销毁自动取消绑定的异步任务。

首次加载时,视图处于.none状态,渲染的是带有task修饰符的Color.clear。当你执行uiState = .loading后,SwiftUI会重新计算View的body,此时视图切换为.loading状态的ProgressView,原来的Color.clear视图被销毁,绑定在它上面的task任务(也就是刚启动的fetch())也会被自动取消,导致异步请求还没执行就终止。

重试时能正常工作,是因为重试任务是通过Button的action创建的独立Task,它不依附于任何视图的task修饰符,所以即使视图切换到.loading状态,这个Task也不会被取消。


解决方案

方案1:修改ViewModel实现

将触发异步请求的逻辑从视图的task修饰符中移出,改为在ViewModel初始化时主动触发,避免依赖会随状态切换销毁的视图task。

修改后的ViewModel:

@MainActor class FetchVM<T> : ObservableObject {

    private var fetch: () async throws -> T?
    @Published var uiState: FetchState<T> = FetchState.none

    init(fetch: @escaping () async throws -> T?) {
        self.fetch = fetch
        // 初始化时自动触发请求
        Task {
            await fetchData()
        }
    }

    public func fetchData() async {
        guard uiState != .loading else { return } // 防止重复触发请求
        uiState = FetchState.loading
        do {
            guard let response = try await fetch() else {return}
            uiState = FetchState.success(response)
        } catch let error {
            print(error.localizedDescription)
            uiState = FetchState.failure
        }
    }
}

修改后的客户端视图:

struct CategoriesScreen: View {
    @StateObject var vm = FetchVM(fetch: Api().fakeProductCategories)
    var body: some View {
        VStack(alignment: .leading) {
            switch vm.uiState {
            case .none, .loading: // .none状态也显示加载,避免短暂空白
                ProgressView()
            case .failure:
                Text("发生未知错误,请检查网络后重试。")
                Spacer()
                Button("重试") {
                    Task {
                        await vm.fetchData()
                    }
                }
            case .success(let data):
                Text("Display data")
            }
        }
    }
}

方案2:修改ViewStateCoordinator实现

将task修饰符移到视图最外层,确保它不会随内部状态切换被销毁,同时在task中判断是否需要触发请求。

修改后的ViewStateCoordinator:

struct ViewStateCoordinator<T, Content: View>: View {

    @State var uiState: FetchState<T> = FetchState.none
    private var fetch: () async throws -> T?
    private var content: (T) -> Content

    init(
        fetch: @escaping () async throws -> T?,
        @ViewBuilder content: @escaping (T) -> Content
    ) {
        self.fetch = fetch
        self.content = content
    }

    var body: some View {
        Group {
            switch uiState {
            case .none, .loading:
                ProgressView()
            case .failure:
                Text("发生未知错误,请检查网络后重试。")
                Spacer()
                Button("重试") {
                    Task {
                        await callServer()
                    }
                }
            case .success(let data):
                content(data)
            }
        }
        .task { // 将task放在外层,不随内部状态切换销毁
            if uiState == .none {
                await callServer()
            }
        }
    }

    private func callServer() async {
        guard uiState != .loading else { return }
        uiState = FetchState.loading
        do {
            guard let response = try await fetch() else {return}
            uiState = FetchState.success(response)
        } catch let error {
            print(error.localizedDescription)
            uiState = FetchState.failure
        }
    }
}

内容的提问来源于stack exchange,提问作者tim.paetz

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 04:20:56