相当于在 Swift Combine 中使用 @Published 计算的属性?

Posted

技术标签:

【中文标题】相当于在 Swift Combine 中使用 @Published 计算的属性?【英文标题】:An equivalent to computed properties using @Published in Swift Combine? 【发布时间】:2020-01-31 20:26:56 【问题描述】:

在命令式 Swift 中,通常使用计算属性来方便地访问数据而无需复制状态。

假设我有这个类用于命令式 MVC:

class ImperativeUserManager 
    private(set) var currentUser: User? 
        didSet 
            if oldValue != currentUser 
                NotificationCenter.default.post(name: NSNotification.Name("userStateDidChange"), object: nil)
                // Observers that receive this notification might then check either currentUser or userIsLoggedIn for the latest state
            
        
    

    var userIsLoggedIn: Bool 
        currentUser != nil
    

    // ...

如果我想用 Combine 创建一个反应式等价物,例如为了与 SwiftUI 一起使用,我可以轻松地将 @Published 添加到存储属性以生成 Publishers,但不适用于计算属性。

    @Published var userIsLoggedIn: Bool  // Error: Property wrapper cannot be applied to a computed property
        currentUser != nil
    

我可以想到各种解决方法。我可以改为存储我的计算属性并保持更新。

选项 1:使用属性观察器:

class ReactiveUserManager1: ObservableObject 
    @Published private(set) var currentUser: User? 
        didSet 
            userIsLoggedIn = currentUser != nil
        
    

    @Published private(set) var userIsLoggedIn: Bool = false

    // ...

选项 2:在我自己的班级中使用 Subscriber

class ReactiveUserManager2: ObservableObject 
    @Published private(set) var currentUser: User?
    @Published private(set) var userIsLoggedIn: Bool = false

    private var subscribers = Set<AnyCancellable>()

    init() 
        $currentUser
            .map  $0 != nil 
            .assign(to: \.userIsLoggedIn, on: self)
            .store(in: &subscribers)
    

    // ...

但是,这些变通方法不如计算属性优雅。它们复制状态并且它们不会同时更新两个属性。

Publisher 添加到Combine 中的计算属性的适当等效方法是什么?

【问题讨论】:

Updating a @Published variable based on changes in an observed variable的可能重复 计算属性是一种派生属性。它们的值取决于依赖项的值。仅出于这个原因,可以说他们永远不会表现得像ObservableObject。您天生就假设ObservableObject 对象应该能够具有变异能力,根据定义,Computed Property 并非如此。 您找到解决方案了吗?我处于完全相同的情况,我想避免状态并且仍然能够发布 感谢private(set) 解决方案。对我帮助很大。 让所有subscribers 保持在一个好主意!我会采纳的 【参考方案1】:

你可以在你的 ObservableObject 中声明一个PassthroughSubject

class ReactiveUserManager1: ObservableObject 

    //The PassthroughSubject provides a convenient way to adapt existing imperative code to the Combine model.
    var objectWillChange = PassthroughSubject<Void,Never>()

    [...]

在您的 @Published var 的 didSet(willSet 可能更好)中,您将使用一个名为 send()

的方法
class ReactiveUserManager1: ObservableObject 

    //The PassthroughSubject provides a convenient way to adapt existing imperative code to the Combine model.
    var objectWillChange = PassthroughSubject<Void,Never>()

    @Published private(set) var currentUser: User? 
    willSet 
        userIsLoggedIn = currentUser != nil
        objectWillChange.send()
    

    [...]

您可以在WWDC Data Flow Talk查看它

【讨论】:

你应该导入 Combine 这与问题本身的选项1有何不同? option1中没有PassthroughSubject objectWillChange 不再需要声明它现在自动带有 ObservableObject 协议。【参考方案2】:

您无需对基于@Published 属性的计算属性执行任何操作。你可以像这样使用它:

class UserManager: ObservableObject 
  @Published
  var currentUser: User?

  var userIsLoggedIn: Bool 
    currentUser != nil
  


currentUser@Published 属性包装器中发生的情况是,它会在更改时调用ObservedObjectobjectWillChange.send()。 SwiftUI 视图不关心@ObservedObjects 的哪些属性发生了变化,它只会重新计算视图并在必要时重绘。

工作示例:

class UserManager: ObservableObject 
  @Published
  var currentUser: String?

  var userIsLoggedIn: Bool 
    currentUser != nil
  

  func logOut() 
    currentUser = nil
  

  func logIn() 
    currentUser = "Demo"
  

