Explore concurrency in SwiftUI
SwiftUI & UI Frameworks 进阶 2m

SwiftUI 中的并发探索:@MainActor 默认、后台优化与数据竞争防护

Explore concurrency in SwiftUI

2025年6月9日

在 Apple 官方观看视频

一句话判断

SwiftUI 的并发模型可以用一句话概括:@MainActor 是编译期默认,Sendable 是后台优化的信号灯。SwiftUI 的注解表达的是运行时语义——View 默认主线程、Shape/visualEffect/Layout 可能后台调用。理解了这一点,你就不需要害怕并发。

这场 Session 讲了什么

Daniel 用一个颜色提取 app 做导览,分三站讲解:

Main Actor Meadows:View 协议声明了 @MainActor 隔离,所有 conforming 类型自动获得主线程保护。@State、body 属性、Observable model 都在 @MainActor 下安全访问。UIKit/AppKit API 也是 @MainActor 隔离的,所以 UIViewRepresentable/NSViewRepresentable 无缝互操作。SwiftUI 的并发注解表达的是运行时语义——框架在运行时确实把你的代码放在主线程执行。

Concurrency Cliffs:SwiftUI 会在后台线程调用你的代码。已知的后台调用点:Shape 的 path(in:)visualEffect 的闭包、Layout 协议的需求方法、onGeometryChange 的闭包。这些 API 用 @Sendable 标注闭包参数,提醒你数据竞争风险。核心策略是”不要在并发任务间共享可变数据”——在闭包捕获列表中拷贝需要的值类型变量,而不是通过 self 引用访问 @MainActor 属性。

Code Camp:SwiftUI 的 action 回调默认是同步的。这是有意的设计——长时间运行的异步任务需要先同步更新 UI 表示加载状态(比如 withAnimation 触发 loading 状态),异步完成后再同步恢复。异步闭包中的状态变更可能因为 suspension delay 错过刷新周期导致动画卡顿。最佳实践是用状态桥接 UI 代码和异步代码:状态发起异步任务,异步结果通过同步状态变更反映回 UI。

值得深挖的点

@MainActor 注解是”下游结果”而非”上游原因”。SwiftUI 的 API 标注 @MainActor 是因为框架在运行时确实把代码放在主线程执行——注解反映的是行为,不是为了类型安全而强加的约束。这和某些框架的”为了编译器检查而标注”有本质区别。理解这一点后,你就知道注解是可信赖的文档。

Swift 6.2 的 main actor by default 模式让大部分类型自动获得 @MainActor 隔离,你不需要手写注解。但这不影响本 Session 的结论——无论是否显式标注,SwiftUI 的运行时行为不变。

visualEffect 闭包的数据竞争解决方案很优雅。问题是:visualEffect 闭包是 @Sendable 的(SwiftUI 会在后台调用),但你需要访问 self.pulse(@MainActor 属性)。解决方案不是”把 pulse 变成非隔离”,而是在捕获列表中拷贝 Bool 值——Bool 是 Sendable 值类型,拷贝后不存在竞争。这种”按值捕获而非按引用捕获”的模式在 SwiftUI 并发场景中非常通用。

异步代码与动画的时间关系是根本性问题。Swift 的 await 创建 suspension point,runtime 可以在 suspend 期间做任意长时间的其他工作。如果动画触发依赖异步完成后的状态变更,可能错过当前刷新周期。这不是 bug,这是异步的本质——你不能精确控制 resume 时机。

代码片段

// 1. visualEffect 中安全访问 @MainActor 状态
struct PulsingView: View {
    @State private var pulse = false

    var body: some View {
        Circle()
            .visualEffect { [pulse] content, proxy in
                // 在捕获列表中拷贝 pulse(Bool 是 Sendable)
                // 不再通过 self 访问 @MainActor 属性
                content
                    .blur(radius: pulse ? 10 : 0)
            }
    }
}

// 2. 同步 action + 异步任务的正确模式
struct ColorExtractor: View {
    @State private var isExtracting = false
    @State private var colors: [Color] = []

    var body: some View {
        Button("Extract") {
            // 同步:先更新 UI 状态
            withAnimation {
                isExtracting = true
            }
            // 再发起异步任务
            Task {
                let result = await extractColors()
                // 同步变更状态回来
                withAnimation {
                    colors = result
                    isExtracting = false
                }
            }
        }
    }
}

// 3. 状态桥接模式
// UI 代码(同步)← 状态桥梁 → 异步代码
@Observable
class ColorModel {
    var colors: [Color] = []
    var isLoading = false

    func extract() {
        isLoading = true
        Task {
            // 异步工作独立于 UI 逻辑
            let result = await performExtraction()
            // 同步状态更新
            isLoading = false
            colors = result
        }
    }
}

最佳实践

  1. 相信 @MainActor 注解。View 的 body 是 @MainActor,你在其中访问的所有成员都是安全的。不需要额外的同步措施。

  2. 在 Sendable 闭包中用捕获列表拷贝值。不要试图把 @MainActor 属性变成非隔离——只需在闭包捕获列表中 [myValue] 拷贝需要的值类型数据。

  3. 异步状态变更是 UI 的实现细节,不是 UI 逻辑。把异步工作放在 model/manager 中,用 Observable 状态通知 UI 更新。视图代码保持同步为主。

  4. 长时间任务的 UI 更新必须同步完成。loading 状态的显示/隐藏用 withAnimation 同步触发。不要在异步函数中做时间敏感的 UI 变更——suspension 时机不可控。

  5. 用单元测试验证异步代码。如果把异步逻辑从视图中分离出来,可以不 import SwiftUI 单独测试。这是状态桥接模式的额外收益。

还有什么值得关注

  • Shape 的 path(in:) 在动画期间从后台线程调用——如果你的 Shape 有动画参数,确保它们是 Sendable 的
  • Layout 协议的需求方法也可能在后台调用
  • @Sendable 闭包中的 self 如果是 View 类型,Swift 认为它是 Sendable(因为 View 是 @MainActor 隔离的)
  • Mutex 是让 class 变成 Sendable 的重要工具
  • 建议尝试在不 import SwiftUI 的情况下为异步代码写单元测试,验证逻辑独立性
SwiftUI Swift