How to fetch CloudKit data manually to update the UI using NSPersistentCloudKitContainer and SwiftUI?
Asked Answered
G

2

8

Let's say we have a working NSPersistentCloudKitContainer and just one CoreData entity named Item. Let's assume we want to sync between iOS, iPad and Mac app.

I watched this WWDC session about syncing CoreData and CloudKit and implemented basic sync in my SwiftUI app. It works and data comes to all 3 devices and syncs automatically.

But on a Mac the data does not appear without reloading the view (as well as in the simulators (because they don't receive push-notifications). It comes, but there is no automatic refresh. I've also tested simulator to a real device and sometimes it syncs automatically, sometimes I need to reopen the app to see the changes.

I'm curious is there a force fetch method that can be used along with NSPersistentCloudKitContainer. Or maybe anyone knows a workaround to Refetch data manually when using NSPersistentCloudKitContainer with SwiftUI?

I also want to show an activity indicator when the new data starts to appear, but not sure where to find this starting getting fetches point in code?

I've used this UIKit sample code provided by Apple and adapted it to work in SwiftUI. I also read all the documentation here.

Here is the persistentContainer code I use:

lazy var persistentContainer: NSPersistentCloudKitContainer = {

        let container = NSPersistentCloudKitContainer(name: "AppName")

        container.persistentStoreDescriptions.first?.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {

                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })

        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        container.viewContext.automaticallyMergesChangesFromParent = true

        return container
    }()

If you see, I didn't include container.viewContext.transactionAuthor = appTransactionAuthorName and setQueryGenerationFrom(.current) and NotificationCenter.default.addObserver in the above code. But I also tried with them and get the same results. Not sure If I need to use them at all, because syncing works without these calls as well.

In my Item class I've added this function to get the items:

   static func getAllItems() -> NSFetchRequest<Item> {

        let request: NSFetchRequest<Item> = Item.fetchRequest() as! NSFetchRequest<Item>

        let sortDescriptor = NSSortDescriptor(key: "name", ascending: false)

        request.sortDescriptors = [sortDescriptor]

        return request

    }

In my ContentView I have this MasterView view:

    struct MasterView: View {

    @FetchRequest(fetchRequest: Item.getAllItems()) var items: FetchedResults<Item>

    @Environment(\.managedObjectContext) var viewContext

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                NavigationLink(
                    destination: DetailView(item: item)
                ) {
                    Text("\(item.name ?? "")")
                }
            }.onDelete { indices in
                self.items.delete(at: indices, from: self.viewContext)
            }
        }
    }
}

P.S. I also noticed that sync works slowly (maybe this is only my case, not sure). I have to wait from 5 to 10 seconds between syncs with this code. Maybe anyone knows if this waiting time normal or not?

Groceryman answered 23/2, 2020 at 18:0 Comment(3)
Well I think it worth handling .NSPersistentStoreRemoteChange notification and force refresh SwiftUI List, because context is usually merged silently in background queue, and UI not handle this automatically. The following topics might be helpful [How to update @FetchRequest, when a related Entity changes in SwiftUI? ](https://mcmap.net/q/274652/-how-to-update-fetchrequest-when-a-related-entity-changes-in-swiftui) and SwiftUI: List does not update automatically after deleting all Core Data Entity entries. Pay attention to redirect UI refresh from notification handler to main queue.Neolamarckism
@Groceryman did you find a solution? I'm facing the same problem.Transatlantic
Hi @MikeBernardo , not yet, I think it is supposed to work this way. It works better when testing on a real device.Groceryman
S
0

Because data synced from remote is written directly into persistent, viewContext does't know them. But we can be notified by NSPersistentStoreRemoteChangeNotification. fetchHistory, filter by author, we get the synced data, then merge into viewContext, so UI refreshs.

  1. settings.
        publicDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        publicDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        ...
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        context.automaticallyMergesChangesFromParent = true
        context.transactionAuthor = containerName
        try? context.setQueryGenerationFrom(.current)
  1. notificaition.
        NotificationCenter.default
            .publisher(for: .NSPersistentStoreRemoteChange)
//            .receive(on: DispatchQueue.main)
            .sink { [weak self] notification in
                guard let self = self else { return }
                self.processRemoteStoreChange(notification)
            }
            .store(in: &subscriptions)
  1. fetch and merge history into viewContext.
    private func processRemoteStoreChange(_ notification: Notification) {
        DispatchQueue(label: "history").async { [weak self] in
            guard let self = self else { return }
            let backgroundContext = self.container.newBackgroundContext()
            backgroundContext.performAndWait { [weak self] in
                guard let self = self else { return }
                
                let request = NSPersistentHistoryChangeRequest.fetchHistory(after: self.historyTimestamp)
                
                if let historyFetchRequest = NSPersistentHistoryTransaction.fetchRequest {
                    historyFetchRequest.predicate = NSPredicate(format: "author != %@", self.containerName)
                    request.fetchRequest = historyFetchRequest
                }
                
                guard let result = try? backgroundContext.execute(request) as? NSPersistentHistoryResult,
                      let transactions = result.result as? [NSPersistentHistoryTransaction],
                      transactions.isEmpty == false else {
                    return
                }
                
                foolPrint("transactions = \(transactions)")
                self.mergeChanges(from: transactions)
                
                if let timestamp = transactions.last?.timestamp {
                    DispatchQueue.main.async { [weak self] in
                        guard let self = self else { return }
                        self.historyTimestamp = timestamp
                    }
                }
            }
        }
    }
    
    private func mergeChanges(from transactions: [NSPersistentHistoryTransaction]) {
        context.perform {
            transactions.forEach { [weak self] transaction in
                guard let self = self, let userInfo = transaction.objectIDNotification().userInfo else { return }
                NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [self.context])
//                foolPrint("mergeChanges, \(transaction.timestamp): \(userInfo)")
            }
        }
    }
Syncytium answered 10/1, 2021 at 10:32 Comment(0)
T
-1

I ran into a similar problem when building an app, so I built and published the ⛅️ CombineCloudKit package. Using Combine Publishers, it's easy to build reactive UI that responds to CloudKit operations.

https://github.com/chris-araman/CombineCloudKit

I hope that people find it useful!

Tindle answered 20/6, 2021 at 1:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.