使用MainActor属性在主队列上的自动调度UI更新
在苹果平台上并发的一个挑战是应用的UI在很大程度上只能在主线程上更新。 因此,无论何时我们(直接或间接)在后台线程上执行任何工作,我们总是必须确保在访问与渲染UI相关的任何属性、方法或函数之前跳回主队列。
理论上,这听起来很简单。在实践中,在后台队列中执行UI更新是非常常见的,这可能会导致小毛病,甚至使应用程序处于不一致或未定义的状态,这反过来可能导致崩溃和其他错误。
手动主队列调用
到目前为止,这个问题最常用的解决方案是将所有与UI相关的更新打包到对DispatchQueue的异步调用中。当在后台队列有任何机会,这些更新将会触发,例如在这样的情况下:
class ProfileViewController: UIViewController {
private let userID: User.ID
private let loader: UserLoader
private lazy var nameLabel = UILabel()
private lazy var biographyLabel = UILabel()
...
private func loadUser() {
loader.loadUser { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let user):
self?.nameLabel.text = user.name
self?.biographyLabel.text = user.biography
case .failure(let error):
self?.showError(error)
}
}
}
}
}
虽然上面的模式确实有效,但必须始终记住执行DispatchQueue.main调用肯定不是很方便,也很容易出错。 在某些情况下尤其如此,比如在观察 Combine publishers 时,或者在实现某些委托方法时,给定的代码段是否确实在后台队列上运行可能不是很明显。
main actor
值得庆幸的是,Swift 5.5引入了一个可能会变得更加健壮和几乎完全自动的解决方案,以此来解决这个非常常见的问题——main actor。
请注意,Swift 5.5作为Xcode 13的一部分正在测试中。
那么,我们究竟如何在那个main actor上***run***我们的代码呢? 我们要做的第一件事就是让异步代码使用新的async/await模式,这也是Swift 5.5并发特性套件的一部分。在这种情况下,这可以通过创建一个新的、 async/await-powered的loadUser方法来实现,也就是我们上面的视图控制器正在调用的方法——它只需要使用Swift的新的continuation API封装一个默认的、基于completion handler-based的版本:
extension UserLoader {
func loadUser() async throws -> User {
try await withCheckedThrowingContinuation { continuation in
loadUser { result in
switch result {
case .success(let user):
continuation.resume(returning: user)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
To learn more about the above pattern, check out my friend Vincent Pradeilles’ WWDC by Sundell & Friends article “Wrapping completion handlers into async APIs”.
有了上述条件,我们现在可以使用一个async block,连同Swift的标准do, try catch错误处理机制,从我们的ProfileViewController中调用loader新的async/await驱动API:
class ProfileViewController: UIViewController {
...
private func loadUser() {
async {
do {
let user = try await loader.loadUser()
nameLabel.text = user.name
biographyLabel.text = user.biography
} catch {
showError(error)
}
}
}
}
等等,上面的代码如何在不调用DispatchQueue.main.async的情况下工作呢? 如果loadUser在后台队列上执行它的工作,这是否意味着我们的UI现在将错误地地更新在后台队列上?
这就是main actor登场的地方。如果我们看一下UILabel和UIViewController的声明,我们可以看到它们都被添加了新的@MainActor属性:
@MainActor class UILabel: UIView
@MainActor class UIViewController: UIResponder
这意味着,当使用Swift的新并发系统时,这些类(及其任何子类,包括我们的ProfileViewController)上的所有属性和方法将自动在主队列上设置、调用和访问。所有这些调用都将通过系统提供的MainActor自动路由,它总是在主线程上执行所有工作——完全消除了我们手动调用DispatchQueue.main.async的需要。Really cool!
自定义UI相关类
但是,如果我们从事的是完全自定义的类型,我们也希望获得上述类型的能力,那会怎么样呢? 例如,当实现一个在SwiftUI视图中使用的ObservableObject时,我们需要确保只在主队列中分配它的@Published-marked属性,所以如果我们也能在这些情况下利用MainActor是不是很好?
好消息是—— we can! 就像很多UIKit的内置类现在都被@MainActor注解了一样,我们也可以把这个属性应用到我们自己的类上——给它们同样的自动主线线程调度行为:
@MainActor class ListViewModel: ObservableObject {
@Published private(set) var result: Result<[Item], Error>?
private let loader: ItemLoader
...
func load() {
async {
do {
let items = try await loader.loadItems()
result = .success(items)
} catch {
result = .failure(error)
}
}
}
}
但有一点需要特别指出的是,所有这些只有在我们使用Swift的新并发系统时才有效。所以当使用其他并发模式时,例如完成处理程序,那么@MainActor属性不起作用-这意味着下面的代码仍然会导致我们的result属性在后台队列上被错误地赋值:
@MainActor class ListViewModel: ObservableObject {
...
func load() {
loader.loadItems { [weak self] result in
self?.result = result
}
}
}
乍一看,这似乎是一个奇怪的限制,但考虑到角色只能使用新的async/await系统异步访问时,这是有意义的。 因此,如果我们在一个完全同步的上下文中操作(我们上面的完成处理程序实际上是这样的,即使它可能在后台线程上被调用),系统没有办法自动将代码分派给main actor。
总结
随着时间的推移,一旦我们的大部分异步代码被迁移到Swift的新并发系统中,MainActor将或多或少地消除我们在主队站上手动调度UI更新的需要。当然,这并不意味着在设计我们的api和架构时不再需要考虑线程和其他并发问题, 但至少很常见的意外问题在后台队列更新UI, 在某种程度上,希望这个问题成为过去式。