Observing changes to managed objects across contexts with Combine
Published on: October 12, 2020A common pattern in Core Data is to fetch objects and show them in your UI using one managed object context, and then use another context to update, insert or delete managed objects. There are several ways for you to update your UI in response to these changes, for example by using an NSFetchedResultsController
. I wrote about doing this in a SwiftUI app in an earlier post.
In this week's post I will show you a useful technique that allows you to observe specific managed objects across different contexts so you can easily update your UI when, for example, that managed object was changed on a background queue.
I'll start with a simple, unoptimized version that only listens for updates and move on to expand it into a more flexible and optimized version that can also watch for insertion of objects of a specific type, or notify you when a managed object has been deleted.
We'll use Combine for this and, suprisingly enough, we're not going to build any custom publishers to achieve our goal.
Building a simple managed object observer
The simplest way to observe changes in a Core Data store is by listening for one of the various notifications that are posted when changes occur within Core Data. For example, you can listen for the NSManagedObjectContext.didSaveObjectsNotification
notification if you want to know when a managed object context saved objects, and possibly extract them from the notification if needed.
NSManagedObjectContext.didSaveObjectsNotification
is available on iOS 14 and above. Prior to iOS 14 this notification was available asNotification.Name.NSManagedObjectContextDidSave
.
If you're looking for a more lightweight notification, for example if you only want to determine if a certain object changed, or if you want to materialize your objects in a context other than the one that triggered the notification, you can use NSManagedObjectContext.didMergeChangesObjectIDsNotification
to be notified when a specific context merged changes for specific objectIDs into its own context.
Typically you will merge changes that occurred on a background context into your view context automatically by setting the automaticallyMergesChangesFromParent
property on your persistent container's viewContext
to true
. This means that whenever a background context saves managed objects, those changes are merged into the viewContext
automatically, providing easy access to updated properties and objects.
Our goal is to make it as easy as possible to be notified of changes to specific managed objects that are shown in the UI. We'll do this by writing a function that returns a publisher that emits values whenever a certain managed object changes.
Here's what the API for this will look like:
class CoreDataStorage {
// configure and create persistent container
// viewContext.automaticallyMergesChangesFromParent is set to true
func publisher<T: NSManagedObject>(for managedObject: T,
in context: NSManagedObjectContext) -> AnyPublisher<T, Never> {
// implementation goes here
}
}
The API is pretty simple and elegant. We can pass the managed object that should be observed to this method, and we can tell it which context should be observed. Note that the context that's expected here is the context that we want to observe, not the context that will make the change. In other words, this will usually be your viewContext
since that's the context that will merge in changes from background contexts and trigger a UI update.
If you pass the managed object context that makes the changes, you will not receive updates with the implementation I'm about to show you. The reason for that is that the context that makes the changes doesn't merge in its own changes because it already contains them.
If you want to receive updates even if the context that makes changes is also the context that's observed you can use the NSManagedObjectContext.didSaveObjectIDsNotification
instead since that will fire for the context that saved (which is the context that made changes) rather than the context that merged in changes.
Note that I made publisher(for:in:)
generic so that I can pass any managed object to it and the returned publisher will publish objects of the same type as the managed object that I want to observe.
I will explain more about this setup later, but let's look at the implementation for publisher(for:in:)
first:
func publisher<T: NSManagedObject>(for managedObject: T,
in context: NSManagedObjectContext) -> AnyPublisher<T, Never> {
let notification = NSManagedObjectContext.didMergeChangesObjectIDsNotification
return NotificationCenter.default.publisher(for: notification, object: context)
.compactMap({ notification in
if let updated = notification.userInfo?[NSUpdatedObjectIDsKey] as? Set<NSManagedObjectID>,
updated.contains(managedObject.objectID),
let updatedObject = context.object(with: managedObject.objectID) as? T {
return updatedObject
} else {
return nil
}
})
.eraseToAnyPublisher()
}
The code above creates a notification publisher for NSManagedObjectContext.didMergeChangesObjectIDsNotification
and passes the context
argument as the object
that should be associated with the notification. This ensures that we only receive and handle notifications that originated in the target context.
Next, I apply a compactMap
to this publisher to grab the notification and check whether it has a list of updated managed object IDs. If it does, I check whether the observed managed object's objectID
is in the set, and if it is I pull the managed object into the target context using object(with:)
. This will retrieve the managed object from the persistent store and associate it with the target context.
Important:
Note that this code does not violate Core Data's threading rules. A managed object'sobjectID
property is the only property that can be safely accessed across threads. It is crucial that the subscriber of the publisher created by this method handles the emitted managed object on the correct context which should be trivial since the context is available from wherepublisher(for:in:)
is called.
If the notification doesn't contain updates, or if the notification doesn't contain the appropropriate objectID
I return nil
. This will ensure that the the publisher doesn't emit anything if we don't have anything to emit since compactMap
will prevent any nil
values from being delivered to our subscribers.
Because I want to keep my return type clean I erase the created publisher to AnyPublisher
.
To use this simple single managed object observer you might write something like the following code:
class ViewModel: ObservableObject {
var album: Album // a managed object subclass
private var cancellables = Set<AnyCancellable>()
init(album: Album, storage: CoreDataStorage) {
self.album = album
guard let ctx = album.managedObjectContext else {
return
}
storage.publisher(for: album, in: ctx)
.sink(receiveValue: { [weak self] updatedObject in
self?.album = updatedObject
self?.objectWillChange.send()
})
.store(in: &cancellables)
}
}
This code is optimized for a SwiftUI app where ViewModel
would be used as an @StateObject
(or @ObservedObject
) property. There are of course several ways for you to publish changes to the outside world, this is just one example. The point here is that you can now obtain a publisher that emits values when your managed object was changed by another managed object context.
While this is a kind of neat setup, it's not ideal. We can only listen for changes to existing managed objects but we can't listen for insert or delete events. We can build a more robust solution that can not only listen for updates, but also deletions or insertions for managed objects of a specific type rather than just a single instance. Let's see how.
Building a more sophisticated observer to publish changes
Before we refactor our implementation, I want to show you the new method signature for publisher(for:in:)
:
func publisher<T: NSManagedObject>(for managedObject: T,
in context: NSManagedObjectContext,
changeTypes: [ChangeType]) -> AnyPublisher<(T?, ChangeType), Never> {
// implementation goes here
}
This signature is very similar to what we had before except I've added a ChangeType
object. By adding a ChangeType
to the publisher(for:in:)
function it's now possible to listen for one or more kinds of changes that a managed object might go through. The publisher that we return now emits tuples that contain the managed object (if it's still around) and the change type that triggered the publisher.
This method is useful if you already have a managed object instance that you want to observer.
Before I show you the implementation for this method, here's what the ChangeType
enum looks like:
enum ChangeType {
case inserted, deleted, updated
var userInfoKey: String {
switch self {
case .inserted: return NSInsertedObjectIDsKey
case .deleted: return NSDeletedObjectIDsKey
case .updated: return NSUpdatedObjectIDsKey
}
}
}
It's a straightforward enum with a computed property to easily access the correct key in a notification's userInfo
dictionary later.
Let's look at the implementation for publisher(for:in:changeType:)
:
func publisher<T: NSManagedObject>(for managedObject: T,
in context: NSManagedObjectContext,
changeTypes: [ChangeType]) -> AnyPublisher<(object: T?, type: ChangeType), Never> {
let notification = NSManagedObjectContext.didMergeChangesObjectIDsNotification
return NotificationCenter.default.publisher(for: notification, object: context)
.compactMap({ notification in
for type in changeTypes {
if let object = self.managedObject(with: managedObject.objectID, changeType: type,
from: notification, in: context) as? T {
return (object, type)
}
}
return nil
})
.eraseToAnyPublisher()
}
I've done some significant refactoring here, but the outline for the implementation is still very much the same.
Since we can now pass an array of change types, I figured it'd be useful to loop over all received change types and check whether there's a managed object with the correct objectID
in the notification's userInfo
dictionary for the key that matches the change type we're currently evaluating. If no match was found we return nil
so no value is emitted by the publisher due to the compactMap
that we applied to the notification center publisher.
Most of the work for this method is done in my managedObject(with:changeType:from:in:)
method. It's defined as a private method on my CoreDataStorage
:
func managedObject(with id: NSManagedObjectID, changeType: ChangeType,
from notification: Notification, in context: NSManagedObjectContext) -> NSManagedObject? {
guard let objects = notification.userInfo?[changeType.userInfoKey] as? Set<NSManagedObjectID>,
objects.contains(id) else {
return nil
}
return context.object(with: id)
}
The logic here looks very similar to what you've seen in the previous section but it's a bit more reusable this way, and it cleans up my for loop nicely.
To use this new method you could write something like the following:
class ViewModel: ObservableObject {
var album: Album? // a managed object subclass
private var cancellables = Set<AnyCancellable>()
init(album: Album, storage: CoreDataStorage) {
self.album = album
guard let ctx = album.managedObjectContext else {
return
}
storage.publisher(for: album, in: ctx, changeTypes: [.updated, .deleted])
.sink(receiveValue: { [weak self] change in
if change.type != .deleted {
self?.album = change.object
} else {
self?.album = nil
}
self?.objectWillChange.send()
})
.store(in: &cancellables)
}
}
The code above would receive values whenever the observed managed object is updated or deleted.
Let's add one more interesting publisher so we can listen for insertion, updating and deleting of any object that matches a specific managed object subclass. Since the code will be similar to what you've seen before, here's the implementation for the entire method:
func publisher<T: NSManagedObject>(for type: T.Type,
in context: NSManagedObjectContext,
changeTypes: [ChangeType]) -> AnyPublisher<[([T], ChangeType)], Never> {
let notification = NSManagedObjectContext.didMergeChangesObjectIDsNotification
return NotificationCenter.default.publisher(for: notification, object: context)
.compactMap({ notification in
return changeTypes.compactMap({ type -> ([T], ChangeType)? in
guard let changes = notification.userInfo?[type.userInfoKey] as? Set<NSManagedObjectID> else {
return nil
}
let objects = changes
.filter({ objectID in objectID.entity == T.entity() })
.compactMap({ objectID in context.object(with: objectID) as? T })
return (objects, type)
})
})
.eraseToAnyPublisher()
}
This method takes a T.Type
rather than a managed object instance as its first argument. By accepting T.Type
callers can pass the type of object they want to observe. For example by passing Album.self
as the type. The AnyPublisher
that we create will return an array of ([T], ChangeType)
since we can have multiple changes in a single notification and each change can have multiple managed objects.
In the implementation I listen for the same didMergeChangesObjectIDsNotification
notification I did before, and then I compactMap
over the publisher. Also the same I did before. Inside the closure for the publisher's compactMap
I use Array
's compactMap
to loop over all change types I'm observing. For each change type I check whether there is an entry in the userInfo
dictionary, and I extract only the managed object IDs that have an entity description that matches the observed type's entity description. Lastly, I attempt to retrieve objects with all found ids from the target context. I do this in a compactMap
because I want to filter out any nil
values if casting to T
fails.
I then create a tuple of ([T], ChangeType)
and return that from the array compactMap
. By doing this I create an array of [([T], ChangeType)]
. This way of using maps, filters and compact map is somewhat advances, and especially when combined with Combine's compactMap
I can see how this might look confusing. It's okay if you have to look at the code a couple of times. Maybe even try to type it line by line if you struggle to make sense of all these maps. Once you get it, it's quite elegant in my opinion.
You can use the above method as follows:
storage.publisher(for: Album.self, in: storage.viewContext, changeTypes: [.inserted, .updated, .deleted])
.sink(receiveValue: { [weak self] changes in
self?.storage.viewContext.perform
// iterate over changes
// make sure to do so on the correct queue if applicable with .perform
}
})
.store(in: &cancellables)
I'm very happy with how the call site for this code looks, and the API is certainly a lot cleaner than listening for managed object context notifications all over the place and extracting the information you need. With this setup you have a clean API where all core logic is bundled in a single location. And most importantly, this code does a good job of showing you the power of what Combine can do without the need to create any custom publishers from scratch.
In Summary
In this post you saw how you can use a notification center publisher and a cleverly placed compactMap
to build a pretty advanced managed object observer that allows you to see when changes to a specific managed object were merged into a specific context. You started off with a pretty basic setup that we expanded into an API that can be used to observe insertion, deletion and updates for a specific object.
After that, we took it one step further to enable observing a managed object context for changes of one or more types to managed objects of a certain class without needing an instance of that managed object up front by listening to changes to all objects that match the desired type.
The techniques demonstrated in this post all build on fundamentals that you likely have seen before. The interesting bit is that you may have never seen or used these fundamentals in the way that I demonstrated in this post. If you have any suggestions, corrections, questions, or feedback about this post, please send me a message on Twitter.