为计算属性添加@Published行为

9

我试图创建一个ObservableObject,用于包装一个UserDefaults变量的属性。

为了符合ObservableObject协议,我需要使用@Published来包装这些属性。但是,对于像我用于UserDefaults值的计算属性,我无法应用它。

我该如何使其工作?我需要做什么才能达到@Published的行为?


1
也许我理解错了,但是没有必要用@Published包装每个属性。因此,您可以在类中同时拥有已发布的属性和普通属性,这些属性是计算出来的。如果您分享一些代码以澄清您想要实现的内容,那将会很有帮助。 - jboi
4个回答

12

当Swift更新以启用嵌套属性包装器时,可能的做法是创建一个@UserDefault属性包装器并与@Published结合使用。

同时,我认为处理这种情况的最佳方法是手动实现ObservableObject而不是依赖于@Published。像这样:

class ViewModel: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    var name: String {
        get {
            UserDefaults.standard.string(forKey: "name") ?? ""
        }
        set {
            objectWillChange.send()
            UserDefaults.standard.set(newValue, forKey: "name")
        }
    }
}

属性包装器

正如我在评论中提到的那样,我认为没有一种方法可以将其封装在一个属性包装器中,以去除所有样板文件,但这是我能想到的最好的方法:

@propertyWrapper
struct PublishedUserDefault<T> {
    private let key: String
    private let defaultValue: T

    var objectWillChange: ObservableObjectPublisher?

    init(wrappedValue value: T, key: String) {
        self.key = key
        self.defaultValue = value
    }

    var wrappedValue: T {
        get {
            UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            objectWillChange?.send()
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

class ViewModel: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    @PublishedUserDefault(key: "name")
    var name: String = "John"

    init() {
        _name.objectWillChange = objectWillChange
    }
}

你仍然需要声明objectWillChange并将其与你的属性包装器连接起来(我在init中完成),但至少属性定义本身非常简单。


1
是的,这个方法可以正常工作,但我明确地想通过使用PorpertyWrapper来摆脱样板文件。 - Nicolas Degen
不幸的是,只有使用@Published才能自动触发'objectWillChange'行为,据我所知,这是硬编码的。因此,你可以创建一个自定义属性包装器,它保存到用户默认值并且也是一个发布者,就像你的答案中一样,但是如果没有某种额外的样板文件,你是无法使其自动更新的。 - jjoelson
你会对我的答案做出哪些具体的修改来实现这一点? - Nicolas Degen
啊,我明白了。我曾尝试在属性初始化中传递一个对 objectWillChange 的引用,但由于它尚未初始化,所以不幸地不起作用。 - Nicolas Degen

9

对于已存在的@Published属性

有一种方法可以实现,您可以创建一个lazy属性,该属性返回从您的@Published发布者派生的发布者:

import Combine

class AppState: ObservableObject {
  @Published var count: Int = 0
  lazy var countTimesTwo: AnyPublisher<Int, Never> = {
    $count.map { $0 * 2 }.eraseToAnyPublisher()
  }()
}

let appState = AppState()
appState.count += 1
appState.$count.sink { print($0) }
appState.countTimesTwo.sink { print($0) }
// => 1
// => 2
appState.count += 1
// => 2
// => 4

然而,这是人为的,可能没有太多实际用途。请查看下一节,了解更有用的内容...

对于支持KVO的任何对象

UserDefaults 支持KVO。我们可以创建一个通用的解决方案,称为 KeyPathObserver,它使用单个 @ObjectObserver 响应支持KVO的对象的更改。以下示例将在Playground中运行:

import Foundation
import UIKit
import PlaygroundSupport
import SwiftUI
import Combine

let defaults = UserDefaults.standard

extension UserDefaults {
  @objc var myCount: Int {
    return integer(forKey: "myCount")
  }

  var myCountSquared: Int {
    return myCount * myCount
  }
}

class KeyPathObserver<T: NSObject, V>: ObservableObject {
  @Published var value: V
  private var cancel = Set<AnyCancellable>()

  init(_ keyPath: KeyPath<T, V>, on object: T) {
    value = object[keyPath: keyPath]
    object.publisher(for: keyPath)
      .assign(to: \.value, on: self)
      .store(in: &cancel)
  }
}

struct ContentView: View {
  @ObservedObject var defaultsObserver = KeyPathObserver(\.myCount, on: defaults)

  var body: some View {
    VStack {
      Text("myCount: \(defaults.myCount)")
      Text("myCountSquared: \(defaults.myCountSquared)")
      Button(action: {
        defaults.set(defaults.myCount + 1, forKey: "myCount")
      }) {
        Text("Increment")
      }
    }
  }
}
let viewController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = viewController

请注意,我们已经向UserDefaults扩展中添加了一个额外的属性myCountSquared来计算派生值,但是观察原始的KeyPath

screenshot


我不太明白如何在PropertyWrapper中使用它。将wrappedValue的类型设置为AnyPublisher<T,Never>? - Nicolas Degen
@NicolasDegen 我在我的回答中特别添加了关于 UserDefaults 的内容,这样更有用吗? - Gil Birman
看起来很有趣!我必须说,尽管它还不完美,但我更喜欢我的提议解决方案。我仍然希望我可以将其作为简单的PropertyWrapper使其正常工作:) - Nicolas Degen

5

更新:通过使用EnclosingSelf下标,你可以轻松实现它!

非常好用!

import Combine
import Foundation

class LocalSettings: ObservableObject {
  static var shared = LocalSettings()

