专注、坚持

Swift 中的 propertyWrapper

2022.06.28 by kingcos

Preface

SwiftUI 中,我们经常可以见到 @State@Binding 等类似 @ 开头的代码,其官方名称为 Property Wrapper —— 属性包装器。

@State

在 Xcode 中,我们可以进入到 @State 的定义中,即:

/// A property wrapper type that can read and write a value managed by SwiftUI.
/// 属性包装类型指可以读取和写入由 SwiftUI 管理的一个值。
///
/// SwiftUI manages the storage of a property that you declare as state. When
/// the value changes, SwiftUI updates the parts of the view hierarchy that
/// depend on the value. Use state as the single source of truth for a given
/// value stored in a view hierarchy.
/// SwiftUI 管理声明为状态的属性存储。当值更改时,SwiftUI 会更新视图层次结构中依赖于该值的部分。使用状态作为存储在视图层次结构中的给定值的单一可信源(single source of truth)。
///
/// A `State` instance isn't the value itself; it's a means of reading and
/// writing the value. To access a state's underlying value, refer to it by
/// its property name, which returns the ``State/wrappedValue`` property value.
/// `State` 实例不是值本身;而是一种读写值的方法。要访问一个状态的底层值,可以通过其属性名引用,该属性名返回  ``State/wrappedValue`` 属性值。
/// For example, you can read and update the `isPlaying` state property in a
/// `PlayButton` view by referring to the property directly:
/// 例如,可以通过直接引用 `PlayButton` 视图中的 `isPlaying` 状态属性来读取和更新该属性:
///
///     struct PlayButton: View {
///         @State private var isPlaying: Bool = false
///
///         var body: some View {
///             Button(isPlaying ? "Pause" : "Play") {
///                 isPlaying.toggle()
///             }
///         }
///     }
///
/// If you pass a state property to a child view, SwiftUI updates the child
/// any time the value changes in the parent, but the child can't modify the
/// value. To enable the child view to modify the stored value, pass a
/// ``Binding`` instead. You can get a binding to a state value by accessing
/// the state's ``State/projectedValue``, which you get by prefixing the
/// property name with a dollar sign (`$`).
/// 如果将状态属性传递给子视图,则当父视图中的值更改时,SwiftUI 会随时更新子视图,但子视图不能修改值。启用子视图修改存储的值,改为传递一个 ``Binding``。可以通过访问状态的 `state/projectedValue`` 来获得与状态值的绑定,该值是通过在属性名称前面加上美元符号(`$`)获得的。
///
/// For example, you can remove the `isPlaying` state from the play button in
/// the example above, and instead make the button take a binding to the state:
/// 例如,可以从上述示例中的播放按钮中删除 `isPlaying` 状态,并使按钮绑定到该状态:
///
///     struct PlayButton: View {
///         @Binding var isPlaying: Bool
///
///         var body: some View {
///             Button(isPlaying ? "Pause" : "Play") {
///                 isPlaying.toggle()
///             }
///         }
///     }
///
/// Then you can define a player view that declares the state and creates a
/// binding to the state using the dollar sign prefix:
/// 然后,可以定义一个播放器视图,该视图声明状态并使用美元符号前缀创建与状态的绑定:
///
///     struct PlayerView: View {
///         var episode: Episode
///         @State private var isPlaying: Bool = false
///
///         var body: some View {
///             VStack {
///                 Text(episode.title)
///                     .foregroundStyle(isPlaying ? .primary : .secondary)
///                 PlayButton(isPlaying: $isPlaying) // Pass a binding. 传递绑定
///             }
///         }
///     }
///
/// Don't initialize a state property of a view at the point in the view
/// hierarchy where you instantiate the view, because this can conflict with
/// the storage management that SwiftUI provides. To avoid this, always
/// declare state as private, and place it in the highest view in the view
/// hierarchy that needs access to the value. Then share the state with any
/// child views that also need access, either directly for read-only access,
/// or as a binding for read-write access.
/// 不要在视图层次结构中实例化视图时,初始化视图的状态属性,因为这可能与 SwiftUI 提供的存储管理冲突。为避免这种情况,需要始终将状态声明为 private,并将其放置在需要访问该值的视图层次结构中的最顶层视图中。然后,将状态与任何也需要访问的子视图共享,可以直接用于只读访问,也可以作为读写访问的绑定。
///
/// You can safely mutate state properties from any thread.
/// @State 是线程安全的。
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct State<Value> : DynamicProperty {

    /// Creates the state with an initial wrapped value.
    /// 使用初始包装值创建状态。
    ///
    /// Don't call this initializer directly. Instead, declare a property
    /// with the ``State`` attribute, and provide an initial value:
    /// 不要直接调用该构造方法。取而代之的是使用 ``State`` 声明属性,并提供初始值:
    ///
    ///     @State private var isPlaying: Bool = false
    ///
    /// - Parameter wrappedValue: An initial wrappedValue for a state.
    public init(wrappedValue value: Value)

    /// Creates the state with an initial value.
    ///
    /// - Parameter value: An initial value of the state.
    public init(initialValue value: Value)

    /// The underlying value referenced by the state variable.
    /// 由状态变量引用的底层值。
    ///
    /// This property provides primary access to the value's data. However, you
    /// don't access `wrappedValue` directly. Instead, you refer to the property
    /// variable created with the ``State`` attribute. In the following example,
    /// the button's label depends on the value of `isPlaying` and its action
    /// toggles the value of `isPlaying`. Both of these accesses implicitly
    /// rely on the state property's wrapped value.
    /// 该属性提供对值数据的主要访问。但是不能直接访问 `wrappedValue`。取而代之,引用了使用 `` State`` 属性创建的属性变量。在以下示例中,按钮的标签取决于 `isPlaying` 的值,以及点击操作将切换 `isPlaying` 的值。这两种访问都隐式地依赖于状态属性的包装值。
    ///
    ///     struct PlayButton: View {
    ///         @State private var isPlaying: Bool = false
    ///
    ///         var body: some View {
    ///             Button(isPlaying ? "Pause" : "Play") {
    ///                 isPlaying.toggle()
    ///             }
    ///         }
    ///     }
    ///
    public var wrappedValue: Value { get nonmutating set }

    /// A binding to the state value.
    /// 状态值的绑定。
    ///
    /// Use the projected value to pass a binding value down a view hierarchy.
    /// To get the `projectedValue`, prefix the property variable with a dollar
    /// sign (`$`). In the following example, `PlayerView` projects a binding
    /// of the state property `isPlaying` to the `PlayButton` view using
    /// `$isPlaying`:
    /// 使用投射值向下传递视图层次结构的绑定值。要获取 `projectedValue`,可在属性变量前加美元符号(`$`)。在下面的示例中,`PlayerView` 使用 `$isPlaying` 将状态属性 `isPlaying` 的绑定投影到 `PlayButton` 视图:
    ///
    ///     struct PlayerView: View {
    ///         var episode: Episode
    ///         @State private var isPlaying: Bool = false
    ///
    ///         var body: some View {
    ///             VStack {
    ///                 Text(episode.title)
    ///                     .foregroundStyle(isPlaying ? .primary : .secondary)
    ///                 PlayButton(isPlaying: $isPlaying)
    ///             }
    ///         }
    ///     }
    ///
    public var projectedValue: Binding<Value> { get }
}

