tva
← 洞察

Swift 6 并发:实用迁移指南

Swift 5 对数据竞争较为宽松。编译器会对部分问题发出警告,却忽略另一些问题,代码库因此形成了一些习惯:有时调用 DispatchQueue.main.async,有时却遗漏。这些做法一直能运行,直到问题出现。Swift 6 改变了这一约定:所有关于 actor 隔离和 Sendable 协议遵循的警告,都变为编译错误。在消除数据竞争风险,或明确承认这些风险之前,构建无法继续。

实际上,多数现有 SwiftUI 应用并没有隐藏着灾难性的数据竞争。它们往往只是采用了一些实际运行安全、但按 Swift 并发模型在技术上并不严谨的模式。迁移与其说是修复真实缺陷,不如说是让编译器理解代码的安全性。这项工作值得做,但首先要理解错误信息究竟在指出什么。

启用严格并发检查

升级到 Swift 6 语言模式前,可以先以警告形式预览错误。在 Xcode 中,将目标的 Swift 编译器设置 SWIFT_STRICT_CONCURRENCY 设为 complete。这样会显示 Swift 6 将拒绝的所有问题,但不会导致构建失败。切换语言版本前,先系统地处理这些警告。

要在目标的 Build Settings 中启用 Swift 6 语言模式,将 SWIFT_VERSION 设为 6。设置后,会隐式启用 complete 级别的严格并发检查,所有警告都会变为错误。多包项目最好逐个目标升级,从 UI 依赖最少的包开始:先处理纯逻辑和网络包,最后处理 UI 包。

MainActor 边界问题

SwiftUI 迁移中最常见的一类错误如下:

// Error: Main actor-isolated property 'items' can not be
// mutated from a nonisolated context
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() {
        Task {
            let result = await API.load()
            self.items = result  // ← error
        }
    }
}

ObservableObject 的 @Published 属性不会自动获得 @MainActor 隔离,但 SwiftUI 会在主线程上观察它们。当 Task {} 在未指定的执行器上运行,并试图写入 items 时,Swift 6 会发现潜在的数据竞争。 对于这里原样给出的类,Swift 6 编译器会在捕获 self 的 Task 闭包处报告数据竞争风险;只有给 items 本身标注 @MainActor 后,才会出现注释中关于主 actor 隔离的诊断信息。

最清晰的修复方式,是为整个类标注 @MainActor:

@MainActor
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() async {
        let result = await API.load()
        items = result  // ✓ — entire class is MainActor-isolated
    }
}

这明确了类的隔离方式。从后台上下文调用 fetch() 的调用者需要使用 await,这是正确的:与驱动 UI 更新的类交互,应在主 actor 上进行。如果无法为整个类添加标注,也可以显式切换 actor:

func fetch() {
    Task {
        let result = await API.load()
        await MainActor.run {
            self.items = result
        }
    }
}

两种方式都有效。如果类的主要作用是驱动 SwiftUI 视图,为整个类标注 @MainActor 更清晰。在结构良好的应用中,ObservableObject 类几乎总是用于这一目的。

PhotosPicker 与委托隔离

PHPickerViewControllerDelegate 经常引发 Swift 6 错误。在当前 SDK 中,委托方法本身隔离在主 actor 上,但其中 item provider 的加载回调会在后台队列中执行,而该回调中的代码通常需要更新 UI。典型的 Swift 5 写法如下:

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    dismiss(animated: true)
    guard let provider = results.first?.itemProvider else { return }

    provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
        if let image = object as? UIImage {
            self?.imageView.image = image  // ← Swift 6 error
        }
    }
}

在 Swift 6 中,传给 loadObject 的闭包是 @Sendable,这意味着如果 self 不符合 Sendable,闭包就不能捕获它。UIViewController 满足这一条件,因为隔离在主 actor 上的类隐式符合 Sendable;真正的错误是从后台上下文访问了由 @MainActor 隔离的 imageView.image 属性。

修复方式是在闭包内部切换到主 actor:

provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
    guard let image = object as? UIImage else { return }
    Task { @MainActor [weak self] in
        self?.imageView.image = image
    }
}

Task { @MainActor in } 语法创建了一个明确隔离到主 actor 的任务。捕获 image 没有问题,因为 UIImage 符合 Sendable。通过 [weak self] 捕获 self 也没有问题,因为视图控制器隔离在主 actor 上,因此符合 Sendable;weak 只用于避免循环引用,并不能让捕获非 Sendable 类型变得安全。

对于 SwiftUI 较新的 PhotosPicker API,写法更简洁,因为它使用 Transferable:

