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。差异中的改动量看似很大,但每项改动通常都很机械。编译器会指出每个需要处理的位置,这正是严格并发检查的目的。
相关洞察
- 构建拥有数千道题的备考应用:在多包 SPM 架构中,这些隔离边界尤其重要。
- 从 SwiftUI 概念到 App Store:数周内完成:在快速推进的功能开发周期中,何时推迟 Swift 6 迁移。
- 修复 App Store 图标被拒问题:在最终质量检查阶段让团队受阻的另一类提交前问题。