Code-along: Elevate an app with Swift concurrency
Developer Tools 进阶 2m

实战:用 Swift 并发优化 app——从单线程到并行的数据竞争处理

Code-along: Elevate an app with Swift concurrency

2025年6月9日

在 Apple 官方观看视频

一句话判断

这是一场循序渐进的 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
        }
    }
}

最佳实践

  1. 先 profile 再优化。用 Instruments Time Profiler 确认主线程被阻塞的事实和严重程度,再决定用 async/await 还是其他优化手段。没有测量数据的优化是盲目的。

  2. 能不引入并发就不引入。如果可以通过算法优化减少计算量(比如用更快的图片处理库),优先考虑。并发引入了复杂性(数据竞争、调试难度),只有在确实需要时才使用。

  3. @concurrent 是声明方法线程语义的正确方式。比在每个调用点手动创建 Task 更清晰。配合 nonisolated 使用,让整个类型脱离 MainActor。

  4. 数据竞争的通用解决方案是避免共享。问自己:这个可变状态真的需要在并发任务间共享吗?如果每个任务可以有独立实例(比如 ColorExtractor),那就给每个任务一个实例。

  5. SwiftUI 的 Sendable 闭包用捕获列表处理visualEffectonGeometryChange 等闭包会在后台调用。需要访问 @MainActor 状态时,在捕获列表中拷贝值类型数据,不要通过 self 引用。

还有什么值得关注

  • Xcode 26 新项目默认启用 approachable concurrency 配置(main actor by default + upcoming features)
  • SelectedPhoto 类型来自 PhotosUI 框架,遵循 Identifiable
  • LazyHStack 的延迟加载确保只有可见视图触发照片加载任务
  • visualEffect 闭包中 selection 的变更会触发闭包重新执行——捕获的值是闭包执行时的最新值
  • TaskGroup 结果按完成顺序迭代,不保证与输入顺序一致(如果需要顺序,需要额外追踪)
开发工具 Swift