1

For a Store/Factory/ViewModel pattern using Combine and SwiftUI, I'd like a Store protocol-conforming class to expose a publisher for when specified model object(s) change internal properties. Any subscribed ViewModels can then trigger objectWillChange to display the changes.

(This is necessary because changes are ignored inside a model object that is passed by reference, so @Published/ObservableObject won't auto-fire for Factory-passed Store-owned models. It works to call objectWillChange in the Store and the VM, but that leaves out any passively listening VMs.)

That's a delegate pattern, right, extending @Published/ObservableObject to passed-by-reference objects? Combing through combine blogs, books, and docs hasn't triggered an idea to what's probably a pretty standard thing.

Crudely Working Attempt

I thought PassthroughSubject<Any,Never> would be useful if I exposed a VM's objectWillChange externally, but PassthroughSubject.send() will fire for every object within the model object. Wasteful maybe (although the ViewModel only fires its objectWillChange once).

Attaching a limiter (e.g., throttle, removeDuplicates) on Ext+VM republishChanges(of myStore: Store) didn't seem to limit the .sink calls, nor do I see an obvious way to reset the demand between the PassthroughSubject and the VM's sink... or understand how to attach a Subscriber to a PassthroughSubject that complies with the Protcols. Any suggestions?

Store-Side

struct Library {
   var books: // some dictionary
}

class LocalLibraryStore: LibraryStore {
    private(set) var library: Library {
         didSet { publish() }
    }

    var changed = PassthroughSubject<Any,Never>()
    func removeBook() {}
}

protocol LibraryStore: Store { 
    var changed: PassthroughSubject<Any,Never> { get }
    var library: Library { get }
}


protocol Store {
    var changed: PassthroughSubject<Any,Never> { get }
}

extension Store {
    func publish() {
        changed.send(1)
        print("This will fire once.")
    }
}

VM-Side

class BadgeVM: VM {
    init(store: LibraryStore) {
        self.specificStore = store
        republishChanges(of: jokesStore)
    }

    var objectWillChange = ObservableObjectPublisher() // Exposed {set} for external call
    internal var subscriptions = Set<AnyCancellable>()

    @Published private var specificStore: LibraryStore
    var totalBooks: Int { specificStore.library.books.keys.count }
}

protocol VM: ObservableObject {
    var subscriptions: Set<AnyCancellable> { get set }
    var objectWillChange: ObservableObjectPublisher { get set }
}

extension VM {
    internal func republishChanges(of myStore: Store) {
        myStore.changed
            // .throttle() doesn't silence as hoped
            .sink { [unowned self] _ in
                print("Executed for each object inside the Store's published object.")
                self.objectWillChange.send()
            }
            .store(in: &subscriptions)
    }
}

class OtherVM: VM {
    init(store: LibraryStore) {
        self.specificStore = store
        republishChanges(of: store)
    }

    var objectWillChange = ObservableObjectPublisher() // Exposed {set} for external call
    internal var subscriptions = Set<AnyCancellable>()

    @Published private var specificStore: LibraryStore
    var isBookVeryExpensive: Bool { ... }
    func bookMysteriouslyDisappears() { 
         specificStore.removeBook() 
    }
}

Beginner
  • 455
  • 7
  • 14
  • Would you clarify a bit more what are you trying to achieve? And why standard ObservableObject/ObservedObject pattern does not fit your needs? – Asperi Sep 19 '20 at 04:07
  • That pattern has the ViewModel ObservableObject housing the in-memory model; in my case I'm passing the model by reference to the ViewModel. I just tested my views now and see that while the publishing pipeline emits a ton of values, the ViewModel's objectWillChange publisher only fires ONCE. So it's not expensive like I thought. @NewDev's answer below is probably the cleaner route... will test. – Beginner Sep 19 '20 at 04:54

2 Answers2

1

It seems that what you want is a type that notifies when its internal properties change. That sounds an awful lot like what ObservableObject does.

So, make your Store protocol inherit from ObservableObject:

protocol Store: ObservableObject {}

Then a type conforming to Store could decide what properties it wants to notify on, for example, with @Published:

class StringStore: Store {
   @Published var text: String = ""
}

Second, you want your view models to automatically fire off their objectWillChange publishers when their store notifies them.

The automatic part can be done with a base class - not with a protocol - because it needs to store the subscription. You can keep the protocol requirement, if you need to:

protocol VM {
   associatedtype S: Store
   var store: S { get }
}

class BaseVM<S: Store>: ObservableObject, VM {
   var c : AnyCancellable? = nil
    
   let store: S
    
   init(store: S) {
      self.store = store

      c = self.store.objectWillChange.sink { [weak self] _ in
         self?.objectWillChange.send()
      }
   }
}

class MainVM: BaseVM<StringStore> {
   // ...
}

Here's an example of how this could be used:


let stringStore = StringStore();
let mainVm = MainVM(store: stringStore)

// this is conceptually what @ObservedObject does
let c = mainVm.objectWillChange.sink { 
   print("change!") // this will fire after next line
} 

stringStore.text = "new text"
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • Yep, you're right. It's a feature ObservableObject itself really should have, as a Store pattern isn't uncommon I think? I'll test this in the morning. Thanks for the push in probably the right direction. (FYI - I retested my implementation and while the pipeline sends lots of values, the VIewModel's objectWillChange publisher only fires once per stream and the View updates accordingly once. So it's not expensive like I first thought. Yours is clearer. Will test. Thanks. – Beginner Sep 19 '20 at 04:56
  • Unfortunately, while logical, your example of marking the Store's object of interest as @Published and manually subscribing to the enclosing ObservableObject's publisher did not work. The object of interest must be monitored by didSet. The subclassing approach was a smart push, thanks. I've posted code that works and is easier to call than using protocols. – Beginner Oct 16 '20 at 22:27
  • I had a few minor omissions (fixed now), but I guess I don't really see how your answer is different. You *can* use `didSet` if you want to - `@Published` just makes it automatic. You also don't need to create a separate base class just to have `func publish() {}` method - you can always just do `didSet { objectWillChange.send() }` from any inheriting repo types. @Beginner – New Dev Oct 17 '20 at 00:20
1

Thanks @NewDev for pointing out subclassing as a smarter route.

If you want to nest ObservableObjects or have an ObservableObject re-publish changes in objects within an object passed to it, this approach works with less code than in my question.

In searching to simplify further with a property wrapper (to get at parent objectWillChange and simplify this further), I noticed a similar approach in this thread: https://stackoverflow.com/a/58406402/11420986. This only differs in using a variadic parameter.

Define VM and Store/Repo Classes

import Foundation
import Combine

class Repo: ObservableObject {
    func publish() {
        objectWillChange.send()
    }
}

class VM: ObservableObject {
    private var repoSubscriptions = Set<AnyCancellable>()

    init(subscribe repos: Repo...) {
        repos.forEach { repo in
            repo.objectWillChange
                .receive(on: DispatchQueue.main) // Optional
                .sink(receiveValue: { [weak self] _ in
                    self?.objectWillChange.send()
                })
                .store(in: &repoSubscriptions)
        }
    }
}

Example Implementation

  • Repo: add didSet { publish() } to model objects
  • VM: The super.init() accepts any number of repos to republish
import Foundation

class UserDirectoriesRepo: Repo, DirectoriesRepository {
    init(persistence: Persistence) {
        self.userDirs = persistence.loadDirectories()
        self.persistence = persistence
        super.init()
        restoreBookmarksAccess()
    }

    private var userDirs: UserDirectories {
        didSet { publish() }
    }

    var someExposedSliceOfTheModel: [RootDirectory] {
        userDirs.rootDirectories.filter { $0.restoredURL != nil }
    }

    ...
}
import Foundation

class FileStructureVM: VM {
    init(directoriesRepo: DirectoriesRepository) {
        self.repo = directoriesRepo
        super.init(subscribe: directoriesRepo)
    }
    
    @Published // No longer necessary
    private var repo: DirectoriesRepository
    
    var rootDirectories: [RootDirectory] {
        repo.rootDirectories.sorted ...
    }

    ...
}

Beginner
  • 455
  • 7
  • 14