为何 Swift 6 中某些视图修饰器无法使用 @State 属性

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

在 Xcode 16 中,为了改善 SwiftUI 在 Swift 6 模式下的表现,苹果对 SwiftUI 框架的 API 进行了多项调整,以满足更严格的并发检查要求。其中最显著的变化是将 View 协议全面标注为 @MainActor。这些优化虽然总体上改善了开发者在 Swift 6 模式下的编程体验,但也在某些特定场景中引发了一些看似反常的编译错误。本文将详细探讨为什么某些视图修饰器中无法直接使用 @State 属性值,并提供相应的解决方案。

问题

在苹果开发者论坛上有一个典型案例:一段在非 Swift 6 模式下可以正常编译的代码,当切换到 Swift 6 模式后,只要 alignmentGuide 中引用了 @State 属性,就会触发编译错误:

Swift
struct ContentView: View {
    @State var isVisible = false
    var body: some View {
        Text("Hello, world!")
            .alignmentGuide(.bottom) {
                // error: Main actor-isolated property 'isVisible' can not be referenced from a Sendable closure
                isVisible ? $0[.bottom] : $0[.top]
            }
    }
}

这个错误提示令人困惑:难道 Swift 6 模式下无法在视图修饰器中使用 @State 属性了?然而当我们在其他视图修饰器中测试时,发现大多数情况下并不会报错。这就引出了一个关键问题:为什么只有某些特定的视图修饰器会出现这种情况?

分析原因

要理解这个问题,我们需要从几个关键点入手:

首先,从 Xcode 16 开始,View 协议被整体标注为 @MainActor,这使得上下文中的 isVisible 也继承了 @MainActor 属性。通过查看 alignmentGuide 的函数声明,我们可以看到它只接受一个 @Sendable 的同步闭包:

Swift
nonisolated public func alignmentGuide(_ g: VerticalAlignment, computeValue: @escaping @Sendable (ViewDimensions) -> CGFloat) -> some View

从错误提示来看,Swift 编译器认为 isVisible 不是一个 Sendable 类型。这可能会让人感到困惑,因为根据 State 的声明:

Swift
extension State : Sendable where Value : Sendable {}

WrappedValue 符合 Sendable 时,State 也应该符合 Sendable。那么在闭包中使用 isVisible(即 StatewrappedValue 值)应该是合法的才对。

然而实际情况更为复杂。@State 和其他属性包装器并不是简单的存储属性,而是一种语法糖。在编译时,一个 @State 属性会被转译成如下形式:

Swift
struct Demo {
    @State var isVisible = false
}

// 转译后
struct Demo {
    private var _isVisible = State(wrappedValue: isVisible)
    var isVisible: Bool {
        get { _isVisible.wrappedValue }
        set { _isVisible.wrappedValue = newValue }
    }
}

这意味着在闭包中访问 isVisible 时,实际上是在调用 isVisible 计算属性的 getter 方法:

Swift
isVisible ? $0[.bottom] : $0[.top]
// 对应
isVisible.getter() ? $0[.bottom] : $0[.top]

由于这个 getter 方法并非 @Sendable,因此 Swift 6 编译器会报错:“Main actor-isolated property ‘isVisible’ can not be referenced from a Sendable closure”。

我们可以通过查看 SIL(Swift Intermediate Language)文件来验证这一分析。

Swift
@MainActor @preconcurrency struct ContentView : View {
  @State @_projectedValueProperty($isVisible) @MainActor @preconcurrency var isVisible: Bool {
    get
    @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
    nonmutating set
    @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
    nonmutating _modify
  }
  @MainActor @preconcurrency var $isVisible: Binding<Bool> { get }
  @_hasStorage @MainActor @preconcurrency @_hasInitialValue var _isVisible: State<Bool> { get set }
  @MainActor @preconcurrency var body: some View { get }
  typealias Body = @_opaqueReturnTypeOf("$s11ContentViewAAV4bodyQrvp", 0) __
  @MainActor @preconcurrency init()
  nonisolated init(isVisible: Bool = false)
}

SIL 代码清楚地显示了编译器创建了一个标注为 @_hasStorage_isVisible: State<Bool>,而 isVisible 仅仅是一个用于读写这个存储属性的计算属性。

通过进一步检查 alignmentGuide 的 SIL 代码,我们可以看到读取 isVisible 的值确实是通过调用一个被标注为 @MainActorgetter 方法实现的( %4%5 清晰的展示了调用方法的过程 )。

Swift
bb0(%0 : $*ViewDimensions, %1 : @closureCapture $ContentView):
  debug_value %0 : $*ViewDimensions, let, name "$0", argno 1, expr op_deref // id: %2
  debug_value %1 : $ContentView, let, name "self", argno 2 // id: %3
  // function_ref ContentView.isVisible.getter
  %4 = function_ref @$s11ContentViewAAV3topSbvg : $@convention(method) (@guaranteed ContentView) -> Bool // user: %5
  %5 = apply %4(%1) : $@convention(method) (@guaranteed ContentView) -> Bool // user: %6
  %6 = struct_extract %5 : $Bool, #Bool._value    // user: %7
  cond_br %6, bb1, bb2                            // id: %7

这样我们就可以得出结论:由于在 Swift 6 模式下,@Sendable 的同步闭包中不允许调用 @MainActor 方法,因此我们无法在 alignmentGuide 中直接使用 @State 属性 isVisible

解决方案

理解了问题的本质后,我们可以采用以下两种方案来解决:

方案一:使用 State 的底层值

从 SIL 文件可以看出,_isVisible 是一个存储属性。因此,我们可以在 alignmentGuide 的闭包中直接使用其 wrappedValue(因为 Bool 符合 Sendable 协议):

Swift
.alignmentGuide(.bottom) {
    _isVisible.wrappedValue ? $0[.bottom] : $0[.top]
}

方案二:闭包外预先获取 Sendable 值

虽然方案一能够解决问题,但它存在两个明显的缺点:

  1. 使用下划线前缀的属性名影响代码可读性
  2. 这种方式难以推广到其他属性包装器(如 @StateObject、@Environment 等)

因此,一个更优雅的解决方案是在闭包外预先获取 State 的值:

Swift
struct ContentView: View {
    @State var isVisible = false
    var body: some View {
        let isVisible = isVisible // 在 MainActor 上调用 getter
        Text("Hello, world!")
            .alignmentGuide(.bottom) {
                isVisible ? $0[.bottom] : $0[.top]
            }
    }
}

这样,新声明的 isVisible 就是一个纯粹的 Sendable 值,可以安全地在 @Sendable 闭包中使用。

总结

本文讨论的问题不仅存在于 alignmentGuide,还出现在其他几个使用 @Sendable 同步闭包的视图修饰器中,包括:scrollTransitionvisualEffectkeyframeAnimator。上述解决方案同样适用于这些情况。

在向 Swift 6 模式迁移的过程中,开发者经常会遇到各种警告和编译错误。面对这些问题时,我们不应该仅仅关注如何让代码通过编译,而是应该深入理解错误产生的根本原因。这种深入理解不仅能帮助我们写出更好的代码,也能让我们在这次重要的语言版本更新中游刃有余,作出更明智的技术决策。

本文是我在 2024 年发表的最后一篇文章。接下来,我将休息一段时间,计划于春节过后( 2025 年 2 月中旬)恢复发布新文章。在此期间,周报仍将正常发布。

祝大家新年快乐!🎉

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam