SwiftUI with complex MVVM (Repository + Nested ObservedObject)

Viewed 867

Explanation

I am still in the process of learning to utilize SwiftUI patterns in the most optimal way. But most SwiftUI MVVM implementation examples I find are very simplistic. They usually have one database class and then 1-2 viewmodels that take data from there and then you have views.

In my app, I have a SQLite DB, Firebase and different areas of content. So I have a few separate model-vm-view paths. In the Android equivalent of my app, I used a pattern like this:

View - ViewModel - Repository - Database

This way I can separate DB logic like all SQL queries in the repository classes and have the VM handle only view related logic. So the whole thing looks something like this:

an overview of the DB structure in my app

In Android this works fine, because I just pass through the LiveData object to the view. But when trying this pattern in SwiftUI, I kind of hit a wall:

  1. It doesn't work / I don't know how to correctly connect the Published objects of both
  2. The idea of "chaining" or nesting ObservableObjects seems to be frowned upon:

This article about Nested Observable Objects in SwiftUI:

I’ve seen this pattern described as “nested observable objects”, and it’s a subtle quirk of SwiftUI and how the Combine ObservableObject protocol works that can be surprising. You can work around this, and get your view updating with some tweaks to the top level object, but I’m not sure that I’d suggest this as a good practice. When you hit this pattern, it’s a good time to step back and look at the bigger picture.

So it seems like one is being pushed towards using the simpler pattern of:

View - ViewModel - Database Repository

Without the repository in-between. But this seems annoying to me, it would make my viewmodel classes bloated and would mix UI/business code with SQL queries.


My Code

So this is a simplified version of my code to demonstrate the problem:

Repository:

class SA_Repository: ObservableObject {
    
    @Published var selfAffirmations: [SelfAffirmation]?
    private var dbQueue: DatabaseQueue?
    
    init() {
        do {
            dbQueue = Database.sharedInstance.dbQueue
            fetchSelfAffirmations()
            
            // Etc. other SQL code
        } catch {
            print(error.localizedDescription)
        }
    }
    
    private func fetchSelfAffirmations() {

        let saObservation = ValueObservation.tracking { db in
            try SelfAffirmation.fetchAll(db)
        }
        if let unwrappedDbQueue = dbQueue {
            let _ = saObservation.start(
                in: unwrappedDbQueue,
                scheduling: .immediate,
                onError: {error in print(error.localizedDescription)},
                onChange: {selfAffirmations in
                    print("change in SA table noticed")
                    self.selfAffirmations = selfAffirmations
                })
        }
    }
    
    public func updateSA() {...}
    public func insertSA() {...}
    // Etc.
}

ViewModel:

class SA_ViewModel: ObservableObject {
    @ObservedObject private var saRepository  = SA_Repository()
    @Published var selfAffirmations: [SelfAffirmation] = []
    
    init() {
        selfAffirmations = saRepository.selfAffirmations ?? []
    }
    
    public func updateSA() {...}
    public func insertSA() {...}
    
    // + all the Firebase stuff later on
}

View:

struct SA_View: View {
    @ObservedObject var saViewModel = SA_ViewModel()
    
    var body: some View {
        NavigationView {
            List(saViewModel.selfAffirmations, id: \.id) { selfAffirmation in
                SA_ListitemView(content: selfAffirmation.content,
                                editedValueCallback: { newString in
                                    saViewModel.updateSA(id: selfAffirmation.id, newContent: newString)
                                })
                    
            }
        }
    }
}

Attempts

Obviously the way I did it here is wrong, because it clones the data from repo to vm once with selfAffirmations = saRepository.selfAffirmations ?? [] but then it never updates when I edit the entries from the view, only on app restart.

I tried $selfAffirmations = saRepository.$selfAffirmations to just transfer the binding. But the repo one is an optional, so I'd need to make the vm selfAffirmations an optional too, which would then mean handling unnecessary logic in the view code. And not sure if it would even work at all.

I tried to do it manually with Combine but this way seemed to not be recommended and fragile. Plus it also didn't work:

selfAffirmations = saRepository.selfAffirmations ?? []
        cancellable = saRepository.$selfAffirmations.sink(
            receiveValue: { [weak self] repoSelfAffirmations in
                self?.selfAffirmations = repoSelfAffirmations ?? []
            }
        )

Question

Overall I would just need some way to pass through the data from the repo to the view, but have the vm be in the middle as a separator. I read about the PassthroughSubject in Combine, which sounds like it would be fitting, but I'm not sure if I am just misunderstanding some concepts here.

Now I am not sure if my architecture concepts are wrong/unfitting, or if I just don't understand enough about Combine publishers yet to make this work.

Any advice would be appreciated.

1 Answers

After getting some input from the comments, I figured out a clean way.

The problem for me was understanding how to make a property of a class publish its values. Because the comments suggested that property wrappers like @ObservedObject was a frontend/SwiftUI only thing, making me assume that everything related was limited to that too, like @Published.

So I was looking for something like selfAffirmations.makePublisher {...}, something that would make my property a subscribable value emitter. I found that arrays naturally come with a .publisher property, but this one seems to only emit the values once and never again.

Eventually I figured out that @Published can be used without @ObservableObject and still work properly! It turns any property into a published property.

So now my setup looks like this:

Repository (using GRDB.swift btw):

class SA_Repository {
    
    private var dbQueue: DatabaseQueue?
    @Published var selfAffirmations: [SelfAffirmation]?
    // Set of cancellables so they live as long as needed and get deinitialiazed with the class end
    var subscriptions = Array<DatabaseCancellable>()
    
    init() {
        dbQueue = Database.sharedInstance.dbQueue
        fetchSelfAffirmations()
    }
    
    private func fetchSelfAffirmations() {
        // DB code....
    }
}

And viewmodel:

class SA_ViewModel: ObservableObject {
    private var saRepository  = SA_Repository()
    @Published var selfAffirmations: [SelfAffirmation] = []
    // Set of cancellables to keep them running
    var subscriptions = Set<AnyCancellable>()
    
    init() {
        saRepository.$selfAffirmations
            .sink{ [weak self] repoSelfAffirmations in
                self?.selfAffirmations = repoSelfAffirmations ?? []
            }
            .store(in: &subscriptions)
    }
}
Related