用 MainActor.assumeIsolated 解决旧 API 与 Swift 6 适配问题

发表于

尽管 Swift 提供严格并发检查已有一段时间,但许多苹果官方 API 仍未对此进行充分适配,这种情况可能还会持续相当长的时间。随着 Swift 6 的逐步普及,这个问题变得愈发突出:开发者一方面希望享受 Swift 编译器带来的并发安全保障,另一方面又对如何让代码满足编译要求感到困惑。本文将通过一个 NSTextAttachmentViewProvider 的实现案例,介绍 MainActor.assumeIsolated 在特定场景下的妙用。

一封邮件

几天前,我收到了一封来自网友 Lucas 的邮件。他遇到了一个旧 API 无法满足 Swift 6 编译要求的问题。他希望通过 NSTextAttachment + NSTextAttachmentViewProviderUITextView 中实现插入自定义视图的功能。为此,他尝试在 NSTextAttachmentViewProviderloadView 方法中加载一个 SwiftUI 视图:

Swift
class CustomAttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        let hosting = UIHostingController(rootView: InlineSwiftUIButton {
            print("SwiftUI Button tapped!")
        })

        hosting.view.backgroundColor = .clear

        // Assign to the provider's view
        self.view = hosting.view
    }
}

// MARK: - SwiftUI Button View

struct InlineSwiftUIButton: View {
    var action: () -> Void
    var body: some View {
        Button("Click Me") {
            action()
        }
        .padding(6)
        .background(Color.blue.opacity(0.2))
        .cornerRadius(8)
    }
}

在 Xcode 开启 Swift 6 模式后(Default Actor Isolation 设为 nonisolated),上述代码出现了如下错误/警告:

image-20250826075726095

为了解决这个编译问题,Lucas 尝试了多种方案:

  • loadView 方法上添加 @MainActor

image-20250826075854962

  • CustomAttachmentViewProvider 类上添加 @MainActor

image-20250826075953957

  • loadView 中的代码包裹在 Task

image-20250826080105350

然而,无论采用哪种方法都无法满足 Swift 编译器的要求。

在 Xcode 26 beta 5 中,最初的代码会报错(编译失败),而在 beta 6 中则会产生警告(可以编译)。

那么,这是否意味着此类旧 API 无法在 Swift 6 目标中进行完美编译呢(没有警告、不会报错)?

分析问题

Swift 6 编译器之所以不认可上述几种写法,主要原因如下:

  • UIHostingController 在声明中标注了 @MainActor,这意味着它必须在 MainActor 上下文中创建
  • NSTextAttachmentViewProvider 的原始声明中没有明确的隔离域
  • 单独为 loadView 添加 @MainActor 与父类的要求不符
  • 如果在 loadView 中构建 MainActor 异步上下文,无法安全地传递 self

我们似乎陷入了一个两难境地:既需要在 MainActor 中构建 UIHostingController,又不能在 MainActor 中将构建后的视图(UIView)赋值给 self.view

有没有一种方式能够满足这种”既要又要”的需求呢?

MainActor.assumeIsolated:在同步方法中提供 MainActor 上下文

在 Swift 的并发 API 中,有一个看起来颇为特殊的存在:MainActor.assumeIsolated。与 MainActor.run 不同,它只能在同步上下文中运行,并且如果当前上下文不是 MainActor,应用会直接崩溃。

初次接触这个方法时,我对它的用途感到困惑。很长一段时间里,我只是将它与 MainActor.assertIsolated 一样,作为调试时判断当前上下文是否为 MainActor 的手段。直到遇到本文提到的问题,我才真正理解了这个 API 的设计意图。

查看 MainActor.assumeIsolated 的签名,我们可以发现该 API 会为其尾随闭包提供一个 MainActor 上下文。这意味着,我们可以在一个非 MainActor 的同步上下文中,无需创建异步环境,就能“同步”地运行一段只能在 MainActor 上下文中执行的代码,并返回一个 Sendable 结果

Swift
public static func assumeIsolated<T>(_ operation: @MainActor () throws -> T, file: StaticString = #fileID, line: UInt = #line) rethrows -> T where T : Sendable

解决方案

理解了 MainActor.assumeIsolated 的作用后,我们可以将 loadView 改写为:

Swift
class CustomAttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        let view = MainActor.assumeIsolated { // 在同步上下文运行
            // assumeIsolated 闭包中提供了 MainActor 环境,可以安全地创建 UIHostingController 实例
            let hosting = UIHostingController(rootView: InlineSwiftUIButton {
                print("SwiftUI Button tapped!")
            })

            hosting.view.backgroundColor = .clear
            return hosting.view // view 为 UIView,标注为 MainActor,满足 Sendable
        }
        self.view = view
    }
}

在上面的代码中:

  • 我们在 loadView 中顺利执行了 MainActor.assumeIsolated 方法
  • MainActor.assumeIsolated 的闭包提供了 MainActor 上下文,使我们能够安全地创建 UIHostingController 实例
  • hosting.viewUIView 类型(声明时已有 @MainActor 标注),满足 Sendable 要求,可以作为闭包的返回值
  • loadView 的同步上下文中,我们将 MainActor.assumeIsolated 的返回值赋值给了 self.view,保证了隔离域的一致性

考虑到 loadView 并非总是在 MainActor 中执行,最终的完整代码如下:

Swift
class CustomAttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        view = getView()
    }

    // 如果 `loadView` 没有运行于主线程,切换到主线程
    func getView() -> UIView {
        if Thread.isMainThread {
            return Self.createHostingViewOnMain()
        } else {
            return DispatchQueue.main.sync {
                Self.createHostingViewOnMain()
            }
        }
    }

    // 使用静态方法避免捕获 self
    private static func createHostingViewOnMain() -> UIView {
        MainActor.assumeIsolated {
            let hosting = UIHostingController(rootView: InlineSwiftUIButton {
                print("SwiftUI Button tapped!")
            })

            hosting.view.backgroundColor = .clear
            return hosting.view
        }
    }
}

至此,我们实现了一个完全满足 Swift 6 编译器要求、与旧 API 相适配的解决方案。

或许不是最优雅,但存在即合理

为了在编译阶段发现并发问题,Swift 在近几年增加了大量相关的关键字和方法,这在一定程度上增加了开发者的学习成本和使用难度。尽管我对编程语言设计并无深入研究,但从直觉来看,这种大量堆砌功能的方式确实不够优雅,也增加了理解难度。

然而,考虑到 Swift 语言并非孤立存在——在实践中我们需要与大量旧 API,甚至是 Objective-C 代码打交道——这些看似“繁琐”的设计也就有了其合理性。

我仍然期待能尽早度过这段略显“混乱”的过渡期。或许再过几年,当大量官方和第三方框架都完成 Swift 6 迁移后,我们终将获得更加轻松的安全并发编程体验。

如果这篇文章对你有帮助,可以 买杯咖啡 ☕️ 支持一下.

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!