為自定義屬性包裝型別新增類 @Published 的能力
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 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
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) } } ```
@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
@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) } } ```
總結
很多東西在我們對其不瞭解時,常將其視為黑魔法。但只要穿越其魔法屏障就會發現,或許並沒有想象中的那麼玄奧。
希望本文能夠對你有所幫助。
原文發表在我的部落格 wwww.fatbobman.com
歡迎訂閱我的公共號:【肘子的Swift記事本】
- 自定義 Button 的外觀和互動行為
- MacBook Pro 使用體驗
- 用 SwiftUI 的方式進行佈局
- 聊一聊可組裝框架( TCA )
- StateObject 與 ObservedObject
- 一些適合 SwiftUI 初學者的教程
- iBug 16 有感
- 在 SwiftUI 中實現檢視居中的若干種方法
- SwiftUI 佈局 —— 尺寸( 下 )
- SwiftUI 佈局 —— 尺寸( 上 )
- SwiftUI 佈局 —— 對齊
- Core Data with CloudKit(三)——CloudKit儀表臺
- Core Data with CloudKit(二)——同步本地資料庫到iCloud私有資料庫
- 在CoreData中使用持久化歷史跟蹤
- 用 Table 在 SwiftUI 下建立表格
- SwiftUI 4.0 的全新導航系統
- 如何在 Core Data 中進行批量操作
- Core Data 是如何在 SQLite 中儲存資料的
- 在 SwiftUI 檢視中開啟 URL 的若干方法
- 為自定義屬性包裝型別新增類 @Published 的能力