UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SwiftUI app architecture.

Forums > Swift

Hello everyone, I have a question about the SwiftUI app architecture.

This week I went through MVVM. I created an app that uses Core Data based on the MVVM architecture (Sample code 1). But I failed to use this architecture with CloudKit as I could not get my code to display the data on one device at the same time when I was adding data to the same database on another device. It only updates the view when I manually trigger it. However, the normal architecture (sample code 2) worked perfectly and supported simultaneous updates. In both examples, I used "NSPersistentCloudKitContainer" to create the connection with CloudKit.

In the sample code 1, I used "NSFetchRequest". Even though I failed, I am sure that there is a way to use MVVM with CloudKit. But in the second example, I used ""@fetchrequest" and the system did all the work for me. What I see is that SwiftUI is not MVVM by default. To make it MVVM, you have to write a lot of code. In my opinion, we simply fight with the system when using MVVM in some cases, such as the CloudKit example. I also think that fighting with the system could be an advantage in some cases, but not in all cases.

However, the disadvantage of the normal architecture of SwiftUI (I am not sure what the name is) is that it creates massive views. When it comes to updating views, this tends to create issues. So, my question is, what kind of architecture do you use? How do you take full advantage of SwiftUI and the other frameworks without changing much while keeping the architecture clean?

Also, I came up with a solution for my app (Sample Code 3). But I am not sure about the disadvantages of this method (such as memory usage). What I do is I declare all the variables within the view but I move all the functions (that are not shared) to an extension of the same view. I can call the variables from those functions directly without using methods such as @publish. The rest are models, and I call them directly from the view. In this way, I can keep the view clean. I would call this Model-View-ViewExtension :).

Sample Code 1

class DogViewModel: ObservableObject {

    static let dogViewModel = DogViewModel()

    private let context = PersistenceController.shared.container.viewContext
    @Published var dogs: [Dog] = []

    init() {
        readRecords()
    }

    func readRecords() {
        let request = NSFetchRequest<Dog>(entityName: "Dog")
        do {
            self.dogs = try self.context.fetch(request)
        }
        catch let error {
            print("Error fetching results \(error)")
        }
    }
}

Sample Code 2


// Getting data
struct ViewItems: View {
    @Environment(\.managedObjectContext) private var managedObjectContext
    @FetchRequest(sortDescriptors: [
        SortDescriptor(\.number)
    ]) private var items:FetchedResults<Item>

    var body: some View {
        //......
    }
}

Sample Code 3

struct ContentView: View {

    @State var test: String = ""

    var body: some View {
        VStack {
            Text(test)
            Button(action: {
                testFunc()
            }, label: {
                Text("Click me!")

            })
        }
    }
}

extension ContentView {
    func testFunc() {
        test = "Hello"
    }
}

2      

hi Tomato,

please don't be terribly concerned if you're a little confused about whether it's MVVM or uses a @FetchRequest or moves many of the "business" functions that are associated with a view into an extension (to keep a View from becoming a MassiveView). there's been plenty of discussion out there on the Apple Developer's Forum as well as here on HWS as to which approach is best; even three years into SwiftUI, no one has a definitive answer that i can see.

i have used all of the above; i'm never been fully pleased with any one of them. but i have found plenty of places where, say for a simple View, a @FetchRequest is the obvious solution; yet others where it's been either implementing a view model or moving some code out to a view's extension and each looks better to me.

my suggestion: start with @FetchRequest, and if it gets out of control, start moving toward implementing a view model to do the heavy lifting.

i'll also suggest you take a look at this page by Mohammad Azam, who is currently all in on "don't use MVVM," despite having spent two years telling everyone (with articles and even a book) exactly the opposite. it's been a very interesting jorney for him, with lots to think about.

lastly, on some programming issues:

-- your Sample Code 2 updates automatically because @FetchRequest monitors changes in Core Data (e.g., updates delivered through the cloud).

-- your Sample Code 1 does not update automatically because your view model does not monitor changes in Core Data ... it simply reads the data at start up, but never sees any external changes after that (as i said in your recent HWS post). for your view model to monitor Core Data, you would likely need to use an NSFetchedResultsController which is, we think, what's actually part of the implementation of SwiftUI's property wrapped @FetchRequest. i posted something recently on this.

