為自定義屬性包裝型別新增類 @Published 的能力

語言: CN / TW / HK

highlight: a11y-dark

本文將對 @Published 與符合 ObservableObject 協議的類例項之間的溝通機制做以介紹,並通過三個示例:@MyPublished( @Published 的仿製版本 )、@PublishedObject(包裝值為引用型別的 @Published 版本)、@CloudStorage(類似 @AppStorage ,但適用於 NSUbiquitousKeyValueStore ),來展示如何為其他的自定義屬性包裝型別新增可訪問包裹其的類例項的屬性或方法的能力。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】

何為 @Published 的能力

@Published 是 Combine 框架中最常用到的屬性包裝器。通過 @Published 標記的屬性在發生改變時,其訂閱者(通過 $projectedValue 提供的 Publisher )將收到即將改變的值。

不要被它名稱尾綴的 ed 所迷惑,它的釋出時機是在改變前( 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 ```

而在符合 ObservableObject 協議的類中,通過 @Published 標記的屬性在發生改變時,除了會通知自身 Publisher 的訂閱者外,也會通過包裹它的類例項的 objectWillChange 來通知類例項( 符合 ObservableObject 協議)的訂閱者。這一特性,也讓 @Published 成為 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 ```

僅從呼叫包裹其類的 objectWillChange 的時機來講,下面的程式碼與上面的程式碼的表現是一樣的,但在 @Published 的版本中,我們並沒有為 @Published 提供包裹其類的例項,它是隱式獲得的

```swift class Weather:ObservableObject { var temperature: Double{ // 沒有使用 @Published 進行標記 willSet { // 改變前呼叫類例項的 objectWillChange self.objectWillChange.send() // 在程式碼中明確地引用了 Weahter 例項 } } init(temperature: Double) { self.temperature = temperature } }

let weather = Weather(temperature: 20) let cancellable = weather.objectWillChange // 訂閱 weather 例項 .sink() { _ in print ("weather will change") } weather.temperature = 25

// weather will change ```

長期以來,我一直將 @Published 呼叫包裹其類的例項方法的行為視為理所當然,從未認真想過它是如何實現的。直到我發現除了 @Published 外,@AppStorage 也具備同樣的行為(參閱 @AppStorage 研究),此時我意識到或許我們可以讓其他的屬性包裝型別具備類似的行為,建立更多的使用場景。

本文中為其他屬性包裝型別新增的類似 @Published 的能力是指 —— 無需顯式設定,屬性包裝型別便可訪問包裹其的類例項的屬性或方法

@Published 能力的祕密

從 Proposal 中找尋答案

我之前並不習慣於看 swift-evolution 的 proposal,因為每當 Swift 推出新的語言特性後,很多像例如 Paul Hudson 這樣的優秀博主會在第一時間將新特性提煉並整理出來,讀起來又快又輕鬆。但為一個語言新增、修改、刪除某項功能事實上是一個比較漫長的過程,期間需要對提案不斷地進行討論和修改。proposal 將該過程彙總成文件供每一個開發者來閱讀、分析。因此,如果想詳細瞭解某一項 Swift 新特性的來龍去脈,最好還是要認真閱讀與其對應的 proposal 文件。

在有關 Property Wrappers 的文件中,對於如何在屬性包裝型別中引用包裹其的類例項是有特別提及的 —— Referencing the enclosing 'self' in a wrapper type

提案者提出:通過讓屬性包裝型別提供一個靜態下標方法,以實現對包裹其的類例項的自動獲取(無需顯式設定)。

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

雖然此種方式是在 proposal 的未來方向一章中提及的,但 Swift 已經對其提供了支援。不過,文件中的程式碼與 Swift 當前的實現並非完全一致,幸好有人在 stackoverflow 上提供了該下標方法的正確引數名稱:

swift public static subscript<OuterSelf>( _enclosingInstance: OuterSelf, // 正確的引數名為 _enclosingInstance wrapped: ReferenceWritableKeyPath<OuterSelf, Value>, storage: ReferenceWritableKeyPath<OuterSelf, Self> ) -> Value

@Published 就是通過實現了該下標方法從而獲得了“特殊”能力。

屬性包裝器的運作原理

考慮到屬性包裝器中的包裝值( wrappedValue )眾多的變體形式,Swift 社群並沒有採用標準的 Swift 協議的方式來定義屬性包裝器功能,而是讓開發者通過宣告屬性 @propertyWrapper 來自定義屬性包裝型別。與 掌握 Result builders 一文中介紹的 @resultBuilder 類似,編譯器在最終編譯前,首先會對使用者自定義的屬性包裝型別程式碼進行轉譯。

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

上面的程式碼,編譯器將其轉譯成:

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

可以看出 propertyWrapper 沒有什麼特別的魔法,就是一個語法糖。上面的程式碼也解釋了為什麼在使用了屬性包裝器後,無法再宣告相同名稱(前面加下劃線)的變數。

swift // 在使用了屬性包裝器後,無法再宣告相同名稱(前面加下劃線)的變數。 struct Demo { @State var name = "fat" var _name:String = "ds" // invalid redeclaration of synthesized property '_name' } // '_name' synthesized for property wrapper backing storage

當屬性包裝型別僅提供了 wrappedValue 時(比如上面的 State ),轉譯後的 getter 和 setter 將直接使用 wrappedValue ,不過一旦屬性包裝型別實現了上文介紹的靜態下標方法,轉譯後將變成如下的程式碼:

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

// 轉譯為 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 } } } ```

當屬性包裝器實現了靜態下標方法且被所包裹時,編譯器將優先使用靜態下標方法來實現 getter 和 setter 。

下標方法的三個引數分別為:

  • _enclosingInstance

包裹當前屬性包裝器的類例項

  • wrapped

對外計算屬性的 KeyPath (上面程式碼中對應 name 的 KeyPath )

  • storage

內部儲存屬性的 KeyPath (上面程式碼中對應 _name 的 KeyPath )

在實際使用中,我們只需使用 _enclosingInstance 和 storage 。儘管下標方法提供了 wrapped 引數,但我們目前無法呼叫它。讀寫該值都將導致應用鎖死

通過上面的介紹,我們可以得到以下結論:

  • @Published 的“特殊”能力並非其獨有的,與特定的屬性包裝型別無關
  • 任何實現了該靜態下標方法的屬性包裝型別都可以具備本文所探討的所謂“特殊”能力
  • 由於下標引數 wrapped 和 storage 為 ReferenceWritableKeyPath 型別,因此只有在屬性包裝型別被類包裹時,編譯器才會轉譯成下標版本的 getter 和 setter

可以在此處獲得 本文的範例程式碼

從模仿中學習 —— 建立 @MyPublished

實踐是檢驗真理的唯一標準。本節我們將通過對 @Published 進行復刻來驗證上文中的內容。

因為程式碼很簡單,所以僅就以下幾點做以提示:

  • @Published 的 projectedValue 的型別為 Published.Publisher<Value,Never>
  • 通過對 CurrentValueSubject 的包裝,即可輕鬆地建立自定義 Publisher
  • 呼叫包裹類例項的 objectWillChange 和給 projectedValue 的訂閱者傳送資訊均應在更改 wrappedValue 之前

```swift @propertyWrapper public struct MyPublished { public var wrappedValue: Value { willSet { // 修改 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 會缺少初始話賦值的呼叫

    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() // 修改 wrappedValue 之前
            observed[keyPath: storageKeyPath].wrappedValue = newValue
        }
    }
}

} ```

現在,@MyPublished 擁有與 @Published 完全一樣的功能與行為表現:

```swift class T: ObservableObject { @MyPublished var name = "fat" // 將 MyPublished 替換成 Published 將獲得同樣的結果 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 ```

下文中我們將演示如何將此能力應用到其他的屬性包裝型別

@PublishedObject —— @Published 的引用型別版本

@Published 只能勝任包裝值為值型別的場景,當 wrappedValue 為引用型別時,僅改變包裝值的屬性內容並不會對外發布通知。例如下面的程式碼,我們不會收到任何提示:

```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 // 不會有提示 ```

為此,我們可以實現一個適用於引用型別的 @Published 版本 —— @PublishedObject

提示:

  • @PublishedObject 的 wrappedValue 為遵循 ObservableObject 協議的引用型別
  • 在屬性包裝器中訂閱 wrappedValue 的 objectWillChange ,每當 wrappedValue 發生改變時,將呼叫指定的閉包
  • 在屬性包裝器建立後,系統會立刻呼叫靜態下標的 getter 一次,選擇在此時機完成對 wrappedValue 的訂閱和閉包的設定

```swift @propertyWrapper public struct PublishedObject { // wrappedValue 要求符合 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 {
            // 只會執行一次
            observed[keyPath: storageKeyPath].setup(observed)
        }
        return observed[keyPath: storageKeyPath].wrappedValue
    }
    set {
        observed.objectWillChange.send() // willSet
        observed[keyPath: storageKeyPath].wrappedValue = newValue
    }
}

private var cancellable: AnyCancellable?
// 訂閱 wrappedvalue 的 objectWillChange 
// 每當 wrappedValue 傳送通知時,呼叫 _enclosingInstance 的 objectWillChange.send。
// 使用閉包對 _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 為我們提供了更加靈活的能力來驅動 SwiftUI 的檢視,比如我們可以這樣使用 @PublishedObject :

```swift @objc(Event) public class Event: NSManagedObject { // Core Data 的託管物件符合 ObservableObject 協議 @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) } } ```

publishedObject_demo1_2022-05-15_09.28.41.2022-05-15 09_29_23

@CloudStorage —— @AppStorage 的 CloudKit 版本

@AppStorage 研究 一文中,我介紹過,除了 @Published 外,@AppStorage 也同樣具備引用包裹其的類例項的能力。因此,我們可以使用如下的程式碼在 SwiftUI 中統一管理 UserDefaults :

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

Tom Lokhorst 寫了一個類似 @AppStorage 的第三方庫 —— @CloudStorage ,實現了在 NSUbiquitousKeyValueStore 發生變化時可以驅動 SwiftUI 檢視的更新:

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) } }

