
-
探索 SwiftUI 中的并发机制
探究 SwiftUI 如何利用 Swift 并发机制来构建安全且响应灵敏的 App。探索 SwiftUI 如何默认使用主 Actor,并将工作转移给其他 Actor。了解如何解释并发注解并使用 SwiftUI 的事件循环来管理异步任务,以实现流畅的动画和 UI 更新。你还将了解如何避免数据争用并放心地编写代码。
章节
- 0:00 - 简介
- 2:13 - 主 Actor 牧场
- 7:17 - 并发山峰
- 16:53 - 代码营地
- 23:47 - 后续步骤
资源
- Concurrency
- Mutex
- The Swift Programming Language: Concurrency
- Updating an App to Use Swift Concurrency
相关视频
WWDC25
WWDC23
-
搜索此视频…
大家好 欢迎加入本次旅程 我是导游 Daniel 来自 SwiftUI 团队
我们将一起探索 并发编程与 SwiftUI App 开发的领域
我们聚在这里 是因为听说过那些 被称为数据争用漏洞的危险存在
或许你过去也曾 亲身遭遇过这类问题
比如 App 状态异常 动画卡顿 甚至永久性数据丢失
但不必担心 本次旅程绝对安全 因为借助 Swift 和 SwiftUI 我们正在将数据争用问题 彻底抛在身后 SwiftUI 会以多种方式 并发运行代码 在本次旅程中 你将学习如何 通过 SwiftUI API 的并发注释 识别这些运行方式 最终 我希望你能 更有信心、无所畏惧地 开启自己的 SwiftUI App 开发之旅
Swift 6.2 引入了新的语言模式
会隐式为模块中的所有类型 添加 @MainActor 注释
无论是否启用这种新模式 本次旅程中介绍的所有内容都适用 本次旅程包含三个景点
首先我们会前往 美丽的主 Actor 草地 了解 SwiftUI 如何将主 Actor 作为应用程序的编译时 和运行时默认选择
接着我们将探访并发悬崖 探索 SwiftUI 如何通过 从主线程卸载工作 来帮助 App 避免 UI 卡顿 同时保护我们免受 数据争用漏洞的影响
最后我们将抵达营地 深入思考并发代码与 SwiftUI API 之间的关系
让我们去前往第一站 主 Actor 草地
在旅程中 我想收集一些 自然灵感的配色方案 因此我开发了一个 App 拍摄照片后 我可以选择要提取的颜色数量 按下“提取”按钮后 App 便会从照片中 挑选互补色 并显示在屏幕上
我可以向下滚动来查看 所有提取的配色方案 然后选择最喜欢的导出
对于提取 UI 我创建了 一个结构体 ColorExtractorView
它遵循 SwiftUI View 协议 这个协议声明了 @MainActor 隔离
Swift 使用数据隔离 来理解和验证 所有可变状态的安全性 在整个旅程中 我们会遇到 许多这样的并发概念 如果你刚开始接触 Swift 并发 或者只是需要复习相关知识 请观看讲座 “拥抱 Swift 并发” 在 SwiftUI 中 View 限定在 @MainActor 上 而我让自己的结构体 遵循了 View 协议
因此 ColorExtractorView 也会被 @MainActor 隔离 这条虚线表示推断隔离 也就是说 这个注释 在编译时隐含存在 但并非代码 显式编写的部分
当整体类型被 @MainActor 隔离时 它的所有成员 也会被隐式隔离
这包括实现 View 协议 要求的 body 属性
以及我声明的其他成员 例如这个 @State 变量
在 View 的 body 中 引用 model.scheme model.colorCount 的绑定 等其他成员属性
是被编译器允许的 因为共享 @MainActor 隔离 保证了这些访问是安全的
这也符合直观预期
@MainActor 是 SwiftUI 的 编译时默认设置 这意味着大多数时候 我只需专注构建 App 功能 无需过多考虑并发问题
我无需为了并发编程 而给代码添加注释 代码会自动保证安全
为了给更多代码腾出空间 我将隐藏这些 推断的隔离标记
这种 @MainActor 编译时默认设置 不仅适用于 View 中的同步代码
数据模型的类型无需 任何 @MainActor 注释
因为模型在 View 声明内部实例化 Swift 会确保 模型实例被正确隔离
这个 SchemeContentView 包含一个轻点手势 用于触发颜色提取工作
颜色提取函数是异步的 因此使用 Task 切换到 异步上下文来调用它
由于 View 的 body 被 @MainActor 隔离 传递给这个 Task 的 闭包也会在主线程上运行 这非常方便
@MainActor 隔离是 SwiftUI 的编译时默认设置
让编写视图 变得便捷易上手 但还有另一个 非常实际的原因 AppKit 和 UIKit 中的 API 完全被 @MainActor 隔离
SwiftUI 可与这些框架无缝互操作 例如 UIViewRepresentable 协议 优化了 View 协议 与结构体类似 UIViewRepresentable 被 @MainActor 隔离
因此遵循 UIViewRepresentable 的 类型也是 View 同样被 @MainActor 隔离
UILabel 的构造器需要 @MainActor 隔离 这在 makeUIView 中有效 因为 makeUIView 属于 @MainActor 隔离的可表示类型成员
无需添加 @MainActor 注释 SwiftUI 会为它的 API 添加 @MainActor 注释 因为这反映了 它实现的默认运行时行为
这些注释是框架 运行时预期语义的延伸
而 SwiftUI 的并发注释 表达了它的运行时语义 这与编译时便利性 看似细微差别 实则至关重要 接下来我们很快会看到另一个示例 进一步强化这一概念
接下来的一站 会非常精彩 请系紧安全带 确保电子设备固定好
随着你在 App 开发过程中 引入更多功能 如果主线程工作太多 App 可能会出现 丢帧或卡顿 你可以使用 Task 和结构化并发 从主线程卸载计算 我们的讲座 “利用 Swift 并发提升 App” 提供了一系列 提升 App 性能的实用技巧 建议大家观看
这次旅程聚焦 SwiftUI 如何 利用 Swift 并发 提升 App 性能
先前 SwiftUI 团队已透露 内置动画使用后台线程 计算中间状态
以 SchemeContentView 中的这个圆圈为例
颜色提取作业 开始和结束时 圆圈会变大 然后又通过动画 缩小到原始大小
这里使用了响应 isLoading 属性的 scaleEffect
这个动画的每一帧都需要 1 到 1.5 之间的不同缩放值
这类动画值计算 涉及复杂的数学运算 逐帧计算开销较大 因此 SwiftUI 在后台线程上 执行这个计算 让主线程有更多资源 来处理其他任务
这种优化也适用于 你实现的 API
没错 有时 SwiftUI 会 在主线程之外运行代码
但不必担心 这并不复杂
SwiftUI 是声明式的 与 UIView 不同 遵从 View 协议的结构体 并非占据固定 内存位置的对象
在运行时 SwiftUI 会为 View 创建独立的表示
这种表示提供了多种优化机会 一种重要的优化是 在后台线程评估 部分 View 表示
SwiftUI 会将这项技术 保留用于 需要为开发者执行 大量计算的场景 例如 大多数情况下 这类计算涉及到 一些高频几何计算
Shape 协议就是典型例子
这个协议要求实现 一个返回路径的方法
我创建了一个自定楔形形状 用于在色轮中表示提取的颜色
这个形状实现了路径生成方法
每个楔形都有独特的朝向 当这个楔形形状 执行动画效果时 我编写的路径方法 会从后台线程接收调用
SwiftUI 为开发者执行的 另一种自定逻辑是闭包参数
圆圈中间是 这些模糊的文本
为了实现这一点 我在 SwiftUI Text 上使用了 visualEffect
当 pulse 值在 true 和 false 之间切换时 它会在两个值之间 改变模糊半径 视图修饰符 visualEffect 接收一个闭包 用于定义 对主题视图 (即文本) 的效果 视觉效果可能很复杂 渲染成本也很高
因此 SwiftUI 可以选择 从后台线程调用这个闭包
以上是两个可能从 后台线程调用代码的 API
我们来快速了解更多案例
Layout 协议可以在主线程之外 调用它的需求方法 与 visualEffect 类似 onGeometryChange 的第一个参数 是一个闭包 它也可以 从后台线程调用
这种使用后台线程的 运行时优化 长期以来一直 是 SwiftUI 的一部分
SwiftUI 可以通过 Sendable 注释向编译器 和开发者 表达 这种运行时行为 (即语义)
而 SwiftUI 的并发注释 再次表达了它的运行时语义
在单独的线程上运行代码 可以释放主线程 使 App 响应更灵敏
而 Sendable 关键词 用于提醒开发者 当需要从 @MainActor 共享数据时 可能存在数据争用风险
可以把 Sendable 想象成悬崖边 小径上的警告标志 上面写着 “危险!请勿在此竞速!”
嗯 这个比喻 可能有点夸张 实际上 Swift 会可靠地 发现代码中任何潜在的争用条件 并通过编译器错误提醒开发者 避免数据争用的最佳策略 是完全不在 并发任务之间共享数据
当 SwiftUI API 要求开发者 编写 Sendable 函数时 这个框架会将所需的大部分变量 作为函数参数提供 这里有个简单示例
之前在 ColorExtactorView 中 有个细节我没展示 色轮和滑块 具有相同的宽度 这要归功于 EqualWidthVStack 类型
EqualWidthVStack 是一种自定布局
我们不关注它如何进行布局 重点是我能够使用 SwiftUI 穿入的参数 完成所有复杂计算 而不触碰任何外部变量
但如果确实需要访问 Sendable 函数之外的一些变量呢?
在 SchemeContentView 中 我需要 此 visualEffect 中的 pulse 状态
但 Swift 提示存在 潜在的数据争用情况
让我们仔细看看 编译器错误在提示什么
pulse 变量是 self.pulse 的缩写 这是在 Sendable 闭包中共享 @MainActor 隔离变量时的 常见场景
Self 是一个 View 它在主 Actor 上隔离 这是我们的起点 我们的最终目标是 在 Sendable 闭包中 访问 pulse 变量 要实现这一点 必须完成两件事
首先 self 的值 必须跨越边界 从主 Actor 到后台 线程代码区域
在 Swift 中 这称为 将 self 变量发送到 后台线程
这要求 self 的 类型为 Sendable
现在 self 出现了正确的位置 我们想在这个非隔离区域 读取它的 pulse 属性 除非 pulse 属性并不 限定在任何 Actor 上 否则编译器不会允许这样做
再来看这段代码 因为 self 是一个 View 所以它受到 @MainActor 保护
因此编译器认为它是 Sendable 类型
正因如此 Swift 可以妥善处理这种情况 对 self 的引用 从 @MainActor 隔离 跨越到了 Sendable 闭包中
实际上 Swift 警告的是 尝试访问 pulse 属性的行为
当然 我们知道 作为 View 的成员 pulse 被 @MainActor 隔离
因此编译器告诉我 即使可以将 self 发送到那里 访问由 @MainActor 隔离的 pulse 属性是不安全的
要修复这个编译错误 可以避免通过 View 引用 来读取这个属性
我编写的视觉效果 不需要这个视图的全部值 只需要知道 pulse 是 true 还是 false 可以在闭包的捕获列表中 拷贝 pulse 变量 并引用这个副本 这样就不再需要 将 self 发送到这个闭包中
而是发送 pulse 的副本 由于 Bool 是简单值类型 它是可发送的
这个副本仅存在于 这个函数作用域内 在这里访问不会 导致任何数据争用问题
在这个示例中 我们无法在 Sendable 闭包中访问 pulse 变量 因为它受到全局 Actor 保护
另一种实现策略是 将所有读取操作 声明为 nonisolated
好了 各位 你们已经来到营地 让我们坐下来谈谈 如何组织并发代码
有经验的 SwiftUI 开发者可能 已经注意到 大多数 SwiftUI 的 API 比如按钮的动作回调 都是同步的
要调用并发代码 首先需要通过 Task 切换到异步上下文
但是为什么 Button 不接受异步闭包呢?
同步更新对良好的 用户体验至关重要 如果 App 包含长时间运行的任务 且用户必须等待结果 这一点尤为重要
在使用 async 函数启动 长时间运行的任务之前 务必要更新 UI 以指示任务正在进行
这种更新应该是同步的 尤其是当它需要触发一些 对时间敏感的动画时
试想一下 如果我让 语言模型帮我提取颜色 这个提取过程需要一段时间 因此在我的 App 中 我使用 withAnimation 来同步触发 各种加载状态
在任务完成后 我会通过另一个同步状态变更 来反转这些加载状态
SwiftUI 的动作回调 接受同步闭包 这对于设置 UI 更新所必需的 例如我的加载状态
另一方面 异步函数需要额外考虑 尤其是在处理动画时 现在我们来深入探讨一下
在我的 App 中 我可以向上滚动 显示之前配色方案的历史记录 当每个配色方案出现在屏幕上时 我希望它的颜色 通过一些动画逐渐显示
视图修饰符 onScrollVisibilityChange 会在配色方案出现 在屏幕上时触发事件 一旦发生这种情况 我就将状态变量设置为 true 以触发动画 使每种颜色的 Y 偏移量随动画更新
作为 UI 框架 为了每帧 都能打造流畅的交互体验 SwiftUI 需要面对 设备要求特定 屏幕刷新率的现实
当我希望代码对滚动等 连续手势做出反应时 这是重要的背景知识 让我们将这段代码放在时间线上
我将使用这个绿色三角形 来标记 SwiftUI 调用 onScrollVisibilityChange 的时刻 蓝色圆圈标记我 通过状态变动 触发动画的时刻
在这种设置下 这种变动是否与 手势回调在同一帧发生 会在视觉上产生很大差异
假设我想在动画变动之前 添加一些异步工作 我将用橙色线条标记异步工作 开始的时刻并等待它完成
在 Swift 中 等待 async 函数 会创建一个暂停点
Task 接受 async 函数作为参数
当编译器看到 await 时 会将 async 函数分成两部分
执行第一部分后 Swift 运行时可以暂停这个函数 并在 CPU 上执行一些其他工作 这可以持续任意时间 然后运行时恢复 原始 async 函数 并执行它的后半部分
这个过程会在函数中 每次出现 await 时重复
回到我们的时间线 这种暂停可能意味着我的任务闭包 要很久之后才会恢复
超过设备规定的 刷新截止时间
对于用户来说 这意味着 动画看起来滞后且不同步 因此 async 函数中的变动 可能无助于实现目标
SwiftUI 默认提供同步回调 这有助于避免 异步代码的意外暂停 在同步动作闭包中 更新 UI 很容易正确实现 你始终可以选择使用 Task 来选择加入异步上下文
动画等对时间敏感的逻辑 需要 SwiftUI 的 输入和输出是同步的 可观察属性的同步变动 和同步回调 是与框架交互的最自然方式 出色的用户体验不一定 需要大量自定并发逻辑 同步代码是许多 App 的 绝佳起点和端点
另一方面 如果 App 会并发执行大量工作 尝试找到 UI 代码和 非 UI 代码之间的边界
最好将异步工作的 逻辑与视图逻辑分离
你可以使用一段状态作为桥梁 这个状态将 UI 代码 与异步代码分离
它可以启动异步任务
当一些异步工作完成时 对状态执行同步变动 以便 UI 可以对这个变更 做出反应并更新
这样 UI 逻辑 大多是同步的
额外的好处是 你会发现 为异步代码编写测试更容易 因为它现在 独立于 UI 逻辑
视图仍可使用 Task 来切换到异步上下文
但请尽量让这个异步 上下文中的代码保持简单 它的作用是 将 UI 事件通知给模型
找到需要大量时间敏感变更的 UI 代码与长时间运行的 异步逻辑之间的边界 是改善 App 结构的好方法
它可以帮助你保持 视图同步和响应迅速 良好组织非 UI 代码 也很重要
借助我在这个营地中展示的技巧 你将有更大的自由度来做到这一点
Swift 6.2 提供了出色的 默认 Actor 隔离设置 如果你已有 App 不妨尝试一下 你将能够删除大多数 @MainActor 注释
Mutex 是使类可发送的重要工具 请查看官方文档 了解具体方法
挑战自己 为 App 中的 异步代码编写一些单元测试 看看你是否可以在不导入 SwiftUI 的情况下做到这一点
好了 各位 这就是 SwiftUI 利用 Swift 并发的方式 帮助你构建快速 且无数据争用的 App
在本次旅程结束时 希望大家已经对 SwiftUI 中的并发 有了扎实的心智模型
感谢你参与本次旅程 祝你在编程征程中收获精彩的成就
-
-
2:45 - UI for extracting colors
// UI for extracting colors struct ColorScheme: Identifiable, Hashable { var id = UUID() let imageName: String var colors: [Color] } @Observable final class ColorExtractor { var imageName: String var scheme: ColorScheme? var isExtracting: Bool = false var colorCount: Float = 5 func extractColorScheme() async {} } struct ColorExtractorView: View { @State private var model = ColorExtractor() var body: some View { ImageView( imageName: model.imageName, isLoading: model.isExtracting ) EqualWidthVStack { ColorSchemeView( isLoading: model.isExtracting, colorScheme: model.scheme, extractCount: Int(model.colorCount) ) .onTapGesture { guard !model.isExtracting else { return } withAnimation { model.isExtracting = true } Task { await model.extractColorScheme() withAnimation { model.isExtracting = false } } } Slider(value: $model.colorCount, in: 3...10, step: 1) .disabled(model.isExtracting) } } } }
-
5:55 - AppKit and UIKit require @MainActor: an example
// AppKit and UIKit require @MainActor // Example: UIViewRepresentable struct FancyUILabel: UIViewRepresentable { func makeUIView(context: Context) -> UILabel { let label = UILabel() // customize the label... return label } }
-
6:42 - UI for extracting colors
// UI for extracting colors struct ColorScheme: Identifiable, Hashable { var id = UUID() let imageName: String var colors: [Color] } @Observable final class ColorExtractor { var imageName: String var scheme: ColorScheme? var isExtracting: Bool = false var colorCount: Float = 5 func extractColorScheme() async {} } struct ColorExtractorView: View { @State private var model = ColorExtractorModel() var body: some View { ImageView( imageName: model.imageName, isLoading: model.isExtracting ) EqualWidthVStack(spacing: 30) { ColorSchemeView( isLoading: model.isExtracting, colorScheme: model.scheme, extractCount: Int(model.colorCount) ) .onTapGesture { guard !model.isExtracting else { return } withAnimation { model.isExtracting = true } Task { await model.extractColorScheme() withAnimation { model.isExtracting = false } } } Slider(value: $model.colorCount, in: 3...10, step: 1) .disabled(model.isExtracting) } } } }
-
8:26 - Animated circle, part of color scheme view
// Part of color scheme view struct SchemeContentView: View { let isLoading: Bool @State private var pulse: Bool = false var body: some View { ZStack { // Color wheel … Circle() .scaleEffect(isLoading ? 1.5 : 1) VStack { Text(isLoading ? "Please wait" : "Extract") if !isLoading { Text("^[\(extractCount) color](inflect: true)") } } .visualEffect { [pulse] content, _ in content .blur(radius: pulse ? 2 : 0) } .onChange(of: isLoading) { _, newValue in withAnimation(newValue ? kPulseAnimation : nil) { pulse = newValue } } } } }
-
13:10 - UI for extracting colors
// UI for extracting colors struct ColorExtractorView: View { @State private var model = ColorExtractor() var body: some View { ImageView( imageName: model.imageName, isLoading: model.isExtracting ) EqualWidthVStack { ColorSchemeView( isLoading: model.isExtracting, colorScheme: model.scheme, extractCount: Int(model.colorCount) ) .onTapGesture { guard !model.isExtracting else { return } withAnimation { model.isExtracting = true } Task { await model.extractColorScheme() withAnimation { model.isExtracting = false } } } Slider(value: $model.colorCount, in: 3...10, step: 1) .disabled(model.isExtracting) } } } }
-
13:47 - Part of color scheme view
// Part of color scheme view struct SchemeContentView: View { let isLoading: Bool @State private var pulse: Bool = false var body: some View { ZStack { // Color wheel … Circle() .scaleEffect(isLoading ? 1.5 : 1) VStack { Text(isLoading ? "Please wait" : "Extract") if !isLoading { Text("^[\(extractCount) color](inflect: true)") } } .visualEffect { [pulse] content, _ in content .blur(radius: pulse ? 2 : 0) } .onChange(of: isLoading) { _, newValue in withAnimation(newValue ? kPulseAnimation : nil) { pulse = newValue } } } } }
-
17:42 - UI for extracting colors
// UI for extracting colors struct ColorExtractorView: View { @State private var model = ColorExtractor() var body: some View { ImageView( imageName: model.imageName, isLoading: model.isExtracting ) EqualWidthVStack { ColorSchemeView( isLoading: model.isExtracting, colorScheme: model.scheme, extractCount: Int(model.colorCount) ) .onTapGesture { guard !model.isExtracting else { return } withAnimation { model.isExtracting = true } Task { await model.extractColorScheme() withAnimation { model.isExtracting = false } } } Slider(value: $model.colorCount, in: 3...10, step: 1) .disabled(model.isExtracting) } } } }
-
18:55 - Animate colors as they appear by scrolling
// Animate colors as they appear by scrolling struct SchemeHistoryItemView: View { let scheme: ColorScheme @State private var isShown: Bool = false var body: some View { HStack(spacing: 0) { ForEach(scheme.colors) { color in color .offset(x: 0, y: isShown ? 0 : 60) } } .onScrollVisibilityChange(threshold: 0.9) { guard !isShown else { return } withAnimation { isShown = $0 } } } }
-
-
- 0:00 - 简介
SwiftUI 利用 Swift 并发来帮助开发者构建快速且无数据争用的 App。Swift 6.2 引入了一种新的语言模式,该模式会隐式为模块中的所有类型添加 @MainActor 注释。SwiftUI 会以多种方式并发运行代码,并通过其 API 提供并发注释,以帮助开发者识别和管理并发。本次讲座将重点讲解 SwiftUI 如何处理并发以避免数据争用并提升 App 性能。
- 2:13 - 主 Actor 牧场
由于 SwiftUI 的 View 协议被 @MainActor 隔离,这使得这种隔离行为成为 UI 代码的编译时默认设置。这意味着大多数 UI 代码在主线程上隐式运行,从而简化了开发流程并确保与 UIKit 和 AppKit 的兼容性。View 中实例化的数据模型也会自动隔离。SwiftUI 的 @MainActor 注释反映的是其运行时行为和预期语义,而不仅仅是编译时的便利性。
- 7:17 - 并发山峰
SwiftUI 使用后台线程执行计算密集型任务,如动画和形状计算 (例如:“Shape”协议的“path”方法、“visualEffect”闭包、“Layout”协议、“onGeometryChange”闭包),以避免 UI 卡顿。在主 Actor 和后台线程之间共享数据时,“Sendable”注释会指示潜在的数据争用情况。为避免数据争用,请尽量减少数据共享。如果确实需要共享,应创建数据副本。
- 16:53 - 代码营地
SwiftUI 的动作回调在设计上是同步的,以确保 UI 能够立即更新,尤其是针对动画和载入状态等场景。长时间运行的任务应异步启动,但 UI 更新仍应保持同步。可将 UI 逻辑与非 UI (异步) 逻辑区分开来,并使用状态作为桥梁,从而在异步任务完成后触发 UI 更新。保持视图中的异步代码简洁明了,并专注于将 UI 事件传达给模型。时间敏感的逻辑要求 SwiftUI 的输入和输出保持同步。
- 23:47 - 后续步骤
Swift 6.2 提供了出色的默认 Actor 隔离设置。如果你已有 App,不妨尝试一下。你将能够删除大多数 @MainActor 注释。Mutex 是使类可发送的重要工具。请查看官方文档了解具体方法。挑战自己,为 App 中的异步代码编写一些单元测试。看看你是否可以在不导入 SwiftUI 的情况下完成编写。