  @Setting(key: "TabSelection")
  var tabSelection: Int = 0
}

@propertyWrapper
struct Setting<T> {
  private let key: String
  private let defaultValue: T

  init(wrappedValue value: T, key: String) {
    self.key = key
    self.defaultValue = value
  }

  var wrappedValue: T {
    get {
      UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
    }
    set {
      UserDefaults.standard.set(newValue, forKey: key)
    }
  }

  public static subscript<EnclosingSelf: ObservableObject>(
    _enclosingInstance object: EnclosingSelf,
    wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, T>,
    storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Setting<T>>
  ) -> T {
    get {
      return object[keyPath: storageKeyPath].wrappedValue
    }
    set {
      (object.objectWillChange as? ObservableObjectPublisher)?.send()
      UserDefaults.standard.set(newValue, forKey: object[keyPath: storageKeyPath].key)
    }
  }
}


2
是的,这确实可以工作,但如果封闭实例不是ObservableObject,则编译器会出现段错误。错误信息为“静态下标'subscript(_enclosingInstance:wrapped:storage:)'要求'SomeType'符合'ObservableObject'”-已在Xcode 11.5中测试。 - Ralf Ebert
1
是的,我也注意到了。不确定该如何修复。OpenCombine使用了一些我还没搞明白的@available技巧。 - Nicolas Degen

1

1
当然,但在ObservableObject中拥有它会很好。 - Nicolas Degen
@NicolasDegen 这没有意义,ObservableObject 只能用于类,所以如果你已经有一个类,那么你不需要一个属性包装器。这些 View 结构体属性包装器的存在是为了使结构体表现得像类的实例。每次创建 View 结构体时,属性包装器中的属性值与上次创建结构体时相同。而类的实例始终保持其属性值。 - malhal
当然,这不是必需的。但就像@Published一样,它也会很方便 :) - Nicolas Degen
@Published 会在设置使用它的模型对象时向视图发送 objectWillChange 发布者,但是当视图已经可以使用 @AppStorage 时,为什么还要对用户默认值执行此操作呢?如果必须这样做,那么您可以将 objectWillChange 发布者重新定义为 UserDefaults KVO 发布者,然后您甚至不需要任何类型的 @Published 属性,一切都会自动完成。 - malhal
2
为了提高可读性,将所有属性收集到ObservableObject中。我还尝试将其实现到一些自定义包装器中,其中问题类似。 - Nicolas Degen
在SwiftUI中,对象很昂贵,只应将其用于模型对象或获取器/加载器。 - malhal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接