if you're interested, i also have some code out on GitHub, which is really my "fail-in-public playground" implementation of a simple shopping list app. there are three branches that use only @FetchRequests, or only view models with NSFetchResultsControllers, or a third really experimental approach that layers on top of the second. i have tried to write lots of comments throughout the code ...

hope some of this helps,

DMG

3      

Hi DMG

Thanks a lot for the reply. I think I am going to stay with @FetchRequest for the current project that I am working on. I can organise my code into extensions, which is simpler. 

I am also going to experiment with the points that you mentioned, such as NSFetchedResultsController and the other ones that are on the HWF link. I am sure that I have to listen to remote calls that are coming from the server and update the view. The sample code 1 does not do that. But it would be an advantage for future projects if I could fix that.

Also, thanks for the Git repo. I had a look at that and it is quite useful.

Tomato

2      

I forgot to mention another issue associated with my approach but a feature in MVVM. Basically, if you are developing a multi-platform app (IOS, MacOS, and WatchOS), you will have to write the same @FetchRequest multiple times to do the same task on different views.

2      

Hello Everyone,

I thought I would publish an update as a comment as I assume that this code may help someone else in the future. Apologies if I am spamming the forum. I managed to fix the code. Now it does support simultaneous updates. Thanks to DMG for pointing out to me what needed to be done. Please let me know if you think that the code can be more optimised. Also, I referred to the following posts:

I tested the code on IOS 15.6 and 16.0. I used an IPad and an IPhone for testing.

CoreData Entity Details

  • Entity name: Dog
  • Attributes:
    • age: Int64
    • hasAChip: Bool
    • name: String
    • registrationDate: Date
    • uuid: UUID

Test_App_2.swift

import SwiftUI

@main
struct Test_App_2: App {
    let persistenceController = PersistenceController.shared
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

Persistence.swift

import CoreData

struct PersistenceController {
   static let shared = PersistenceController()
   let container: NSPersistentCloudKitContainer

   init(inMemory: Bool = false) {
       container = NSPersistentCloudKitContainer(name: "Test-App-2")
       if inMemory {
           container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
       }
       container.loadPersistentStores(completionHandler: { (storeDescription, error) in
           if let error = error as NSError? {
               fatalError("Unresolved error \(error), \(error.userInfo)")
           }
       })
       container.viewContext.automaticallyMergesChangesFromParent = true
       container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   }

   func save(completion: @escaping (Error?) -> () = {_ in}) {
       let context = container.viewContext
       if context.hasChanges {
           do {
               try context.save()
               completion(nil)
           } catch {
               completion(error)
           }
       }
   }

   func delete(_ object: NSManagedObject, completion: @escaping (Error?) -> () = {_ in}){
       let context = container.viewContext
       context.delete(object)
       save(completion: completion)
   }
}

DogViewModel.swift

import CoreData

class DogViewModel: NSObject, NSFetchedResultsControllerDelegate, ObservableObject {

    static let dogViewModel = DogViewModel()
    private let context = PersistenceController.shared.container.viewContext
    private let dogsFetchController: NSFetchedResultsController<Dog>

    override init() {
        let fetchRequest: NSFetchRequest<Dog> = Dog.fetchRequest()
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
        dogsFetchController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
        super.init()
        dogsFetchController.delegate = self
        do {
            try dogsFetchController.performFetch()
        } catch {
            NSLog("Error: could not fetch objects")
        }
    }

    var dogs: [Dog] {
        return dogsFetchController.fetchedObjects ?? []
    }

    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        objectWillChange.send()
    }

    func writeRecord(name: String, age: String, hasAChip: Bool, registrationDate: Date) {
        let dogAgeInt: Int64 = Int64(age) ?? 1
        let newDog = Dog(context: context)
        newDog.uuid = UUID()
        newDog.name = name
        newDog.age = dogAgeInt
        newDog.hasAChip = hasAChip
        newDog.registrationDate = registrationDate
        PersistenceController.shared.save()
    }

    func updateRecord(record: Dog, name: String, age: Int64, hasAChip: Bool, registrationDate: Date) {
        record.name = name
        record.age = age
        record.hasAChip = hasAChip
        record.registrationDate = registrationDate
        PersistenceController.shared.save()
    }

