探索 SwiftUI 中的并发
Discover concurrency in SwiftUI
2021年6月10日
一句话判断
task 修饰符 + @MainActor 是 SwiftUI 对 async/await 的回答——它比你手写任何 Task 管理代码都更安全,但你需要理解它的取消语义才能用好。
这场 Session 讲了什么
Swift Concurrency(async/await、Actor、Task)在 WWDC 2021 正式发布,而这场 Session 专门讲解 SwiftUI 如何与这套并发模型整合。核心内容是三个新 API:.task 修饰符、@MainActor 在视图中的自动传播、以及 AsyncStream 作为 Timer 和 Delegate 的替代方案。
Session 用一个天气 app 作为贯穿示例。启动时加载天气数据、定时刷新、用户拖动地图时取消旧请求并发起新请求——这些场景用旧版的 DispatchQueue + URLSession 回调写起来很痛苦,但用 .task + async let 写起来就像同步代码一样直觉。
特别强调了取消语义。SwiftUI 视图消失时,.task 中的异步操作会被自动取消。如果你的网络请求不支持 cooperative cancellation,取消操作不会真正停止网络传输,只是不再处理结果。Session 建议在自定义的 async 函数中检查 Task.isCancelled,或者使用 withTaskCancellationHandler 来注册清理逻辑。
值得深挖的点
.task(id:) 的 id 变化时的行为
.task 有一个 id 参数,当 id 值变化时,SwiftUI 会取消当前正在执行的 task 并重新启动一个新的。这非常适合「参数驱动」的数据加载场景:比如用户选择了一个城市,id 变化,旧的天气数据请求被取消,新的请求自动发起。这比 onChange + 手动 Task.cancel 的模式简洁得多。但要注意,id 的变化会触发 task 的重新执行,如果你的 id 是一个频繁变化的值(比如滑块位置),这会导致大量的请求-取消循环。
@MainActor 的传播边界
SwiftUI 的 View protocol 已经被标记为 @MainActor,这意味着 body 的计算、@State 的读写、所有修饰符的闭包都自动运行在主线程上。但如果你在 .task 中调用的 async 函数没有被标记为 @MainActor,它会在后台执行。这其实是好事——数据加载应该在后台进行,只有 UI 更新才需要回到主线程。@MainActor 的自动 hop 会在 await 恢复时发生,不需要你手动切换线程。
代码片段
// 使用 .task(id:) 实现参数驱动的数据加载
struct WeatherView: View {
let cityId: String
@State private var weather: WeatherData?
var body: some View {
VStack {
if let weather = weather {
Text("\(weather.temperature)°C")
.font(.largeTitle)
Text(weather.description)
} else {
ProgressView("加载中...")
}
}
// cityId 变化时自动取消旧请求,发起新请求
.task(id: cityId) {
do {
weather = try await fetchWeather(cityId: cityId)
} catch is CancellationError {
// 被取消时不显示错误
} catch {
print("加载失败: \(error)")
}
}
}
func fetchWeather(cityId: String) async throws -> WeatherData {
let url = URL(string: "https://api.weather.com/\(cityId)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(WeatherData.self, from: data)
}
}
// 用 AsyncStream 替代 Timer
struct LiveClockView: View {
@State private var currentTime = Date()
var body: some View {
Text(currentTime, style: .time)
.font(.system(.title, design: .monospaced))
.task {
// 创建一个每秒发送当前时间的 AsyncStream
let stream = AsyncStream<Date> { continuation in
let timer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { _ in
continuation.yield(Date())
}
continuation.onTermination = { _ in
timer.invalidate()
}
}
// 迭代 stream,自动在主线程更新 UI
for await time in stream {
currentTime = time
}
}
}
}
// 并发加载多个数据源
struct DashboardView: View {
@State private var profile: UserProfile?
@State private var feed: [Post]?
@State private var notifications: [Notification]?
var body: some View {
DashboardContent(profile: profile, feed: feed, notifications: notifications)
.task {
// 三个请求并发执行,全部完成后才更新
async let p = loadProfile()
async let f = loadFeed()
async let n = loadNotifications()
do {
// 并发等待,任一请求失败则全部失败
(profile, feed, notifications) = try await (p, f, n)
} catch {
print("加载失败: \(error)")
}
}
}
}
最佳实践
不要在 .task 中做无限循环的网络轮询。如果你的 app 需要实时数据更新,用 WebSocket 或者 AsyncStream + Timer 的组合,而不是在 .task 里写 while true { await ... }。无限循环会阻止 SwiftUI 在视图消失时正确清理资源。
对于复杂的数据加载逻辑,建议把异步操作封装到 ViewModel 或 Service 层,视图层只调用一个 async 方法。这样方便单元测试,也避免了视图代码膨胀。
还有什么值得关注
.task的优先级默认继承自创建它的上下文,可以通过Task(priority: .userInitiated)手动调整。@Environment值在.task闭包中可以直接访问,不需要额外捕获。withTaskGroup可以在 SwiftUI 的.task中使用,用来管理一组并发的子任务。