注意

在许多之前的书籍或内容中,State 结构体还遵守了 BindingConvertible 协议,但从 Xcode 13 以及 Xcode 14-beta 中均已经找不到该协议的内容。

由上我们可以得出以下结论:

  1. 使用赋值 @State 变量的方式是通过 init(wrappedValue:) 初始化的;
  2. 访问或更新 @State 变量值的本质是通过对 wrappedValue 访问或更新的;
  3. 通过 $ 访问 @State 变量的本质是 projectedValue,投射值使得原本的值类型变量具有了引用语义;
  4. @State 变量要使用 private 修饰,当需要子组件更新值时,需要声明为 @Binidng 并使用 $ 按引用传值。

另外,有人可能和我一样有疑问,为什么 @State 提供了两个类似的初始化方法?答案是最初的提议是通过 init(initialValue:) 实现的,而后改为了 init(wrappedValue:)。但由于向后兼容,前者也得以保留。具体也可见 Swift 官方论坛的讨论:SwiftUI.State: .init(wrappedValue:) vs. .init(initialValue:): what’s the difference?

自定义 propertyWrapper

属性包装器的本质类似于自定义 getter 和 setter。

@propertyWrapper struct WrapperDemo {
    var value: Int = 0

    var wrappedValue: Int {
        get {
            value
        }
        set {
            value = newValue
        }
    }

    init(wrappedValue: Int) {
        print(#function, wrappedValue)
        self.wrappedValue = wrappedValue
    }

    init(initialValue: Int) {
        print(#function, initialValue)
        self.wrappedValue = initialValue
    }
}


struct ModelDemo {
    // init(initialValue: Int)
    @WrapperDemo(initialValue: 10) var d1
    // public init(wrappedValue: Int)
    @WrapperDemo var d2 = 20
    
    func test() {
        print(d1) // #1    0x0000000100006040 in ModelDemo.d1.getter ()
        print(d2)
    }
}

let d = ModelDemo()
d.test()

// init(initialValue:) 10
// init(wrappedValue:) 20
// 10
// 20

如上,我们也可以封装属性包装器,使得依赖 getter 和 setter 的代码更加优雅。

参考