我們可以使用本文介紹的方法為其添加了類似 @Published 的能力。

在撰寫 在 SwiftUI 下使用 NSUbiquitousKeyValueStore 同步資料 一文的時候,我尚未掌握本文介紹的方法。當時只能採用一種比較笨拙的手段來與包裹 @CloudStorage 的類例項進行通訊。現在我已用本文介紹的方式重新修改了 @CloudStorage 程式碼。由於 @CloudeStorage 的作者尚未將修改後的程式碼合併,因此大家目前可以暫時使用我 修改後的 Fork 版本

程式碼要點:

  • 由於設定的 projectValue 和 _setValue 的工作是在 CloudStorage 構造器中進行的,此時只能捕獲為 nil 的閉包 sender ,通過建立一個類例項 holder 來持有閉包,以便可以通過下標方法為 sender 賦值。
  • 注意 holder?.sender?() 的呼叫時機,應與 willSet 行為一致

```swift @propertyWrapper public struct CloudStorage: 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?() // 注意呼叫時機
            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()
    }
}

// 因為設定的 projectValue 和 _setValue 的工作是在構造器中進行的,無法僅捕獲閉包 sender(當時還是 nil),建立一個類例項來持有閉包,以便可以通過下標方法配置。
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 {
        // 設定 holder 的時機和邏輯與 @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
        }
    }
}

} ```

使用修改後的程式碼,可以將 @AppStorage 和 @CloudStorage 統一管理,以方便在 SwiftUI 檢視中使用:

```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) } } ```

cloudStorage_demo_2022-05-15_09.41.31.2022-05-15 09_42_28

總結

很多東西在我們對其不瞭解時,常將其視為黑魔法。但只要穿越其魔法屏障就會發現,或許並沒有想象中的那麼玄奧。

希望本文能夠對你有所幫助。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】