还有一个 SwiftUI 演示视图:

struct ContentView: View 

  @ObservedObject
  var userManager = UserManager()

  var body: some View 
    VStack( spacing: 50) 
      if userManager.userIsLoggedIn 
        Text( "Logged in")
        Button(action: userManager.logOut) 
          Text("Log out")
        
       else 
        Text( "Logged out")
        Button(action: userManager.logIn) 
          Text("Log in")
        
      
    
  

【讨论】:

使用您的解决方案(没有private (set)),currentUser 可以从用户管理器类外部访问,这在 OOP 方面不是一个好的做法。 谢谢你的解释,很高兴知道 对于 SwiftUI 视图观察到可观察对象的特定上下文,这是一个很好的答案。但是如果你想要Published 的所有特性——一个可以在任意上下文中读取和订阅的属性——你需要在计算属性之外创建一个映射的发布者。 rberggreen,是的,这是专门针对@PublishedObservableObject 结合使用的情况的答案。对于没有ObservableObject 的情况,您需要创建映射。【参考方案3】:

创建订阅您要跟踪的属性的新发布者。

@Published var speed: Double = 88

lazy var canTimeTravel: AnyPublisher<Bool,Never> = 
    $speed
        .map( $0 >= 88 )
        .eraseToAnyPublisher()
()

然后您将能够像您的 @Published 属性一样观察它。

private var subscriptions = Set<AnyCancellable>()


override func viewDidLoad() 
    super.viewDidLoad()

    sourceOfTruthObject.$canTimeTravel.sink  [weak self] (canTimeTravel) in
        // Do something…
    )
    .store(in: &subscriptions)

虽然没有直接相关但很有用,您可以使用combineLatest 以这种方式跟踪多个属性。

@Published var threshold: Int = 60

@Published var heartData = [Int]()

/** This publisher "observes" both `threshold` and `heartData`
 and derives a value from them.
 It should be updated whenever one of those values changes. */
lazy var status: AnyPublisher<Status,Never> = 
    $threshold
       .combineLatest($heartData)
       .map( threshold, heartData in
           // Computing a "status" with the two values
           Status.status(heartData: heartData, threshold: threshold)
       )
       .receive(on: DispatchQueue.main)
       .eraseToAnyPublisher()
()

【讨论】:

我相信在你的第二个 sn-p 中应该是 sourceOfTruthObject.canTimeTravel 而不是 sourceOfTruthObject.$canTimeTravel。也许在撰写本文时情况有所不同。 试图理解为什么会这样lazy @SergioBost 我相信您在初始化时无法访问threshold。因此,lazy 会告诉编译器这不可能发生,并且仍然以您想要的方式声明它(即:不在init() 中)【参考方案4】:

扫描(::) 通过将当前元素与闭包返回的最后一个值一起提供给闭包来转换来自上游发布者的元素。

您可以使用 scan() 获取最新和当前值。 示例:

@Published var loading: Bool = false

init() 
// subscriber connection

 $loading
        .scan(false)  latest, current in
                if latest == false, current == true 
                    NotificationCenter.default.post(name: NSNotification.Name("userStateDidChange"), object: nil) 
        
                return current
        
         .sink(receiveValue:  _ in )
         .store(in: &subscriptions)


上面的代码等价于:(less Combine)

  @Published var loading: Bool = false 
            didSet 
                if oldValue == false, loading == true 
                    NotificationCenter.default.post(name: NSNotification.Name("userStateDidChange"), object: nil)
                
            
        

【讨论】:

【参考方案5】:

如何使用下游?

lazy var userIsLoggedInPublisher: AnyPublisher = $currentUser
                                          .map$0 != nil
                                          .eraseToAnyPublisher()

这样订阅会从上游获取元素,那么你可以使用sinkassign来做didSet的想法。

【讨论】:

以上是关于相当于在 Swift Combine 中使用 @Published 计算的属性?的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 Combine + Swift 复制 PromiseKit 风格的链式异步流

Xcode 11 中 Swift Combine.framework 的可选链接

使用 Swift 和 Combine 链接 + 压缩多个网络请求

Swift - JSONDecoder & Combine 框架问题

Swift Combine Pipeline 比较草稿模型

使用 Swift Combine 创建一个 Timer Publisher