WWDC21 Session 10019 - Discover concurrency in SwiftUI
本文知识目录
本文属于 WWDC21 中 SwiftUI 与 Concurrency 结合应用的文章。
关于 SwiftUI 可以查看这篇介绍:Introduction to SwiftUI,而 Swift Concurrency 算是今年 WWDC 的重头戏,从使用层面来看,就是引入了 Async / Await 这一语法,但是解决的却是软件工程中最令人头疼的问题之一。
接下来让我们看看 Concurrency 新工具是如何与 SwiftUI 结合的。
Tips:文末有示例代码地址。
引言
随着Swift 5.5 及 SwiftUI 的更新,您将拥有一系列新的并发编程工具。本文将重点介绍在 SwiftUI 中的相关新特性,主要包括三个方面,分别为:Concurrent Data Models
、SwiftUI & MainActor
、New concurrency tools
。我们将通过一个星云图片浏览的 Demo 向您展示在 SwiftUI 中,现有的异步工具存在的问题,并运用新的并发工具来解决这些问题。最后我们会介绍 SwiftUI 中新引入的并发工具。
Concurrent Data Models
在 Swift 中想要使用并发编程,对数据模型有哪些要求呢 ?让我们从零开始造火箭。
首先,定义了 SpacePhoto
,它需要遵循 Codable 和 Identifiable 这两个协议。Codeable 自不必多说,用于将原始数据解析成您定义的数据模型。而 Identifiable 协议则最早是在 SwiftUI 中出现的,在 Swift 5.1 被加入到 Swift 标准库中的。
Identifiable
从字面看应该不难理解,它用来表示所关联的数据结构具有唯一标识。其定义如下:
1public protocol Identifiable {
2 associatedtype ID : Hashable
3 var id: Self.ID { get }
4}
在之前版本的 SwiftUI 中,您使用 ForEach 遍历 Array 时需要提供一个 ID 来标识 Element 的唯一性:
1ForEach(photos.items, id: \.id) { item in
2 PhotoView(photo: item)
3}
当你的 Model 遵循了 Identifiable 协议就可以直接使用:
1ForEach(photos.items) { item in
2 PhotoView(photo: item)
3}
下面是苹果给出的定义,Identifiable 的唯一性是不限定持续时间和使用范围,可以是下面的任意场景:
- 保证始终唯一(例如:UUID)。
- 每个环境永久唯一(例如:database record keys)。
- 在进程的生命周期内是唯一的(例如:global incrementing integers)。
- 在对象的生命周期内是唯一的(例如:object identifiers)。
- 在当前集合中是唯一的(例如:collection index)。
另外有意思的是,Identifiable 将引用语义扩展到值类型,Swift 为 AnyObject 提供了默认实现:
1extension Identifiable where Self: AnyObject {
2 var id: ObjectIdentifier {
3 return ObjectIdentifier(self)
4 }
5}
关于 Identifiable 更详细的讨论强烈推荐 Mattt 的这篇文章 Identifiable。
User Interface
展开下一个 Model 前,预览一下您要做出的星云 Demo 的效果:
ObservableObject
接着使用 ObservableObject
来声明 Photos 用于监听数据的变更。
当有数据变更时,ObservableObject
中声明了 @Published 的属性将会收到 publisher 通过 objectWillChange
发来的通知。
我们先提供一个简单的 PhotoView 来展示每个星云的 title:
1struct PhotoView: View {
2 var photo: SpacePhoto
3 var body: some View {
4 Text(photo.title)
5 }
6}
接着我们在 Catalog 列表中来消费 photos。
逻辑也很简单,仅需在对应属性前增加 @StateObject
来表明 photos 数据是可变化的。
上面的 Preview 效果就是纯文本版本的 Catalog list。这个最终效果是使用了两个特性:
- .listStyle(.plain)
- .listRowSeparator(.hidden)
使用前后比对如下:
SwiftUI & MainActor
在 WWDC20 的 “Data essentials in SwiftUI” 中,Raj 谈到了 SwiftUI 的生命周期,而 run loop 则是驱动该生命周期的工具。在 Swift 5.5 中 run loop 将运行在 MainActor 中。
Actor
关于 Actior 详细信息,可查看 “Protect mutable state with Swift actors”。
这里做简单了解,Actor 是定义成一个遵循 Sendable 的协议:
1public protocol Actor : AnyObject, Sendable { }
2
3public protocol Sendable { }
Swift 提供了 actor
关键字,同时也是一种新的具体名义类型,同 class、struct、enum 等。
1actor Photos {
2 var items: [SpacePhoto]
3 ...
4}
Actor 在概念上类似于在并发环境中可以安全使用的类。 因为 Swift 确保在任何给定时间只能由单个线程访问 actor 内的可变状态,这有助于在编译器级别消除各种严重的错误。
而 main actor 是 actor 的一个全局单例,其声明如下:
1@globalActor public actor MainActor {
2 public static let shared: MainActor
3}
我们通过添加 @MainActor
修饰后,Swift 会确保所修饰的代码会执行在主线程中。
SwiftUI run loop
run loop 过程,应用会不断接收用户事件,更新模型,最终将 SwiftUI 视图呈现到屏幕上。这里把每次循环的更新称作 “ticks of the run loop“。让我们展开这个循环,每个刻度表示一个循环,以便您可以连续查看多个刻度。
在 SwiftUI 中,ObservableObjects 可以通过一些有趣的方式与 SwiftUI run loop 交互。让我们回到 Photos ObservableObject 并查看 updateItems 方法。
在上图表示的是 updateItems
方法的执行在 SwiftUI run loop 中的状态变化,具体如下:
- 蓝色矩形框:表示在一个 run loop 周期内执行
updateItems
方法的耗时。 - 橙色部分:表示获取到新数据后,会通过 publiser 的
objectWillChange
通知观察者有 photos 更新; - 绿色 Snapshot:SwiftUI 在收到数据更新的通知后会对当前状态进行快照,为后续对比准备;
- 紫色部分:表示 items 数据已更新;
- 绿色 tick:在下一个 run loop tick 节点,SwiftUI 同样进行 items 快照,并与之前快照对比。
从 SwiftUI 视图中调用 updateItems 时,这些逻辑均在 MainActor
上被顺序执行。不过上面描述的属于理想状态,很多时候您的数据更新会产生延迟。
上述为发生了主线程 block 的情况,错失一次 tick 的刷新机会,对于用户而言则算是一次障碍。过去解决方式就是使用 dispatch queues
将 updateItems
的逻辑切换到了异步线程执行,而这将导致 run loop 的快照状态产生了变化。
可以看到,在异步更新的 updateItems
方法中,虽然触发了 objectWIllChange
调用,但 items 赋值还未完成,SwiftUI 却已进入下一个 run loop 周期,导致快照对比结果为未更新。而如果您能保证如下状态的顺序执行,则可以避免上述的情况。
objectWillChange
- The state changes
- The run loop ticks
解决方案就是:
Using await
通过 async / await
的使用,使得状态变更能够在主线程被及时感知。上图中跳过的一段 tick 周期就是由于网络延迟等导致的 tick 空转。接下来就是实现 fetchPhotos
方法,逻辑很简单就是遍历 photos 然后获取对应 entity 和 image 即可:
1@MainActor
2class Photos: ObservableObject {
3
4 @Published private(set) var items: [SpacePhoto] = []
5
6 // Updates `items` to a new, random list of photos.
7 func updateItems() async {
8 let fetched = await fetchPhotos()
9 items = fetched
10 }
11
12 // Fetches a new, random list of photos.
13 func fetchPhotos() async -> [SpacePhoto] {
14 var downloaded: [SpacePhoto] = []
15 for query in Photos.keys {
16 let url = SpacePhoto.request(key: query)
17 if let photo = await fetchPhoto(from: url) {
18 downloaded.append(photo)
19 }
20 }
21 return downloaded
22 }
23
24 func fetchPhoto(from url: URL) async -> SpacePhoto? {
25 do {
26 let (data, _) = try await URLSession.shared.data(from: url)
27 let decoder = JSONDecoder()
28 let response = try decoder.decode(NASAResponse.self, from: data)
29 return response.collection.items.randomElement()?.data
30 } catch {
31 print(error)
32 return nil
33 }
34 }
35}
这里提供的代码与官方 Demo 展示的 Photos 数据获取 API 稍有不同,本文采用了 NASA 提供的 image search API。
这里您用 @MainActor
来修饰了 Photos 类,之后 Swift Complier 会保证所有 Photos 的属性和方法都将通过 main actor 来访问。
1updateItems() async ->
2 fetchPhotos() async ->
3 fetchPhoto(from:) async ->
可以看到三个方法都是使用了 async 关键字来声明其为异步执行。而对于 async 声明的方法,对应的需要配上 await 关键字。
最后就差 updateItems 的调用,让我们在 CatalogView 中来完成最后一步。
New concurrency tools
最后一节,我们来介绍几个支持异步更新的 API,为您的程序添加更友好的用户体验。
Task & Refreshable
SwiftUI 为 View 提供了新的入口来执行任务。
1struct CatalogView: View {
2
3 @StateObject private var photos = Photos()
4
5 private var photoKey = [String : SpacePhoto]()
6
7 var body: some View {
8 NavigationView {
9 List {
10 ForEach(photos.items) { item in
11 PhotoView(photo: item)
12 .listRowSeparator(.hidden)
13 }
14 }
15 .navigationTitle("Catalog")
16 .listStyle(.plain)
17 .refreshable {
18 await photos.updateItems()
19 }
20 }
21 .task {
22 await photos.updateItems()
23 }
24 }
25}
当 View 展现屏幕上时候会触发任务的执行,在 View 消失时则会取消对应的任务。其定义如下:
1extension View {
2 @inlinable public func task(_ action: @escaping () async -> Void) -> some View
3}
另外一个 New API 是 refreshable
,本质上是一个 ViewModifier,这里我们给 List 添加上 refreshable
后,它就能响应用户的下拉刷新动作。
AsyncImage
AsyncImage 可以帮助您实现异步下载和展示图片,再结合上 ProgressView 让 Image 在下载过程中作为 placeholder 展示。
Custom Button Action
同 AsyncImage 一样的思路,您可以为 SaveButton 添加 ProgressView,当图片正在保存时以展示 ProgeessView 作为中间状态。
1struct SavePhotoButton: View {
2
3 var photo: SpacePhoto
4 @State private var isSaving = false
5
6 var body: some View {
7 Button {
8 async {
9 isSaving = true
10 await photo.save()
11 isSaving = false
12 }
13 } label: {
14 Text("Saved")
15 .opacity(isSaving ? 0 : 1)
16 .overlay {
17 if isSaving {
18 ProgressView()
19 }
20 }
21 }
22 .disabled(isSaving)
23 .buttonStyle(.bordered)
24 .controlSize(.small) // .large, .medium or .small
25 }
26}
效果如下:
Tips
由于本文脱水过程 Apple 还未提供 Session 中的示例工程,这里作者参照视频中的代码提供了功能完备的 Demo Project,有兴趣的小伙伴自取。记得用 Xcode 13 打开 😊。
总结
这里您看到了 SwiftUI 与 Swift 的并发特性很好地集成在一起,默认情况下为用户提供了最佳行为。
在许多情况下,您只需要使用 await
来使用并发的能力。将 ObservableObject
标记为 @MainActor
,以便更可靠地检查您的对象是否以适合您的视图的方式更新。
- 使用 SwiftUI 的 API 附加功能,以最少的工作量编写安全且高性能的并发应用程序。
- 使用
AsyncImage
并发加载图像。 - 使用
refreshable
修饰符添加到视图层次结构中,以允许用户手动刷新数据。 - 就像您在 Save 按钮上看到的那样,您可以在自己的自定义视图中使用 Swift 的新并发功能。
众所周知,在计算机领域并发是很棘手的一个难题,现在您拥有了管理应用程序中这种复杂性的工具。我们希望您喜欢并了解 Swift 5.5 和 SwiftUI 中出色的新并发工具,我们期待看到您使用它们解决应用程序中棘手问题。