13

I want to achieve the following: Whenever someone triggers a CoreData save (ie. NSManagedObjectContextDidSave notification gets sent), I'd like to perform some background calculation based the changed NSManagedObject. Concrete example: Assume in a notes app, I want to asynchronously calculate the total number of words in all notes.

The problem currently lies with the fact that NSManagedObject context is explicitly bound to thread and you are discouraged from using NSManagedObjects outside this thread.

I have setup two NSManagedObjectContexts in my SceneDelegate:

let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
let backgroundContext = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.newBackgroundContext()

I also have subscribed to the notification via NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave) and am receiving a save notification twice after I trigger only one managedObjectContext.save(). However, both notifications are sent from the same thread (which is the UIThread) and all NSManagedObjects in the user dictionary have a .managedObjectContext which is the viewContext and not the backgroundContext.

My idea was to filter the notifications based on whether or not the associated NSManagedObjectContext was the background one as I assumed that the notification is also sent on the (private) DispatchQueue but it seems all notifications are sent on the UIThread and the background context is never used.

Any idea on how to solve this? Is this a bug? How can I retrieve notifications based on the backgroundContext with downstream tasks being run on the associated DispatchQueue?

Sam Spencer
  • 8,492
  • 12
  • 76
  • 133
Nicolas
  • 755
  • 9
  • 22
  • 1
    publisher(for:) has a second parameter called object that defaults to nil. Try setting the object parameter to the context you want to observe. [Docs](https://developer.apple.com/documentation/foundation/notificationcenter/3329353-publisher) – Rob C Mar 03 '20 at 16:54
  • not a good idea to keep a background context around or it'll get full of unnecessary objects – malhal Jul 27 '20 at 23:58

3 Answers3

17

You can create a Publisher which informs you when something relevant for you in Core Data has changed.

I wrote an article on this. Combine, Publishers and Core Data.

import Combine
import CoreData
import Foundation

class CDPublisher<Entity>: NSObject, NSFetchedResultsControllerDelegate, Publisher where Entity: NSManagedObject {
    typealias Output = [Entity]
    typealias Failure = Error

    private let request: NSFetchRequest<Entity>
    private let context: NSManagedObjectContext
    private let subject: CurrentValueSubject<[Entity], Failure>
    private var resultController: NSFetchedResultsController<NSManagedObject>?
    private var subscriptions = 0

      init(request: NSFetchRequest<Entity>, context: NSManagedObjectContext) {
        if request.sortDescriptors == nil { request.sortDescriptors = [] }
        self.request = request
        self.context = context
        subject = CurrentValueSubject([])
        super.init()
    }

      func receive<S>(subscriber: S)
        where S: Subscriber, CDPublisher.Failure == S.Failure, CDPublisher.Output == S.Input {
        var start = false

        synchronized(self) {
            subscriptions += 1
            start = subscriptions == 1
        }

        if start {
            let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, 
                                                        sectionNameKeyPath: nil, cacheName: nil)
            controller.delegate = self

            do {
                try controller.performFetch()
                let result = controller.fetchedObjects ?? []
                subject.send(result)
            } catch {
                subject.send(completion: .failure(error))
            }
            resultController = controller as? NSFetchedResultsController<NSManagedObject>
        }
        CDSubscription(fetchPublisher: self, subscriber: AnySubscriber(subscriber))
    }

      func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        let result = controller.fetchedObjects as? [Entity] ?? []
        subject.send(result)
    }

      private func dropSubscription() {
        objc_sync_enter(self)
        subscriptions -= 1
        let stop = subscriptions == 0
        objc_sync_exit(self)

        if stop {
            resultController?.delegate = nil
            resultController = nil
        }
    }

    private class CDSubscription: Subscription {
        private var fetchPublisher: CDPublisher?
        private var cancellable: AnyCancellable?

        @discardableResult
        init(fetchPublisher: CDPublisher, subscriber: AnySubscriber<Output, Failure>) {
            self.fetchPublisher = fetchPublisher

            subscriber.receive(subscription: self)

            cancellable = fetchPublisher.subject.sink(receiveCompletion: { completion in
                subscriber.receive(completion: completion)
            }, receiveValue: { value in
                _ = subscriber.receive(value)
            })
        }

        func request(_ demand: Subscribers.Demand) {}

        func cancel() {
            cancellable?.cancel()
            cancellable = nil
            fetchPublisher?.dropSubscription()
            fetchPublisher = nil
        }
    }

}
Apostolos
  • 409
  • 3
  • 13
  • 2
    Really like this piece of code, good example on how to write custom publishers. I just noticed that the obsolete `synchronized()` and also `objc_sync_enter()`/`..exit()` could be replaced with a serial queue or something similar :) – smat88dd Nov 10 '20 at 02:36
  • One drawback of the suggested way, is that the NSFetchedResultsController does not observe relationship changes. If you need relationship change observation you need to take a different approach. – TheoK Jul 15 '22 at 18:34
  • Yes, it would only get the changes a NSFetchResultController would report. But this is not a real issue. Imagine you have a class Shop which has Users. If you need a Shop you would create a `CDPublisher` for the Shop. If you need the Users of this shop you would create a `CDPublisher ` to give you the Users of this Shop. – Apostolos Jul 30 '22 at 11:14
8

You can pass the object you want to observe to publisher(for:):

NotificationCenter.default
  .publisher(for: .NSManagedObjectContextDidSave, object: backgroundMoc)
  .sink(receiveValue: { notification in
    // handle changes
  })

That will only listen for notifications related to a background managed object context which means you can do processing on that context's queue safely.

donnywals
  • 7,241
  • 1
  • 19
  • 27
  • 3
    I am going to accept this answer, as indeed it filters for the correct context and the subsequent operations are performed on the correct thread. But I'd like to leave info here, that in my scenario, you don't get a `NSManagedObjectContextDidSave` notification for the background thread (probably as it did not save, it just got updates). So you have to listen to `NSManagedObjectContextObjectsDidChange` instead. – Nicolas Mar 05 '20 at 08:47
-1

If you aren’t saving the context twice then you must be adding the observer twice.

malhal
  • 26,330
  • 7
  • 115
  • 133