WWDC Quick Look 💓 By SwiftGGTeam
Explore concurrency in SwiftUI

Explore concurrency in SwiftUI

观看原视频

Highlight

SwiftUI 的 View 协议默认带 @MainActor 隔离,Shape.path(in:)visualEffectLayoutonGeometryChange 等 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:)visualEffectLayout 协议方法、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 协议声明了 @MainActorColorExtractorView 通过 conformance 推断出 @MainActor 隔离,body@State 都在主线程。
  • model 实例在 view 声明里 @State 创建,Swift 会自动把它带进 @MainActor 隔离,所以 ColorExtractor 这个 @Observable class 不用单独加注解。
  • 点击手势里 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@MainActorView,把它送进非隔离闭包后再访问 @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)。

核心启发

  1. 相信 @MainActor 默认值。Swift 6.2 的新 language mode 会给整个 module 隐式加 @MainActor。升级老 app 时先打开它,删掉视图层手写的 @MainActor——大概率能掉一大批冗余注解。

  2. 闭包要外部值,先用捕获列表拷一份。看到 @Sendable 闭包里访问 self.someState 编译报错,第一反应是 [someState] 而不是去把属性改成 nonisolated。前者只复制必要数据,后者会把数据安全的责任推给你自己。

  3. 把异步逻辑搬出 view,用 state 桥接。视图里只放同步的 state 读写,长任务交给 model/manager,结果通过 @Observable 的同步 mutation 回来。Task 在 view 里的角色仅限于「通知 model 一个 UI 事件」,不要在 Task 里做时间敏感的 UI 改动。

  4. 动画相关的 state 变更必须同步触发withAnimation { isLoading = true } 要写在 onTapGesture 这种同步 callback 里,不要写在 await 之后。await 之后的恢复时机不可控,可能错过帧 deadline。

  5. Mutex 让自定义 class 变 sendable。如果非要在并发任务间共享一个引用类型,Synchronization.Mutex 是 Apple 官方文档推荐的工具,比自己写 lock 更稳。

关联 Session

评论

GitHub Issues · utterances