肘子的 Swift 记事本

Going Beyond @Published:Empowering Custom Property Wrappers

Published on

Get weekly handpicked updates on Swift and SwiftUI!

This article will introduce the communication mechanism between @Published and instances of classes that conform to the ObservableObject protocol, and demonstrate how to add the ability to access the properties or methods of the wrapped class instance for other custom property wrapper types through three examples: @MyPublished (a replica of @Published), @PublishedObject (a version of @Published that wraps reference types), and @CloudStorage (similar to @AppStorage, but for NSUbiquitousKeyValueStore).

What is the ability of @Published

@Published is the most commonly used property wrapper in the Combine framework. Properties marked with @Published will notify their subscribers (provided through $ or projectedValue Publisher) of the upcoming changes when the property is modified.

Don’t be confused by the “ed” suffix in its name, it actually publishes the change before it occurs (willSet).

Swift
class Weather {
    @Published var temperature: Double
    init(temperature: Double) {
        self.temperature = temperature
    }
}

let weather = Weather(temperature: 20)
let cancellable = weather.$temperature
    .sink() {
        print ("Temperature now: \($0)")
}
weather.temperature = 25

// Temperature now: 20.0
// Temperature now: 25.0

In classes that conform to the ObservableObject protocol, properties marked with @Published will not only notify subscribers of their own Publisher when they change, but also notify subscribers of the enclosing class instance’s objectWillChange. This feature makes @Published one of the most useful property wrappers in SwiftUI.

Swift
class Weather:ObservableObject {  // 遵循 ObservableObject
    @Published var temperature: Double
    init(temperature: Double) {
        self.temperature = temperature
    }
}

let weather = Weather(temperature: 20)
let cancellable = weather.objectWillChange // 订阅 weather 实例的 obejctWillChange
    .sink() { _ in
        print ("weather will change")
}
weather.temperature = 25

// weather will change

Speaking solely based on the timing of calling objectWillChange wrapped by the package, the following code behaves the same as the previous code, but in the @Published version, we did not provide an instance wrapped by the @Published, it is implicitly obtained.

Swift
class Weather:ObservableObject {
    var temperature: Double{  // Not marked with @Published
        willSet {  // Calls objectWillChange on the class instance before changing
            self.objectWillChange.send()  // Explicitly references the Weahter instance in code
        }
    }
    init(temperature: Double) {
        self.temperature = temperature
    }
}

let weather = Weather(temperature: 20)
let cancellable = weather.objectWillChange // Subscribes the weather instance
    .sink() { _ in
        print ("weather will change")
}
weather.temperature = 25

// weather will change

For a long time, I took for granted the behavior of calling the instance method objectWillChange wrapped by the @Published package, and never seriously thought about how it was implemented. It wasn’t until I discovered that, in addition to @Published, @AppStorage also has the same behavior (see Mastering @AppStorage in SwiftUI) that I realized that perhaps we can make other property wrappers have similar behavior, creating more use cases.

The capability added to other property wrappers in this article, similar to @Published, refers to the ability to access properties or methods of the class instance wrapped by the property wrapper without explicitly setting it.

The Secret of @Published Capability

Finding Answers from Proposals

I wasn’t used to reading proposals from swift-evolution before because excellent bloggers like Paul Hudson would extract and organize new language features in a quick and easy-to-read way whenever Swift released a new feature. However, adding, modifying, or removing a language feature is actually a lengthy process that requires continuous discussion and modification of proposals. Proposals summarize this process into documents for every developer to read and analyze. Therefore, if you want to know the ins and outs of a certain Swift feature in detail, it’s best to carefully read its corresponding proposal document.

In the document about Property Wrappers, there is a special mention of how to refer to the enclosing class instance in a wrapper type - Referencing the enclosing ‘self’ in a wrapper type.

The proposer suggests: by allowing the property wrapper type to provide a static subscript method, automatic acquisition of the enclosing class instance that it wraps can be achieved (without explicit setting).。

Swift
// 提案建议的下标方法
public static subscript<OuterSelf>(
        instanceSelf: OuterSelf,
        wrapped: ReferenceWritableKeyPath<OuterSelf, Value>,
        storage: ReferenceWritableKeyPath<OuterSelf, Self>) -> Value

Although the proposal’s Future Directions section mentions this approach, Swift has already included support for it. However, the code in the documentation is not entirely consistent with Swift’s current implementation. Fortunately, someone on stackoverflow provided the correct parameter names for this subscript method:

Swift
public static subscript<OuterSelf>(
        _enclosingInstance: OuterSelf, // The correct parameter name is _enclosingInstance
        wrapped: ReferenceWritableKeyPath<OuterSelf, Value>,
        storage: ReferenceWritableKeyPath<OuterSelf, Self>
    ) -> Value

@Published gains its “special” ability by implementing this subscript method.

How Property Wrappers Work

Considering the numerous variants of the wrapped value in property wrappers, the Swift community has not adopted a standard Swift protocol approach to define the functionality of property wrappers. Instead, developers can customize the property wrapper types by declaring the @propertyWrapper attribute. Similar to the @resultBuilder introduced in the “Mastering Result Builders” article, the compiler first translates the code for user-defined property wrapper types before the final compilation.

Swift
struct Demo {
    @State var name = "fat"
}

The compiler translates the code above into:

Swift
struct Demo {
    private var _name = State(wrappedValue: "fat")
    var name: String {
        get { _name.wrappedValue }
        set { _name.wrappedValue = newValue }
    }
}

It can be seen that propertyWrapper is not particularly magical, it is just a syntax sugar. The above code also explains why after using property wrappers, it is not possible to declare variables with the same name (prefixed with an underscore).

Swift
// It is not possible to declare variables with the same name (prefixed with an underscore) after using property wrappers.
struct Demo {
    @State var name = "fat"
    var _name:String = "ds"  // invalid redeclaration of synthesized property '_name'
}
// '_name' synthesized for property wrapper backing storage

When the property wrapper only provides wrappedValue (like State in the above code), the translated getter and setter will directly use wrappedValue. However, once the property wrapper implements the static subscript method introduced earlier, the translated code will be as follows:

Swift
class Test:ObservableObject{
    @Published var name = "fat"
}

// Translated to
class Test:ObservableObject{
    private var _name = Published(wrappedValue: "fat")
    var name:String {
        get {
            Published[_enclosingInstance: self,
                                 wrapped: \Test.name,
                                 storage: \Test._name]
        }
        set {
            Published[_enclosingInstance: self,
                                 wrapped: \Test.name,
                                 storage: \Test._name] = newValue
        }
    }
}

When the property wrapper implements the static subscript method and is wrapped by a class, the compiler will use the static subscript method to implement the getter and setter.

The three parameters of the subscript method are:

  • _enclosingInstance

    The instance of the class that wraps the current property wrapper

  • wrapped

    The KeyPath for the externally calculated property (corresponding to the name KeyPath in the above code)

  • storage

    The KeyPath for the internally stored property (corresponding to the _name KeyPath in the above code)

In practical use, we only need to use _enclosingInstance and storage. Although the subscript method provides the wrapped parameter, we currently cannot call it. Reading and writing this value will cause the application to lock up.

From the above introduction, we can draw the following conclusions:

  • The “special” ability of @Published is not unique to it and is not related to specific property wrapper types.
  • Any property wrapper type that implements this static subscript method can possess the so-called “special” ability discussed in this article.
  • Since the subscript parameters wrapped and storage are of type ReferenceWritableKeyPath, the compiler will only translate them into subscript versions of the getter and setter when the property wrapper type is wrapped by a class.

The sample code for this article can be found here.

Learning from Imitation - Creating @MyPublished

Practice is the only criterion for testing truth. In this section, we will replicate @Published to verify the content mentioned earlier.

Since the code is simple, only the following points are made as prompts:

  • The type of projectedValue of @Published is Published.Publisher<Value,Never>
  • By wrapping CurrentValueSubject, a custom Publisher can be easily created
  • Calling objectWillChange of the wrapper class instance and sending information to the subscriber of projectedValue should be done before changing wrappedValue
Swift
@propertyWrapper
public struct MyPublished<Value> {
    public var wrappedValue: Value {
        willSet {  // Before modifying wrappedValue
            publisher.subject.send(newValue)
        }
    }

    public var projectedValue: Publisher {
        publisher
    }

    private var publisher: Publisher

    public struct Publisher: Combine.Publisher {
        public typealias Output = Value
        public typealias Failure = Never

        var subject: CurrentValueSubject<Value, Never> // PassthroughSubject will lack the call of initial assignment

        public func receive<S>(subscriber: S) where S: Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
            subject.subscribe(subscriber)
        }

