如何在 Combine 中最好地创建 @Published 值的发布者聚合?

Posted

技术标签:

【中文标题】如何在 Combine 中最好地创建 @Published 值的发布者聚合?【英文标题】:How to best create a publisher aggregate of @Published values in Combine? 【发布时间】:2021-08-27 18:20:35 【问题描述】:

鉴于@OberservableObjects 的层次结构 - 我经常发现自己需要一个发布者来提供整个结构的某种更新聚合(下面的示例计算一个总和,但它可以是任何东西)

以下是我想出的解决方案 - 有点工作,但也不是...... :)

问题 #1:它看起来很复杂 - 我觉得我错过了一些东西......

问题 #2:它不起作用,因为顶部的 $foo 发布者确实在 foo 更改之前向 foo 发出更改,然后第二个 self.$foo 发布者中不存在这些更改(显示旧状态)。

有时我需要聚合与 swiftUI 视图更新同步 - 这样我就需要使用 @Published 值,而不需要在变量的 didSet 期间发出单独的发布者。

我没有找到好的解决方案...那么你们将如何解决这个问题?

class Foo:ObservableObject 
    @Published var bar:Int = 0



class Foobar:ObservableObject 
    
    @Published var foo:[Foo] = []
    
    var sumPublisher:AnyPublisher<Int,Never> 
        
        // Whenever the foo array or one of the foo.bar values change
        //
        $foo
            .map  fooArray in
                Publishers.MergeMany( fooArray.map  foo in foo.$bar  )
            
            .switchToLatest()
            
            // Calclulate a new sum by collecting and reducing all foo.bar values.
            //
            .map  [unowned self] _ in
                self.$foo // <--- in case of a foo change, this is still the unchanged foo, therefore not correct.
                    .map  fooArray -> AnyPublisher<Int,Never> in
                        Publishers.MergeMany( fooArray.map  foo in foo.$bar.first()  )
                            .collect()
                            .map  barArray -> Int in
                                barArray.reduce(0,  $0 + $1 )
                            
                            .eraseToAnyPublisher()
                    
                    .switchToLatest()
            
            .switchToLatest()
            .removeDuplicates()
            .eraseToAnyPublisher()
    
    

【问题讨论】:

【参考方案1】:

问题 #2:@Published 在“willSet”而不是“didSet”上触发信号。 你可以使用这个扩展:

extension Published.Publisher 
    var didSet: AnyPublisher<Value, Never> 
        self.receive(on: RunLoop.main).eraseToAnyPublisher()
    

self.$foo.didSet
   .map  _ in 
   //...//

问题 #1: 也许是这样:

class Foobar:ObservableObject 
    
    @Published var foo:[Foo] = []
    @Published var sum = 0
    var cancellable: AnyCancellable?
    
    init() 
        cancellable =
            sumPublisher
            .sink 
                self.sum = $0
            
    
    
    var sumPublisher: AnyPublisher<Int,Never> 
        let firstPublisher = $foo.didSet
            .flatMap array in
                array.publisher
                    .flatMap  $0.$bar.didSet 
                    .map  _ -> [Foo] in
                        return self.foo
                    
            
            .eraseToAnyPublisher()
        let secondPublisher = $foo.didSet
            .dropFirst(1)
        return Publishers.Merge(firstPublisher, secondPublisher)
            .map  barArray -> Int in
                return barArray
                    .map $0.bar
                    .reduce(0,  $0 + $1 )
            
            .removeDuplicates()
            .eraseToAnyPublisher()
    

并进行测试:

struct FooBarView: View 
    @StateObject var fooBar = Foobar()
    var body: some View 
        VStack 
            HStack 
                Button("Change list") 
                    fooBar.foo = (1 ... Int.random(in: 5 ... 9)).map  _ in Int.random(in: 1 ... 9) .map(Foo.init)
                
                Text(fooBar.sum.description)
                Button("Change element") 
                    let idx = Int.random(in: 0 ..< fooBar.foo.count)
                    fooBar.foo[idx].bar = Int.random(in: 1 ... 9)
                
            
            List(fooBar.foo, id: \.bar)  foo in
                Text(foo.bar.description)
            
            .onAppear 
                fooBar.foo = [1, 2, 3, 8].map(Foo.init)
            
        
    

