前言
随着 Swift 5.5 的发布,苹果为我们带来了一系列强大的并发特性,其中 @MainActor 作为一个全局 actor,为我们提供了一种优雅的方式来确保代码在主线程上执行。在 iOS 开发中,我们经常需要在主线程上更新 UI,而 @MainActor 的引入大大简化了这一过程。在这篇文章中,我们将深入探讨 @MainActor 的使用方法、适用场景以及一些常见的误区,帮助你更好地理解和应用这一强大的特性。
什么是 MainActor?
MainActor 是 Swift 5.5 引入的一个全局 actor,它提供了一个在主线程上执行任务的执行器。在构建应用程序时,确保 UI 更新任务在主线程上执行是至关重要的,而当使用多个后台线程时,这有时会变得具有挑战性。使用 @MainActor 属性将帮助确保你的 UI 始终在主线程上更新。从本质上讲,MainActor 是一个全局唯一的 actor,它在主线程上执行其任务。你可以将它用于属性、方法、实例和闭包,以确保它们在主线程上执行。在 SE-0316 提案中,MainActor 被引入作为全局 actor 的一个例子,它继承了 GlobalActor 协议。
理解全局 Actor
在深入了解如何在代码中使用 MainActor 之前,我们需要理解全局 actor 的概念。你可以将全局 Actor 视为单例:只存在一个实例。我们可以如下定义一个全局 actor:
@globalActor
actor MyCustomActor {
static let shared = MyCustomActor()
}
shared 属性是 GlobalActor 协议的要求,它确保有一个全局唯一的 actor 实例。一旦定义,你就可以在整个项目中使用这个全局 actor,就像使用其他 actor 一样:
@MyCustomActor
final class DataFetcher {
// ...
}
在任何使用全局 actor 属性的地方,你都会通过共享的 actor 实例确保同步,以确保对声明的互斥访问。
@MainActor 的底层实现与我们自定义的全局 actor 类似:
@globalActor
final actor MainActor: GlobalActor {
static let shared: MainActor
}
它默认可用,并在并发框架内定义。换句话说,你可以立即开始使用这个全局 actor,并通过这个全局 actor 同步来标记你的代码在主线程上执行。
如何在 Swift 中使用 MainActor?
你可以将全局 actor 用于属性、方法、闭包和实例。例如,我们可以为视图模型添加 @MainActor 属性,以确保所有任务都在主线程上执行:
@MainActor
final class HomeViewModel {
// ...
}
使用 nonisolated,我们可以确保没有主线程要求的方法尽可能快地执行,而不需要等待主线程变得可用。你只能在没有超类、超类使用相同的全局 actor 注解,或者超类是 NSObject 的情况下,为类添加全局 actor 注解。全局 actor 注解类的子类必须隔离到相同的全局 actor。在其他情况下,我们可能想要为单个属性定义全局 actor:
final class HomeViewModel {
@MainActor var images: [UIImage] = []
}
使用 @MainActor 标记 images 属性确保它只能从主线程更新。编译器会强制执行 MainActor 属性的要求。这在使用 SwiftUI 的 MVVM 模式时非常有用,因为你只希望在主线程上触发视图重绘。
你也可以用这个属性标记单个方法:
@MainActor func updateViews() {
/// 只要它在并发上下文中被调用,就会始终分派到主线程。
}
需要注意的是:这个方法只有在从异步上下文调用时才能保证分派到主线程。Xcode 16 会适当地提醒你这一点,但了解这一功能对于理解主 actor 属性如何应用是很重要的。最后,你可以标记闭包在主线程上执行:
func updateData(completion: @MainActor @escaping () -> ()) {
Task {
await someHeavyBackgroundOperation()
await completion()
}
}
不过在这种情况下,你应该将 updateData 方法重写为异步变体,而不需要完成闭包。
直接使用 MainActor
Swift 中的 MainActor 带有一个扩展,可以直接使用 actor:
extension MainActor {
/// 堆代码 duidaima.com
/// 在主 actor 上执行给定的 body 闭包。
public static func run<T>(resultType: T.Type = T.self, body: @MainActor @Sendable () throws -> T) async rethrows -> T
}
这让我们可以在方法中调用 @MainActor,而不需要在 body 中使用其属性。
Task {
await someHeavyBackgroundOperation()
await MainActor.run {
// 执行 UI 更新
}
}
换句话说,我们不再需要使用 DispatchQueue.main.async 了。然而,我建议使用全局 actor 属性来限制对主线程的任何访问。如果没有全局 actor 属性,任何人都可能忘记使用 MainActor.run,可能导致 UI 更新在后台线程上进行。
什么时候应该使用 MainActor 属性?
在 Swift 5.5 之前,你可能定义了许多 dispatch 语句来确保任务在主线程上运行。一个例子可能如下所示:
func fetchImage(for url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data, let image = UIImage(data: data) else {
DispatchQueue.main.async {
completion(.failure(ImageFetchingError.imageDecodingFailed))
}
return
}
DispatchQueue.main.async {
completion(.success(image))
}
}.resume()
}
在上面的例子中,你确信需要 dispatch 到主线程来返回图像。我们必须在几个地方执行 dispatch,导致代码混乱,有几个闭包。有时,我们甚至可能在已经在主线程上时 dispatch 到主队列。这种情况会导致一个额外的 dispatch,你本可以跳过。通过重写你的代码以使用 async/await 和主 actor,你允许优化只在需要时进行 dispatch。在这些情况下,将属性、方法、实例或闭包隔离到主 actor 确保任务在主线程上执行。理想情况下,我们会将上面的例子重写如下:
@MainActor
func fetchImage(for url: URL) async throws -> UIImage {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageFetchingError.imageDecodingFailed
}
return image
}
@MainActor 属性确保逻辑在主线程上执行,而网络请求仍然在后台队列上执行。只有在需要时才会 dispatch 到主 actor,以确保尽可能最佳的性能。
为什么 @MainActor 并不总是确保主线程执行?
如果你是并发的新手,你可能认为用 @MainActor 标记的方法一定是在主线程上运行。不幸的是,对于非隔离上下文中的同步方法,这并不能保证。在分享细节之前,值得一提的是 Swift 6 语言模式捕获了大多数这些情况。但大部分项目还没有迁移到 Swift 6,所以我还是想强调一下。例如,这段代码在 Swift 5 语言模式下编译正常:
DispatchQueue.global().async {
let viewModel = HomeViewModel()
viewModel.updateImages() // 标记为 @MainActor 但在后台线程上执行
}
通过全局队列的 dispatch 创建了一个非隔离上下文,@MainActor 属性没有起作用。然而,如果我们开启 Swift 6 语言模式,编译器会立即告诉我们潜在的问题:
'updateImages()' is isolated to global actor 'MainActor' and must be called from an async context
结语
@MainActor 是 Swift 并发特性中的一个强大工具,它为我们提供了一种优雅的方式来确保代码在主线程上执行。通过正确使用它,我们可以避免许多与线程相关的问题,并使我们的代码更加清晰和可维护。然而,重要的是要理解 @MainActor 的局限性,特别是在非隔离上下文中的同步方法中。随着 Swift 6 的到来,编译器将提供更多的保护,但在此之前,我们需要保持警惕。
如果你正在使用 Swift 并发特性,我强烈建议你考虑迁移到 Swift 6 语言模式,以获得更好的编译时保护。虽然这可能需要一些工作,但长期来看,它将帮助你避免许多潜在的问题。
希望这篇文章对你有所帮助!如果你有任何问题或想分享你使用 @MainActor 的经验,欢迎在下方留言。