        init(_ output: Output) {
            subject = .init(output)
        }
    }

    public init(wrappedValue: Value) {
        self.wrappedValue = wrappedValue
        publisher = Publisher(wrappedValue)
    }

    public static subscript<OuterSelf: ObservableObject>(
        _enclosingInstance observed: OuterSelf,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
    ) -> Value {
        get {
            observed[keyPath: storageKeyPath].wrappedValue
        }
        set {
            if let subject = observed.objectWillChange as? ObservableObjectPublisher {
                subject.send() // Before modifying wrappedValue
                observed[keyPath: storageKeyPath].wrappedValue = newValue
            }
        }
    }
}

Now, @MyPublished has exactly the same functionality and behavior as @Published:

Swift
class T: ObservableObject {
    @MyPublished var name = "fat" // Replacing MyPublished with Published will yield the same result
    init() {}
}

let object = T()

let c1 = object.objectWillChange.sink(receiveValue: {
    print("object will changed")
})
let c2 = object.$name.sink{
    print("name will get new value \($0)")
}

object.name = "bob"

// name will get new value fat
// object will changed
// name will get new value bob

In the following text, we will demonstrate how to apply this ability to other property wrapper types.

@PublishedObject —— The reference type version of @Published

@Published can only be used to wrap value types. When wrappedValue is a reference type, changing the properties of the wrapped value will not notify the subscribers. For example, in the following code, we will not receive any notifications:

Swift
class RefObject {
    var count = 0
    init() {}
}

class Test: ObservableObject {
    @Published var ref = RefObject()
}

let test = Test()
let cancellable = test.objectWillChange.sink{ print("object will change")}

test.ref.count = 100
// no notification will be sent

To solve this problem, we can implement a version of @Published that works with reference types —— @PublishedObject.

Note:

  • The wrappedValue of @PublishedObject is a reference type that conforms to the ObservableObject protocol.
  • In the property wrapper, objectWillChange is subscribed to wrappedValue, and the specified closure is called whenever wrappedValue changes.
  • After the property wrapper is created, the system will immediately call the getter of the static subscript once, and choose to subscribe to wrappedValue and set up the closure at this time.
Swift
@propertyWrapper
public struct PublishedObject<Value: ObservableObject> { // wrappedValue requires to conform to ObservableObject
    public var wrappedValue: Value

    public init(wrappedValue: Value) {
        self.wrappedValue = wrappedValue
    }

    public static subscript<OuterSelf: ObservableObject>(
        _enclosingInstance observed: OuterSelf,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
    ) -> Value where OuterSelf.ObjectWillChangePublisher == ObservableObjectPublisher {
        get {
            if observed[keyPath: storageKeyPath].cancellable == nil {
                // This is executed only once.
                observed[keyPath: storageKeyPath].setup(observed)
            }
            return observed[keyPath: storageKeyPath].wrappedValue
        }
        set {
            observed.objectWillChange.send() // willSet
            observed[keyPath: storageKeyPath].wrappedValue = newValue
        }
    }

    private var cancellable: AnyCancellable?
    // Subscribe to objectWillChange of wrappedvalue.
    // When wrappedValue sends a notification, call the _enclosingInstance's objectWillChange.send().
    // Use a closure to weakly reference _enclosingInstance.
    private mutating func setup<OuterSelf: ObservableObject>(_ enclosingInstance: OuterSelf) where OuterSelf.ObjectWillChangePublisher == ObservableObjectPublisher {
        cancellable = wrappedValue.objectWillChange.sink(receiveValue: { [weak enclosingInstance] _ in
            (enclosingInstance?.objectWillChange)?.send()
        })
    }
}

@PublishedObject provides us with more flexible capabilities to drive SwiftUI views. For example, we can use @PublishedObject like this:

Swift
@objc(Event)
public class Event: NSManagedObject {
    @NSManaged public var timestamp: Date?
}

class Store: ObservableObject {
    @PublishedObject var event = Event(context: container.viewContext)

    init() {
        event.timestamp = Date().addingTimeInterval(-1000)
    }
}

struct DemoView: View {
    @StateObject var store = Store()
    var body: some View {
        VStack {
            Text(store.event.timestamp, format: .dateTime)
            Button("Now") {
                store.event.timestamp = .now
            }
        }
        .frame(width: 300, height: 300)
    }
}

https://cdn.fatbobman.com/publishedObject_demo1_2022-05-15_09.28.41.2022-05-15%2009_29_23.gif

@CloudStorage —— CloudKit version of @AppStorage

In the article Mastering @AppStorage in SwiftUI, I introduced that besides @Published, @AppStorage also has the ability to reference a wrapped class instance. Therefore, we can use the following code to manage UserDefaults uniformly in SwiftUI:

Swift
class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
}

Tom Lokhorst has written a third-party library similar to @AppStorage called @CloudStorage, which enables updates to SwiftUI views when changes occur in NSUbiquitousKeyValueStore:

Swift
struct DemoView: View {
    @CloudStorage("readyForAction") var readyForAction: Bool = false
    @CloudStorage("numberOfItems") var numberOfItems: Int = 0
    var body: some View {
        Form {
            Toggle("Ready", isOn: $readyForAction)
                .toggleStyle(.switch)
            TextField("numberOfItems",value: $numberOfItems,format: .number)
        }
        .frame(width: 400, height: 400)
    }
}

We can use the method introduced in this article to add capabilities similar to @Published.

Code highlights:

  • Since the work of setting projectValue and _setValue is done in the CloudStorage constructor, the closure sender, which is nil at this time, can only be captured by creating a class instance holder to hold the closure, so that sender can be assigned a value using the subscript method.
  • Note that the timing of calling holder?.sender?() should be consistent with the willSet behavior.
Swift
@propertyWrapper public struct CloudStorage<Value>: DynamicProperty {
    private let _setValue: (Value) -> Void

    @ObservedObject private var backingObject: CloudStorageBackingObject<Value>

    public var projectedValue: Binding<Value>

    public var wrappedValue: Value {
        get { backingObject.value }
        nonmutating set { _setValue(newValue) }
    }

    public init(keyName key: String, syncGet: @escaping () -> Value, syncSet: @escaping (Value) -> Void) {
        let value = syncGet()

        let backing = CloudStorageBackingObject(value: value)
        self.backingObject = backing
        self.projectedValue = Binding(
            get: { backing.value },
            set: { [weak holder] newValue in
                backing.value = newValue
                holder?.sender?() // Note the timing of the call
                syncSet(newValue)
                sync.synchronize()
            })
        self._setValue = { [weak holder] (newValue: Value) in
            backing.value = newValue
            holder?.sender?()
            syncSet(newValue)
            sync.synchronize()
        }

        sync.setObserver(for: key) { [weak backing] in
            backing?.value = syncGet()
        }
    }

    // Because the work of setting projectValue and _setValue is done in the constructor, and the closure sender (which was nil at the time) cannot be captured alone, create a class instance to hold the closure so that it can be configured using the subscript method.
    class Holder{
        var sender: (() -> Void)?
        init(){}
    }

    var holder = Holder()

    public static subscript<OuterSelf: ObservableObject>(
        _enclosingInstance observed: OuterSelf,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
    ) -> Value {
        get {
            // The timing and logic of setting holder is consistent with @PublishedObject.
            if observed[keyPath: storageKeyPath].holder.sender == nil {
                observed[keyPath: storageKeyPath].holder.sender = { [weak observed] in
                    (observed?.objectWillChange as? ObservableObjectPublisher)?.send()
                }
            }
            return observed[keyPath: storageKeyPath].wrappedValue
        }
        set {
            if let subject = observed.objectWillChange as? ObservableObjectPublisher {
                subject.send()
                observed[keyPath: storageKeyPath].wrappedValue = newValue
            }
        }
    }
}

Using the modified code, @AppStorage and @CloudStorage can be managed uniformly for easy use in SwiftUI views:

Swift
class Settings:ObservableObject {
       @AppStorage("name") var name = "fat"
       @AppStorage("age") var age = 5
       @CloudStorage("readyForAction") var readyForAction = false
       @CloudStorage("speed") var speed: Double = 0
}

struct DemoView: View {
    @StateObject var settings = Settings()
    var body: some View {
        Form {
            TextField("Name", text: $settings.name)
            TextField("Age", value: $settings.age, format: .number)
            Toggle("Ready", isOn: $settings.readyForAction)
                .toggleStyle(.switch)
            TextField("Speed", value: $settings.speed, format: .number)
            Text("Name: \(settings.name)")
            Text("Speed: ") + Text(settings.speed, format: .number)
            Text("ReadyForAction: ") + Text(settings.readyForAction ? "True" : "False")
        }
        .frame(width: 400, height: 400)
    }
}

https://cdn.fatbobman.com/cloudStorage_demo_2022-05-15_09.41.31.2022-05-15%2009_42_28.gif

Summary

Many things are often seen as black magic when we don’t understand them. But once we break through the magic barrier, we may find that they are not as mysterious as we thought.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways