FE_Tech
FE_Tech

Reputation: 1750

NSPersistentStoreRemoteChangeNotification not getting fired

I am trying to perform history tracking in my CoreData+CloudKit project which uses NSPersistentCloudKitContainer. I have been following along with Apple's sample project

I want to perform certain task when the remote store has been updated. For this apple recommends enabling remote notification in the Signing & capabilities's Background Mode section of the app.

I have enabled History Tracking for my project as shown in Apple's sample project.

    // turn on persistent history tracking
    let description = container.persistentStoreDescriptions.first
    description?.setOption(true as NSNumber,
                           forKey: NSPersistentHistoryTrackingKey)

    // ...

Also I have registered my store to listen for store changes.

    // turn on remote change notifications
    let remoteChangeKey = "NSPersistentStoreRemoteChangeNotificationOptionKey"
    description?.setOption(true as NSNumber,
                               forKey: remoteChangeKey)

    // ...

Observer is also added to listen for NSPersistentStoreRemoteChangeNotification.

However there is no NSPersistentStoreRemoteChangeNotification being fired. To make sure there is no mistake in my implementation, I am have simply put breakpoints in @objc func storeRemoteChange(_ notification: Notification) the Apple's provided sample code but still I can not see any notification being fired and no breakpoints are activated.

I have understood the deduplication of the Tags done in the sample project and also tried testing it but without any success. Is it a bug in the Apple's implementation or am I missing any setup which is required?

Upvotes: 18

Views: 6549

Answers (5)

malhal
malhal

Reputation: 30746

My guess is you are observing the container instead of the store coordinator, add your observer like this:

NotificationCenter.default.addObserver(
   self, selector: #selector(type(of: self).storeRemoteChange(_:)),
   name: .NSPersistentStoreRemoteChange,
   object: container.persistentStoreCoordinator
)

Note the last param container.persistentStoreCoordinator

And a warning, this notification comes in on all different threads so you be careful with concurrency. Just put a 5 second sleep in the method and you'll see on app launch 3 different threads call it. This is likely why in the example there is a historyQueue with maxOperationCount 1 to handle it.

Some notifications have NSPersistentHistoryTokenKey in the userInfo not sure why.

Upvotes: 20

Didier B.
Didier B.

Reputation: 61

SwiftUI

Here's a way to be notified of CloudKit remote changes in a SwiftUI view, and, say, update the contents of a List that would depend on a @FetchRequest--not shown in the code for simplicity:

struct MyView: View {
    @State var refresh = UUID()
    var didRemoteChange = NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange).receive(on: RunLoop.main)
    var body: some View {
        List {
            // ...
        }
        .id(refresh)
        .onReceive(self.didRemoteChange) { _ in
            self.refresh = UUID()
        }
    }
}

Note: .receive(on: RunLoop.main) is necessary in order to avoid modifying the UI from a background thread, as the remote event could (and will) otherwise fire from a background thread. Alternatively, .receive(on: DispatchQueue.main) can also be used.

For that to work, the NSPersistentCloudKitContainer needs to be set up to fire events when remote changes occur:

struct PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentCloudKitContainer
    init(inMemory: Bool = false) {
        container = NSPersistentCloudKitContainer(name: "YourApp")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        //
        // Generate notifications upon remote changes
        //
        container.persistentStoreDescriptions.forEach {
            $0.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.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}

Upvotes: 3

Positron
Positron

Reputation: 2431

I was able to reliably echo Core Data changes via iCloud between two devices in my project. But I reached a point where I needed access to the change history. Apple has nicely described the steps to set it up in Consuming Relevant Store Changes

I followed along and happily copy and pasted the relevant code into my app. But the NSPersistentStoreRemoteChange notification was not coming through. As in comedy, timing is everything. Per the documentation for persistentStoreDescriptions I

If you will be configuring custom persistent store descriptions, you must set this property before calling loadPersistentStores(completionHandler:)

I was configuring persistentStoreDescriptions inside of loadPersistentStores(completionHandler:) So the painfully obvious way to do it is setup the following code in the AppDelegate.

// MARK: - Core Data stack

lazy var persistentContainer: NSPersistentCloudKitContainer = {
    /*
     The persistent container for the application. This implementation
     creates and returns a container, having loaded the store for the
     application to it. This property is optional since there are legitimate
     error conditions that could cause the creation of the store to fail.
    */
    let container = NSPersistentCloudKitContainer(name: "yourProjectNameGoesHere")
    
    // turn on persistent history tracking
    // https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes
    let description = container.persistentStoreDescriptions.first
    description?.setOption(true as NSNumber,
                           forKey: NSPersistentHistoryTrackingKey)
    
    // turn on remote change notifications
    let remoteChangeKey = "NSPersistentStoreRemoteChangeNotificationOptionKey"
    description?.setOption(true as NSNumber,
                               forKey: remoteChangeKey)
    
    // this will make background updates from iCloud available to the context.
    container.viewContext.automaticallyMergesChangesFromParent = true
    
    // call this LAST, after the persistentStoreDescriptions configuration.  
    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
        if let error = error as NSError? {
            // Replace this implementation with code to handle the error appropriately.
            // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
             
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    })
    
    return container
}()

Catch the notification from your view controller or model.

init() {
    NotificationCenter.default.addObserver(self,
        selector: #selector(fetchChanges),
            name: .NSPersistentStoreRemoteChange,
          object: pc.persistentStoreCoordinator)
}

@objc func fetchChanges(note: Notification) {
    print("Just received a NSPersistentStoreRemoteChange notification")
}

Upvotes: 3

brotskydotcom
brotskydotcom

Reputation: 675

Debugging the sample app mentioned by the OP, I observed the following:

  • As of XCode Version 11.3 (11C29), there are SDK constants both for the option key (NSPersistentStoreRemoteChangeNotificationPostOptionKey) and for the notification name (.NSPersistentStoreRemoteChange), and these are reflected in the latest download of the sample code.
  • The sample app registers for the remote change notifications on the wrong object, so it never receives any. Changing the sender as per the accepted answer fixes this.
  • The app UI always updates to reflect changes received from the cloud, but those updates are prompted not by remote change notifications but by the app's NSFetchedResultsController delegate using the controllerDidChangeContent callback to refresh the UI.
  • The standard NSPersistentCloudKitContainer used by the sample app is doing automatic imports into the local persistent store of all the cloud-sent updates and, because the persistentStore is set up for history tracking and the viewContext is set up to auto-update to the latest generation of data, each import triggers a UI update.

Based on these observations, I wrote a small app from scratch based on the XCode template you get by specifying use of CoreData, CloudKit, and SwiftUI. I set up its persistent container and view context the same way they are set up in the sample app, and used SwiftUI's @FetchRequest wrapper to obtain the data in the master view display. Sure enough, I saw the exact same remote import behavior without using any remote change notifications, and the UI updated after each import.

I then confirmed that, as per the accepted answer, if I registered for remote change notifications correctly, they would be received. They seem to be sent after each receive and import operation in the NSPersistentCloudKit completes. Observing them is not needed to get notifications of the local data changes initiated by those imports.

Upvotes: 6

FE_Tech
FE_Tech

Reputation: 1750

I don't know whether it's a bug. Simply downloading and running the Apple's Sample Project but the NSPersistentStoreRemoteChangeNotification is never fired.

I added one more observer for the same NSPersistentStoreRemoteChangeNotification in my AppDelegate and it is firing.

I added notification observer in AppDelegate and then simply call the StoreRemoteChange(_:) of the CoreDataStack. Also, Tag deduplication logic works properly.

Here is the code which I added in AppDelegate

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // The view controller hierarchy is defined in the main storyboard.
        guard let splitViewController = window?.rootViewController as? UISplitViewController,
            let navController = splitViewController.viewControllers[splitViewController.viewControllers.count - 1] as? UINavigationController,
            let topViewController = navController.topViewController else {
                return false
        }
        // Configure the splitViewController.
        topViewController.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem
        splitViewController.delegate = self
        splitViewController.preferredDisplayMode = .allVisible

        // Observe Core Data remote change notifications.
        NotificationCenter.default.addObserver(
            self, selector: #selector(type(of: self).storeRemoteChange(_:)),
            name: .NSPersistentStoreRemoteChange, object: nil)

        return true
    }

@objc
func storeRemoteChange(_ notification: Notification) {
        coreDataStack.storeRemoteChange(notification)
}

Upvotes: 2

Related Questions