编辑:

如果你真的更喜欢使用@PublishedwillSet 发布者),它会发送bar 的新值,因此你可以推导出foo(数组)的新值:

var sumPublisher: AnyPublisher<Int, Never> 
        let firstPublisher = $foo
            .flatMap  array in
                array.enumerated().publisher
                    .flatMap  index, value in
                        value.$bar
                            .map  (index, $0) 
                    
                    .map  index, value -> [Foo] in
                        var newArray = array
                        newArray[index] = Foo(bar: value)
                        return newArray
                    
            
            .eraseToAnyPublisher()
        let secondPublisher = $foo
            .dropFirst(1)

        return Publishers.Merge(firstPublisher, secondPublisher)
            .map  barArray -> Int in
                barArray
                    .map  $0.bar 
                    .reduce(0,  $0 + $1 )
            
            .removeDuplicates()
            .eraseToAnyPublisher()
    

【讨论】:

这种方法的问题是它与 UI 更新不同步?我将始终有 1 个循环延迟,其中状态处于转换状态 - 这并不理想。 您能否解释一下您希望使用这种方法会遇到什么样的问题? 如果我需要聚合来更新 UI... 例如。总和可以反映我在 UI 中绘制结果所需的空间。 UI 更新会由 ObservableObject 的 objectWillChange 发布者触发,但是发布者的数据只能在一个周期后才可用? @Michael:好的。然后将 didSet 扩展放在一边。我用新的尝试更新了我的答案。 @MichaelJ:这对你有帮助吗?【参考方案2】:

到目前为止,这里最简单的方法是使用struct 而不是class@Published

struct Foo 
    var bar: Int = 0

然后你可以简单地创建一个计算属性:

class Foobar: ObservableObject 
    
    @Published var foo: [Foo] = []
    
    var sum: Int 
       foo.map(\.bar).reduce(0, +)
    

    // ...

对于 SwiftUI 视图,您甚至不需要将其设为发布者 - 当 foo 更改时,因为它是 @Published,它会导致视图再次访问 sum,这将给它重新计算的值.

如果你坚持它是一个发布者,这仍然很容易做到,因为foo 本身会在它的任何值Foo 发生变化时发生变化(因为它们是值类型structs):

var sumPublisher: AnyPublisher<Int, Never> 
   self.$foo
       .map  $0.map(\.bar).reduce(0, +) 
       .eraseToAnyPublisher()


有时,无论出于何种原因,都无法将class 更改为struct(可能每个class 都有自己的可自我更新的生命周期)。然后您需要手动跟踪数组中Foo 对象的所有添加/删除(通过willSetdidSet),并订阅foo.bar 中的更改。

【讨论】:

是的 - 我们经常进行这些结构与类的讨论。在我的真实案例中,Foo 是一个基类,还有很多其他的东西在发生。 而且我的 UI 总是会在顶层更新……? 每当数组foo 发生变化时,顶层都会更新——例如,添加或删除元素。但是,大概,您想在现有元素的bar 属性更改时更新总和?如果是这样,您必须订阅该更改。 好吧,如果 Foo 是一个结构,那么正如您所指出的,改变 bar 会改变数组,整个 UI 会被重绘?

以上是关于如何在 Combine 中最好地创建 @Published 值的发布者聚合?的主要内容,如果未能解决你的问题,请参考以下文章

Combine如何驯服Future发布器的“怪癖”

如何在 Node.js 中最好地创建 RESTful API [关闭]

如何最好地重新创建 Oracle 数据库?

如何使用具有超过8个值的System.HashCode.Combine?

将 .combine 与 cforest 一起使用时遇到问题

使用 SwiftUI 和 Combine 根据授权状态有条件地显示视图?