SwiftUI task 被取消后还更新界面,或刚进入 loading 就失败怎么办
SwiftUI task 刚进入 loading 就取消,或取消后继续更新界面,检查任务附着位置、id 变化、try? 吞错与协作式取消检查点。
异步加载刚开始就抛出 CancellationError,或者点击取消后计数反而立即增加,常见原因分别是任务绑定的视图被移除,以及代码吞掉了取消错误后继续执行。两种现象都应从任务生命周期和取消处理检查,不能靠延迟一帧或换成 try? 解决。
本文使用 iOS 15 及以后的 SwiftUI .task 和 Swift Concurrency。示例中的 Task.sleep(nanoseconds:) 用来模拟等待,接入项目时应换成实际异步操作。
try? 会吞掉取消错误,后面的语句仍执行
下面的代码在等待期间收到取消时,sleep 会抛出 CancellationError。try? 忽略错误,控制流继续到计数加一:
try? await Task.sleep(nanoseconds: 3_000_000_000)
counter += 1
取消是协作式信号,不是强行终止正在执行的所有语句。把不应在取消后执行的操作放在成功路径中,并单独处理取消:
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
try Task.checkCancellation()
counter += 1
} catch is CancellationError {
return
} catch {
// 处理这项操作可能抛出的其他错误。
}
checkCancellation() 对多阶段操作尤其有用:被调用的方法即使正常返回,也不代表它已经检查了任务取消状态。检查点应放在继续昂贵工作或提交结果之前,但它不是跨线程竞态的原子锁,不能据此承诺取消与状态提交完全同步。
任务挂在临时分支上,切换 loading 会移除它
如果结构如下,开始加载时把状态从 idle 改为 loading,会使挂着 .task 的 Color.clear 离开这一分支:
switch state {
case .idle:
Color.clear.task {
await load()
}
case .loading:
ProgressView()
default:
Text("其他状态")
}
SwiftUI 让 .task 的生命周期与被修饰的视图关联。它被移除或身份变化时,未完成的任务会被取消。应将任务挂到包含各状态分支、在本次加载期间保持身份的容器上。
下面是一个独立页面示例。点击重新加载会改变 requestID,使 SwiftUI 取消旧任务并启动新任务;单纯切换显示状态不会移除外层 VStack:
import SwiftUI
struct LoadingScreen: View {
private enum LoadState {
case idle, loading, loaded(String), failed(String)
}
@State private var state: LoadState = .idle
@State private var requestID = 0
var body: some View {
VStack {
switch state {
case .idle:
Text("等待加载")
case .loading:
ProgressView()
case .loaded(let text):
Text(text)
case .failed(let message):
Text(message)
}
Button("重新加载") {
requestID += 1
}
}
.task(id: requestID) {
state = .loading
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
try Task.checkCancellation()
state = .loaded("加载完成")
} catch is CancellationError {
return
} catch {
if Task.isCancelled { return }
state = .failed(error.localizedDescription)
}
}
}
}
不要用 state 本身作为这里的任务 ID。加载过程会修改它,可能导致任务自己取消并重新启动。ID 应表达需要重新执行的输入,例如查询词、记录 ID 或明确的重试标记。
网络取消不一定表现为 CancellationError
接入真实网络调用后,底层 API 也可能用自身的错误类型表示取消。比如 Foundation 的 URL 加载错误有 URLError.cancelled。不能把所有 catch 都转成“网络故障”,也不能仅捕获一种错误就假定其他错误都是请求失败。
按所用 API 的契约识别取消,并结合当前任务的 Task.isCancelled 决定是否丢弃结果;真实的认证、解析或服务错误则保留给正常错误处理。对于可能忽略取消的旧回调桥接,还应使用请求编号或输入校验,防止旧请求稍后返回时覆盖新请求结果。不要把“请求已收到取消信号”理解成“服务器已经撤销操作”。
页面退出后仍要继续的工作,需要独立拥有者
.task(id:) 不会因为 ID 没变化就保证跨页面退出继续运行。若任务是只服务当前页面的加载,退出后取消通常符合目的;若是上传或其他需要独立持续的业务,应由生命周期足够长的服务管理任务句柄、重复启动和取消,再由页面观察状态。
直接在 .task 里再创建一个无结构的 Task { ... } 会改变取消和完成关系。外层结束并不等于内层也结束,不应以这种写法隐藏生命周期问题。任务离开页面继续运行也不代表获得了 iOS 后台执行权限,涉及后台传输时还要采用对应系统机制。
检查时分别触发正常完成、加载中退出页面、快速连续重试。正常任务应能到达结果状态;取消任务应尽快退出,不显示成普通失败,也不在旧结果返回后覆盖当前请求。若一改 loading 就取消,优先检查 .task 所附视图和 ID,若取消后继续执行,则优先检查被忽略的错误和缺失的取消检查点。


