实战:用 Swift 并发优化 app——从单线程到并行的数据竞争处理
Code-along: Elevate an app with Swift concurrency
2025年6月9日
一句话判断
这是一场循序渐进的 Swift 并发实战教程——从单线程贴纸 app 出发,通过 Instruments 发现 hang,逐步引入 async/await、@concurrent、async let 和 TaskGroup,同时处理了两个真实的数据竞争场景。如果你对 Swift 6 的数据竞争安全检查还比较陌生,这场的错误信息分析过程比最终代码更有价值。
这场 Session 讲了什么
Sima 构建了一个从照片提取贴纸的 app,分阶段优化:
异步加载照片:PhotosPickerItem 的 loadTransferable 是异步 API,用 SwiftUI 的 task modifier 启动。await 标记 suspension point,主线程在等待期间可以响应 UI 事件。LazyHStack 确保只对可见视图启动加载任务。
移出主线程:图片处理(贴纸提取 + 主色计算)阻塞主线程超过 10 秒导致严重 hang。解决方案:PhotoProcessor 标记 nonisolated(脱离 MainActor),process 方法加 @concurrent + async(强制切换到后台线程)。
并行化:贴纸提取和颜色提取相互独立,用 async let 并行执行,利用多核 CPU。引入第一个数据竞争——ColorExtractor 的可变像素缓冲区被多个并发任务共享。解决方案:将 ColorExtractor 从存储属性移到 extractColors 函数的局部变量,每个任务获得独立实例。
SwiftUI 中的数据竞争:visualEffect 闭包是 @Sendable 的(SwiftUI 在后台调用),访问 self.pulse(@MainActor 属性)触发编译器错误。解决方案:在闭包捕获列表中拷贝 selection 值。
TaskGroup:处理所有照片时任务数量不确定,用 TaskGroup 动态创建子任务。Group 遵循 AsyncSequence,结果按完成顺序迭代存储到 dictionary。
值得深挖的点
@concurrent 属性是 Swift 6.1 的新特性。它告诉 Swift “这个方法总是切换到后台线程执行”,与 nonisolated 类型标注配合使用。这比手动在每个调用点创建 Task 更清晰——方法本身的线程语义在声明处就明确了。
数据竞争的两阶段处理策略值得学习。第一阶段(并行化引入的 bug):ColorExtractor 的可变状态被并发访问——解决方案是”不要共享”,每个任务创建独立实例。第二阶段(SwiftUI API 引入的 bug):visualEffect 闭包访问 @MainActor 状态——解决方案是”按值拷贝”。两个场景的共同原则:避免共享可变状态。
Instruments 驱动的优化流程是正确的工作方式。先用 Time Profiler 发现主线程 hang(PhotoProcessor 阻塞 10 秒),优化后再次 profiling 验证 hang 消失。“先测量,再优化,再验证”——不要凭直觉引入并发。
async let 的适用条件是”已知数量的独立任务”。两个操作(贴纸 + 颜色)用 async let。未知数量(处理所有照片)用 TaskGroup。如果只有一个异步操作,直接 await 就行,不需要并行化。
代码片段
// 1. @concurrent + nonisolated 移出主线程
nonisolated struct PhotoProcessor {
func process(data: Data) async throws -> ProcessedPhoto {
// 这个方法在后台线程执行
}
// 使用 @concurrent 确保切换到后台线程
@concurrent
func extractSticker(from data: Data) async throws -> Image { ... }
@concurrent
func extractColors(from data: Data) async throws -> [Color] { ... }
}
// 2. async let 并行化
@concurrent
func process(data: Data) async throws -> ProcessedPhoto {
async let sticker = extractSticker(from: data)
async let colors = extractColors(from: data)
return ProcessedPhoto(
image: try await sticker,
colors: try await colors
)
}
// 3. 修复数据竞争:ColorExtractor 从共享属性变为局部变量
nonisolated struct PhotoProcessor {
// 之前:共享实例导致数据竞争
// let colorExtractor = ColorExtractor()
@concurrent
func extractColors(from data: Data) async throws -> [Color] {
// 现在:每个调用创建独立实例
let colorExtractor = ColorExtractor()
return try await colorExtractor.extract(from: data)
}
}
// 4. TaskGroup 处理不定数量任务
func processAllPhotos() async {
await withTaskGroup(of: (UUID, ProcessedPhoto).self) { group in
for photo in selection {
if processedPhotos[photo.id] != nil { continue }
group.addTask {
let data = try await photo.item.loadTransferable(of: Data.self)
let processed = try await processor.process(data: data!)
return (photo.id, processed)
}
}
for await (id, photo) in group {
processedPhotos[id] = photo
}
}
}
最佳实践
-
先 profile 再优化。用 Instruments Time Profiler 确认主线程被阻塞的事实和严重程度,再决定用 async/await 还是其他优化手段。没有测量数据的优化是盲目的。
-
能不引入并发就不引入。如果可以通过算法优化减少计算量(比如用更快的图片处理库),优先考虑。并发引入了复杂性(数据竞争、调试难度),只有在确实需要时才使用。
-
@concurrent 是声明方法线程语义的正确方式。比在每个调用点手动创建 Task 更清晰。配合 nonisolated 使用,让整个类型脱离 MainActor。
-
数据竞争的通用解决方案是避免共享。问自己:这个可变状态真的需要在并发任务间共享吗?如果每个任务可以有独立实例(比如 ColorExtractor),那就给每个任务一个实例。
-
SwiftUI 的 Sendable 闭包用捕获列表处理。
visualEffect、onGeometryChange等闭包会在后台调用。需要访问 @MainActor 状态时,在捕获列表中拷贝值类型数据,不要通过 self 引用。
还有什么值得关注
- Xcode 26 新项目默认启用 approachable concurrency 配置(main actor by default + upcoming features)
- SelectedPhoto 类型来自 PhotosUI 框架,遵循 Identifiable
- LazyHStack 的延迟加载确保只有可见视图触发照片加载任务
- visualEffect 闭包中 selection 的变更会触发闭包重新执行——捕获的值是闭包执行时的最新值
- TaskGroup 结果按完成顺序迭代,不保证与输入顺序一致(如果需要顺序,需要额外追踪)