NSPersistentStoreRemoteChangeNotification not getting fired
Asked Answered
H

5

17

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?

Heathen answered 3/12, 2019 at 13:45 Comment(2)
Hi, did you ended up finding a solution for this? I'm in this exact situation, right now I just rely on automaticallyMergesChangesFromParent to let it do its own thing for cloud+extension changes. And then I force a new fetchrequest on willEnterForegroundNotification. It's the only way I can think of doing this. Is this bad?Verdellverderer
see #60365679Salty
E
20

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.

Eucharis answered 9/2, 2020 at 23:30 Comment(6)
Yeah, this works too! I checked it. I wonder why Apple has provided the last parameter incorrect?Heathen
I would guess when the sample was written the notification was sent for the container and they changed it. I've seen the same thing happen before, samples that use UIViewControllerShowDetailTargetDidChangeNotification are broken because they changed the object from UISplitViewController to UISplitViewControllerPanelImpl in iOS 13.Eucharis
Thanks, @malhal! I also checked this in my "vanilla" app (mentioned in my answer below) and it works like a charm. May I ask how you discovered the right object to observe?Platonic
I used object:nil which disables the filter then checked the type of the object in the NSNotification received.Eucharis
I have seen this working just container as in the sample code. I suspect that this changed with iOS 14, so if you still support iOS 13 you may want to test/branch for bothMarquesan
@Marquesan perhaps the container was accidentally nil in the code you saw? Using nil disables the object filter so it would receive the notification.Eucharis
P
6

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.

Platonic answered 3/1, 2020 at 6:28 Comment(2)
Hi, did you ended up finding a solution for this? I'm in this exact situation, right now I just rely on automaticallyMergesChangesFromParent to let it do its own thing for cloud+extension changes. And then I force a new fetchrequest on willEnterForegroundNotification. It's the only way I can think of doing this. Is this bad?Verdellverderer
Personally, as long as the local data gets updated, and local updates make it to the cloud, I'm just as happy not to have to handle the notifications myself. So no, in my opinion, your approach is not bad at all. I'm not sure you need to force a fetch request on entering the foreground, by the way: I am seeing all the updates force a refresh of the fetch request via the normal @ObservedObject updates.Platonic
B
3

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")
}
Biology answered 29/8, 2020 at 20:49 Comment(2)
Thank you! Your answer made it obvious to me that I was setting the remote change notification option inside the .loadPersistentStores() completion block, which is already too late. It works now!Corporal
Positron can you please also post objective c code? Thanks.Liverwurst
I
3

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
    }
}
Inconformity answered 3/8, 2021 at 18:5 Comment(0)
H
2

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)
}
Heathen answered 12/2, 2020 at 4:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.