Highlight
SwiftUI 的
View协议默认带@MainActor隔离,Shape.path(in:)、visualEffect、Layout、onGeometryChange等 API 的闭包则被标为@Sendable,由 SwiftUI 调度到后台线程执行。
核心内容
Data race 是 SwiftUI 老用户最熟悉的麻烦:动画卡顿、状态错乱,甚至数据丢失。在 Swift 6 之前,开发者要靠纪律和经验,自己判断哪些代码跑在主线程,哪些不是。一旦写错,bug 几乎只能在运行时复现。
Daniel 用一个「颜色提取 app」做导览,分三站讲解 SwiftUI 在 Swift 并发体系下的位置。第一站 Main Actor Meadows:View 协议本身就标注了 @MainActor,所以你写的 struct、body、@State 全部默认在主线程,连 @Observable 的 model 也不需要手动加注解。第二站 Concurrency Cliffs:Shape.path(in:)、visualEffect、Layout 协议方法、onGeometryChange 这几个 API,SwiftUI 会主动把闭包丢到后台线程算,这些参数因此被标 @Sendable,编译器替你拦截 data race。第三站 Camp:解释为什么 Button 的 action 是同步闭包——因为长任务前后的 UI 状态切换(比如 loading 动画)必须同步发出,异步函数里的 await 会引入 suspension,可能错过下一帧的刷新窗口。
详细内容
整个 session 围绕一个 ColorExtractorView 展开(02:45):
struct ColorExtractorView: View {
@State private var model = ColorExtractor()
var body: some View {
ImageView(
imageName: model.imageName,
isLoading: model.isExtracting
)
EqualWidthVStack {
ColorSchemeView(
isLoading: model.isExtracting,
colorScheme: model.scheme,
extractCount: Int(model.colorCount)
)
.onTapGesture {
guard !model.isExtracting else { return }
withAnimation { model.isExtracting = true }
Task {
await model.extractColorScheme()
withAnimation { model.isExtracting = false }
}
}
Slider(value: $model.colorCount, in: 3...10, step: 1)
.disabled(model.isExtracting)
}
}
}
关键点:
View协议声明了@MainActor,ColorExtractorView通过 conformance 推断出@MainActor隔离,body和@State都在主线程。model实例在 view 声明里@State创建,Swift 会自动把它带进@MainActor隔离,所以ColorExtractor这个@Observableclass 不用单独加注解。- 点击手势里
withAnimation同步切到 loading 状态,再用Task跳进异步上下文调用extractColorScheme(),结果回来后再同步切回。这段闭包同样继承了@MainActor。
后台线程的入口长这样(08:26):
struct SchemeContentView: View {
let isLoading: Bool
@State private var pulse: Bool = false
var body: some View {
ZStack {
Circle()
.scaleEffect(isLoading ? 1.5 : 1)
VStack {
Text(isLoading ? "Please wait" : "Extract")
}
.visualEffect { [pulse] content, _ in
content
.blur(radius: pulse ? 2 : 0)
}
.onChange(of: isLoading) { _, newValue in
withAnimation(newValue ? kPulseAnimation : nil) {
pulse = newValue
}
}
}
}
}
关键点:
visualEffect的闭包标了@Sendable,SwiftUI 可能在后台线程调用它,目的是把高频的视觉计算从主线程剥离(11:12)。- 闭包要读
pulse,直接写pulse等价于self.pulse,编译器会报错——self是@MainActor的View,把它送进非隔离闭包后再访问@MainActor属性会触发 data race 检查。 - 修法是用捕获列表
[pulse]拷贝一份。Bool是值类型且 sendable,闭包里只读这份本地拷贝,从此不再依赖self,绕开了跨 actor 访问(15:52)。
第三站讲为什么不该把异步状态变更直接写进视图(19:15)。await 会插入 suspension point,运行时可以暂停任意时长再恢复,恢复时机可能已经错过这一帧的 deadline,动画因此跟不上手势。Daniel 的建议是用一段 state 做桥:state 发起 Task,async 工作完成后用同步赋值改 state,UI 通过 @Observable 自动响应——视图代码本身保持同步。这样还有副作用:把异步逻辑剥离出去后,单元测试不需要 import SwiftUI 也能跑(23:48)。
核心启发
-
相信
@MainActor默认值。Swift 6.2 的新 language mode 会给整个 module 隐式加@MainActor。升级老 app 时先打开它,删掉视图层手写的@MainActor——大概率能掉一大批冗余注解。 -
闭包要外部值,先用捕获列表拷一份。看到
@Sendable闭包里访问self.someState编译报错,第一反应是[someState]而不是去把属性改成nonisolated。前者只复制必要数据,后者会把数据安全的责任推给你自己。 -
把异步逻辑搬出 view,用 state 桥接。视图里只放同步的 state 读写,长任务交给 model/manager,结果通过
@Observable的同步 mutation 回来。Task在 view 里的角色仅限于「通知 model 一个 UI 事件」,不要在Task里做时间敏感的 UI 改动。 -
动画相关的 state 变更必须同步触发。
withAnimation { isLoading = true }要写在onTapGesture这种同步 callback 里,不要写在await之后。await之后的恢复时机不可控,可能错过帧 deadline。 -
用
Mutex让自定义 class 变 sendable。如果非要在并发任务间共享一个引用类型,Synchronization.Mutex是 Apple 官方文档推荐的工具,比自己写 lock 更稳。
关联 Session
- Embracing Swift concurrency — Swift 并发模型的入门导览,本 session 多次引用作为前置阅读。
- Elevate an app with Swift concurrency — 讲如何把高负载工作从主线程卸下,配合本 session 的 Concurrency Cliffs 食用。
- What’s new in SwiftUI — SwiftUI 2025 新特性总览,包括本 session 涉及的
onScrollVisibilityChange等 API。 - Code-along: Cook up a rich text experience in SwiftUI with AttributedString — 同样以
@Observablemodel +@State桥接异步工作的实战示例。
评论
GitHub Issues · utterances