// In a @MainActor view model:
func handlePickerSelection(_ item: PhotosPickerItem?) async {
    guard let item else { return }
    if let data = try? await item.loadTransferable(type: Data.self) {
        selectedImage = UIImage(data: data)
    }
}

loadTransferable 是 async 调用,因此需要使用 await,而赋值会在函数所隔离的 actor 上执行。如果视图模型标注了 @MainActor,赋值就在主 actor 上完成,无须手动切换 actor。

Sendable 与非 Sendable 类型

如果一个类型可以安全地跨 actor 隔离边界传递,它就符合 Sendable。大致而言,这包括值类型,以及通过同步机制保护状态的类。在 Swift 6 中,只要值跨越隔离边界,编译器就会强制检查这一点。

最常出现的错误模式如下:

// Error: Capture of 'config' with non-sendable type 'RequestConfig'
// in a '@Sendable' closure
struct RequestConfig {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

Task {
    let result = await API.fetch(config)  // config captured here
}

修复通常很直接:为值类型添加 Sendable 协议遵循声明:

struct RequestConfig: Sendable {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

对于成员全部符合 Sendable 的结构体(包括 String、Data 以及所有标准值类型),这一要求很容易满足。编译器会为非公开结构体隐式推断其符合性,因此在实践中,这个错误会出现在从其他模块使用的 public 类型上;显式的 : Sendable 声明则由编译器检查。类的情况更复杂:编译器只能检查以下类的 Sendable 符合性:所有存储属性均为 Sendable 类型的 let 常量的 final 类,或隔离在 @MainActor 等全局 actor 上的类。通过自身内部同步机制保护可变状态的类需要使用 @unchecked Sendable,因为编译器无法验证锁机制。对于始终在主 actor 上访问的 ObservableObject,应优先使用 @MainActor,这样它就会自动符合 Sendable;将 @unchecked Sendable 视为最后的选择,而非首选。

SharedDataProvider 与跨视图模型的共享状态

较大的 SwiftUI 应用常使用共享数据提供程序,通过环境注入多个视图。在 Swift 6 中,这个对象需要显式 actor 隔离,因为访问它的视图主体运行在主 actor 上:

@MainActor
class SharedDataProvider: ObservableObject {
    static let shared = SharedDataProvider()

    @Published var currentUser: User?
    @Published var sessionToken: String?

    func refreshSession() async {
        let token = await AuthClient.refresh()
        sessionToken = token
    }
}

这里的 @MainActor 标注发挥着实际作用:它确保对 currentUser 和 sessionToken 的所有访问都通过主 actor 串行执行,这正是驱动 SwiftUI 视图更新的属性应采用的隔离方式。需要写入这个类的后台任务必须使用 await;它们应该等待主 actor 可用,而非并发写入。

容易忽略的一点是,@MainActor 类上的 static let shared 只能从主 actor 访问。如果任何代码路径试图从后台上下文访问 SharedDataProvider.shared,Swift 6 都会拒绝。修复方式是沿函数调用链显式传递实例(从主 actor 外访问其属性仍需使用 await),或用 await MainActor.run { ... } 包裹访问。

@unchecked Sendable:手动承担同步责任

有些类实际上无法完全符合 Sendable:它们可能封装 C 库、桥接 Objective-C 类型,或以安全但编译器无法理解的方式管理内部状态。对于这些情况,@unchecked Sendable 相当于告诉编译器:“相信我,同步已经处理好了”:

final class AudioPlayer: @unchecked Sendable {
    private let queue = DispatchQueue(label: "audio.player")
    private var _isPlaying = false

    var isPlaying: Bool {
        queue.sync { _isPlaying }
    }

    func play() {
        queue.async { self._isPlaying = true }
    }
}

这不是逃避迁移工作的捷径,而是明确声明:您已经考虑过并发语义,并承担相应责任。应将它用于确实需要内部同步的类,并添加注释,说明提供安全保证的具体机制。

实用的迁移顺序

先启用 SWIFT_STRICT_CONCURRENCY = complete,处理所有警告,再切换到 Swift 6 语言模式。从依赖图中的叶子包开始,也就是不依赖其他内部模块的包。修复这些包,将其升级到 Swift 6 语言模式,再进入下一层。这样可以避免修复下游包时,被迫同时修改仍处于警告模式的上游包。

典型 SwiftUI 代码库的迁移工作,主要是为 ObservableObject 类添加 @MainActor 标注,为跨任务边界传递的值类型声明 Sendable 协议遵循,以及在委托回调中显式切换 actor。差异中的改动量看似很大,但每项改动通常都很机械。编译器会指出每个需要处理的位置,这正是严格并发检查的目的。

相关洞察

延伸阅读