Swift:使用MainActor属性来自动调度主队列UI更新的教程

1,477 阅读5分钟

谈到苹果平台上的并发性,一个挑战是,在大多数情况下,一个应用程序的用户界面只能在主线程上更新。因此,只要我们在后台线程上执行任何类型的工作(无论是直接还是间接),那么我们就必须确保在访问与渲染我们的用户界面有关的任何属性、方法或函数之前跳回主队列。

在理论上,这可能听起来很简单。在实践中,意外地在后台队列上执行UI更新是非常常见的--这可能会导致故障,甚至使应用程序处于不一致或未定义的状态,这反过来可能导致崩溃和其他错误。

手动主队列调度

到目前为止,这个问题最常用的解决方案是将所有与UI相关的更新包在异步调用DispatchQueue.main ,当这些更新有可能在后台队列中被触发时--例如像这样的情况:

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时,或者在实现某些委托方法时。

主要行为者

值得庆幸的是,Swift 5.5 引入了一个可能会变得更加强大且几乎完全自动的解决方案来解决这个非常常见的问题--主行为体。虽然我们会在未来的文章中更详细地探讨 Swift 的 Actor 模式的实现,但对于这个特定的用例,我们真正需要知道的是,这个新的、内置的actor 实现确保所有在其上运行的工作总是在主队列上执行。

那么,我们究竟如何在主行为体上 "运行 "我们的代码?我们要做的第一件事是让我们的异步代码使用新的async/await模式,这也是作为Swift 5.5的并发功能套件的一部分被引入的。在这种情况下,可以通过创建一个新的、支持异步/等待的loadUser 方法来实现,我们的上述视图控制器正在调用该方法--这只是涉及到使用Swift的新延续API包装对其默认的、基于完成处理器的版本的调用:

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)
                }
            }
        }
    }
}

要了解更多关于上述模式的信息,请查看我的朋友Vincent Pradeilles的WWDC by Sundell & Friends文章《将完成处理程序包装成异步API》。

有了上述内容,我们现在可以使用一个异步Task ,再加上Swift的标准do, try catch 错误处理机制,从我们的ProfileViewController ,调用我们的加载器的新的异步/await-powered API:

class ProfileViewController: UIViewController {
    ...
    
    private func loadUser() {
        Task {
            do {
                let user = try await loader.loadUser()
                nameLabel.text = user.name
                biographyLabel.text = user.biography
            } catch {
                showError(error)
            }
        }
    }
}

但是,等等,如果不调用DispatchQueue.main.async ,上面的工作怎么可能进行?如果loadUser 在后台队列中执行其工作,这不就意味着我们的用户界面现在也会在后台队列中被错误地更新吗?

这就是主要演员的问题所在。如果我们看一下UILabelUIViewController 的声明,我们可以看到它们都被注释了新的@MainActor 属性。

@MainActor class UILabel: UIView
@MainActor class UIViewController: UIResponder

这意味着,当使用Swift的新并发系统时,这些类(以及它们的任何子类,包括我们的ProfileViewController )上的所有属性和方法将自动在主队列上被设置、调用和访问。所有这些调用将自动通过系统提供的MainActor ,它总是在主线程上执行所有的工作--完全不需要我们手动调用DispatchQueue.main.async 。真的很酷!

自定义UI相关的类

但是,如果我们正在开发一个完全自定义的类型,而我们也想获得上述那种能力呢?例如,当实现一个在 SwiftUI 视图中使用的ObservableObject 时,我们需要确保只在主队列中分配其@Published 标记的属性,所以如果我们也能在这些情况下利用MainActor ,那不是很好吗?

好消息是--我们可以就像UIKit的许多内置类现在都被注解为@MainActor ,我们也可以把这个属性应用到我们自己的类中--给它们同样的自动主线程调度行为。

@MainActor class ListViewModel: ObservableObject {
    @Published private(set) var result: Result<[Item], Error>?
    private let loader: ItemLoader
    ...

    func load() {
        Task {
            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系统时,它确实是有意义的。因此,如果我们在一个完全同步的环境中操作(我们上面的完成处理程序实际上是这样的,即使它可能在后台线程上被调用),系统就没有办法自动将代码分配给主角色。

结论

随着时间的推移,一旦我们的大部分异步代码被迁移到Swift的新并发系统,MainActor ,希望能或多或少地消除我们在主队列上手动调度UI更新的需要。当然,这并不意味着我们在设计API和架构时不再需要考虑线程和其他并发问题--但至少那个非常常见的不小心在后台队列上执行UI更新的问题,在某种程度上有望成为过去。

谢谢你的阅读!