解密 Swift 5.5 中的 @MainActor, 深入了解其优势与误区

这里每天分享一个 iOS 的新知识,快来关注我吧

前言

随着 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:

scss 复制代码
@globalActor
actor MyCustomActor {
    static let shared = MyCustomActor()
}

shared 属性是 GlobalActor 协议的要求,它确保有一个全局唯一的 actor 实例。一旦定义,你就可以在整个项目中使用这个全局 actor,就像使用其他 actor 一样:

kotlin 复制代码
@MyCustomActor
final class DataFetcher {
    // ...
}

在任何使用全局 actor 属性的地方,你都会通过共享的 actor 实例确保同步,以确保对声明的互斥访问。

@MainActor 的底层实现与我们自定义的全局 actor 类似:

swift 复制代码
@globalActor
final actor MainActor: GlobalActor {
    static let shared: MainActor
}

它默认可用,并在并发框架内定义。换句话说,你可以立即开始使用这个全局 actor,并通过这个全局 actor 同步来标记你的代码在主线程上执行。

如何在 Swift 中使用 MainActor?

你可以将全局 actor 用于属性、方法、闭包和实例。例如,我们可以为视图模型添加 @MainActor 属性,以确保所有任务都在主线程上执行:

kotlin 复制代码
@MainActor
final class HomeViewModel {
    // ...
}

使用 nonisolated,我们可以确保没有主线程要求的方法尽可能快地执行,而不需要等待主线程变得可用。你只能在没有超类、超类使用相同的全局 actor 注解,或者超类是 NSObject 的情况下,为类添加全局 actor 注解。全局 actor 注解类的子类必须隔离到相同的全局 actor。

在其他情况下,我们可能想要为单个属性定义全局 actor:

kotlin 复制代码
final class HomeViewModel {
    
    @MainActor var images: [UIImage] = []

}

使用 @MainActor 标记 images 属性确保它只能从主线程更新。编译器会强制执行 MainActor 属性的要求。

这在使用 SwiftUI 的 MVVM 模式时非常有用,因为你只希望在主线程上触发视图重绘。

你也可以用这个属性标记单个方法:

less 复制代码
@MainActor func updateViews() {
    /// 只要它在并发上下文中被调用,就会始终分派到主线程。
}

需要注意的是:这个方法只有在从异步上下文调用时才能保证分派到主线程。Xcode 16 会适当地提醒你这一点,但了解这一功能对于理解主 actor 属性如何应用是很重要的。

最后,你可以标记闭包在主线程上执行:

less 复制代码
func updateData(completion: @MainActor @escaping () -> ()) {
    Task {
        await someHeavyBackgroundOperation()
        await completion()
    }
}

不过在这种情况下,你应该将 updateData 方法重写为异步变体,而不需要完成闭包。

直接使用 MainActor

Swift 中的 MainActor 带有一个扩展,可以直接使用 actor:

less 复制代码
extension MainActor {
    /// 在主 actor 上执行给定的 body 闭包。
    public static func run<T>(resultType: T.Type = T.self, body: @MainActor @Sendable () throws -> T) async rethrows -> T
}

这让我们可以在方法中调用 @MainActor,而不需要在 body 中使用其属性。

arduino 复制代码
Task {
    await someHeavyBackgroundOperation()
    await MainActor.run {
        // 执行 UI 更新
    }
}

换句话说,我们不再需要使用 DispatchQueue.main.async 了。然而,我建议使用全局 actor 属性来限制对主线程的任何访问。如果没有全局 actor 属性,任何人都可能忘记使用 MainActor.run,可能导致 UI 更新在后台线程上进行。

什么时候应该使用 MainActor 属性?

在 Swift 5.5 之前,你可能定义了许多 dispatch 语句来确保任务在主线程上运行。一个例子可能如下所示:

less 复制代码
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 确保任务在主线程上执行。理想情况下,我们会将上面的例子重写如下:

swift 复制代码
@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 语言模式下编译正常:

scss 复制代码
DispatchQueue.global().async {
    let viewModel = HomeViewModel()
    viewModel.updateImages() // 标记为 @MainActor 但在后台线程上执行
}

通过全局队列的 dispatch 创建了一个非隔离上下文,@MainActor 属性没有起作用。

然而,如果我们开启 Swift 6 语言模式,编译器会立即告诉我们潜在的问题:

csharp 复制代码
'updateImages()' is isolated to global actor 'MainActor' and must be called from an async context

结语

@MainActor 是 Swift 并发特性中的一个强大工具,它为我们提供了一种优雅的方式来确保代码在主线程上执行。通过正确使用它,我们可以避免许多与线程相关的问题,并使我们的代码更加清晰和可维护。

然而,重要的是要理解 @MainActor 的局限性,特别是在非隔离上下文中的同步方法中。随着 Swift 6 的到来,编译器将提供更多的保护,但在此之前,我们需要保持警惕。

如果你正在使用 Swift 并发特性,我强烈建议你考虑迁移到 Swift 6 语言模式,以获得更好的编译时保护。虽然这可能需要一些工作,但长期来看,它将帮助你避免许多潜在的问题。

希望这篇文章对你有所帮助!如果你有任何问题或想分享你使用 @MainActor 的经验,欢迎在下方留言。

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 "iOS新知",每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

相关推荐
茶底世界之下5 小时前
类型擦除之后,Metal 滤镜组合为什么不能只执行一个 Filter?
ios·swift
传奇开心果编程8 小时前
【SwiftUI提高练中学】第8课 网络层架构:拦截器、重试、缓存与离线优先
学习·macos·ui·ios·swiftui
传奇开心果编程14 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
tink16 小时前
告别 Xcode IDE:用 VS Code + SweetPad + XcodeGen 开发 iOS 应用的完整指南
ios·swiftui·xcode
辉夜技术17 小时前
iPad 插着数据线,Mac 却说"附近没有任何显示器"
客户端
传奇开心果编程17 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程19 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程19 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
黑科技iOS上架19 小时前
iOS隐私合规扫描工具
ios·审核
传奇开心果编程19 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer