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