    func deleteRecord(at offsets: IndexSet){
        for index in offsets {
            let dog = self.dogs[index]
            PersistenceController.shared.delete(dog)
        }
    }
}

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView { // Please note that NavigationView is replaced by NavigationStack starting from IOS 16.
            VStack {
                NavigationLink("View Dogs", destination: ViewDogs())
                    .padding(.top)
                NavigationLink("Add Dog", destination: AddDog())
                    .padding(.top)
                Spacer()
            }
            .navigationTitle("Dogs Information Register")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

ViewDogs.swift

import SwiftUI

struct ViewDogs: View {

    @StateObject private var vm = DogViewModel.dogViewModel

    var body: some View {
        NavigationView {
            List {
                ForEach(vm.dogs, id: \.self) { dog in
                    NavigationLink(destination: UpdateDog(vm: self.vm, dog: dog)){
                        VStack(alignment: .leading) {
                            HStack {
                                Text("Name:")
                                Spacer()
                                Text(dog.name ?? "")
                            }
                            HStack {
                                Text("Age")
                                Spacer()
                                Text("\(dog.age)")
                            }
                            HStack {
                                Text("Has A Chip")
                                Spacer()
                                if dog.hasAChip {
                                    Text("Yes")
                                }
                                else {
                                    Text("No")
                                }
                            }
                            HStack {
                                Text("Registration Date")
                                Spacer()
                                Text(dog.registrationDate ?? Date(), style: .date)
                            }

                        }
                        .padding(.vertical)
                    }
                }
                .onDelete(perform: vm.deleteRecord)
            }
            .navigationTitle("Dogs")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

AddDog.swift

import SwiftUI
import Combine

struct AddDog: View {

    @StateObject private var vm = DogViewModel.dogViewModel
    @State private var dogName: String = ""
    @State private var dogAge: String = ""
    @State private var hasAChip: Bool = false
    @State private var registrationDate: Date = Date()
    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationView{
            Form {
                Section {
                    TextField("Dog's Name", text: self.$dogName)
                    TextField("Dog's Age", text: self.$dogAge)
                        .keyboardType(.numberPad)
                        .onReceive(Just(self.dogAge)) { newValue in
                            let filtered = newValue.filter { "0123456789".contains($0) }
                            if filtered != newValue {
                                self.dogAge = filtered
                            }
                            if filtered.count > 3 {
                                self.dogAge.removeLast()
                            }
                            if filtered == "0" {
                                self.dogAge = "1"
                            }
                        }
                    Toggle("Has a Chip", isOn: self.$hasAChip)
                    DatePicker(
                            "Start Date",
                            selection: self.$registrationDate,
                            displayedComponents: [.date]
                        )
                }
                Section {
                    Button(action: {
                        vm.writeRecord(name: self.dogName, age: dogAge, hasAChip: self.hasAChip, registrationDate: self.registrationDate)
                        dismiss()
                    }, label: {
                        Text("Add the dog")
                            .frame(maxWidth: .infinity, alignment: .center)
                    })
                    .buttonStyle(.plain)
                }
            }
            .navigationTitle("Add Dog")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

UpdateDog.swift

import SwiftUI

struct UpdateDog: View {

    @ObservedObject var vm: DogViewModel
    @ObservedObject var dog: Dog
    @State private var dogName: String = ""
    @Environment(\.dismiss) var dismiss

    init (vm:DogViewModel, dog:Dog){
        self.vm = vm
        self.dog = dog
        self._dogName = State<String>(initialValue: dog.name ?? "")
    }

    var body: some View {
        Form {
            Section {
                TextField("Dog's Name", text: self.$dogName)
            }
            Section {
                Button(action: {
                    vm.updateRecord(record: dog, name: dogName, age: dog.age, hasAChip: dog.hasAChip, registrationDate: dog.registrationDate ?? Date())
                    dismiss()
                }, label: {
                    Text("Update the dog")
                        .frame(maxWidth: .infinity, alignment: .center)
                })
                .buttonStyle(.plain)
            }
        }
    }
}

2      

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.