Responding to changes in a managed object context

Published on: November 23, 2020

Working with multiple managed object contexts will often involve responding to changes that were made in one context to update another context. You might not even want to update another context but reload your UI or perform some other kind of update. Maybe you want to do this when a specific context updates, or maybe you want to run some code when any context updates.

In this week's post I will show you how you can listen for changed in managed object contexts, and how you can best use them. I will also show you a convenient way to extract information from a Core Data related Notification object through a nice extension.

Subscribing to Core Data related Notifications

Regardless of your specific needs, Core Data has a mechanism that allows you to be notified when a managed object updates. This mechanism plays a key role in objects like NSFetchedResultsController which tracks a specific managed object context in order to figure out whether specific objects were inserted, deleted or updated. In addition to this, a fetch result also tracks whether the position of a managed object within a result set has changed which is not something that you can trivially track yourself; this is implemented within the fetched results controller.

You can monitor and respond to changes in your managed object contexts through NotificationCenter. When your managed object context updates or saves, Core Data will post a notification to the default NotificationCenter object.

For example, you can listen for an NSManagedObjectContext.didSaveObjectsNotification to be notified when a managed object context was saved:

class ExampleViewModel {
  init() {
    let didSaveNotification = NSManagedObjectContext.didSaveObjectsNotification
    NotificationCenter.default.addObserver(self, selector: #selector(didSave(_:)),
                                            name: didSaveNotification, object: nil)
  }

  @objc func didSave(_ notification: Notification) {
    // handle the save notification
  }
}

The example code above shows how you can be notified when any managed object context is saved. The notification you receive here contains a userInfo dictionary that will tell you which objects were inserted, deleted and/or updated. For example, the following code extracts the inserted objects from the userInfo dictionary:

@objc func didSave(_ notification: Notification) {
  // handle the save notification
  let insertedObjectsKey = NSManagedObjectContext.NotificationKey.insertedObjects.rawValue
  print(notification.userInfo?[insertedObjectsKey])
}

Note that NSManagedObjectContext has a nested type called NotificationKey. This type is an enum that has cases for every relevant key that you might want to use. Since the enum case name for the notification keys don't match with the string that you need to access the relevant key in the dictionary, it's important that you use the enum's rawValue rather than the enum case directly.

Note that NSManagedObjectContext.NotificationKey is only available on iOS 14.0 and up. For iOS 13.0 and below you can use the Notification.Name.NSManagedObjectContextDidSave to listen for save events. For a more complete list for iOS 13.0 notifications I'd like to point you to the "See Also" section on the documentation page for NSManagedObjectContextDidSave which is located here.

I'm not a big fan of how verbose this is so I like to use an extension on Dictionary to help me out:

extension Dictionary where Key == AnyHashable {
  func value<T>(for key: NSManagedObjectContext.NotificationKey) -> T? {
    return self[key.rawValue] as? T
  }
}

This extension is very simple but it allows me to write code from before as follows which is much cleaner:

@objc func didSave(_ notification: Notification) {
  // handle the save notification
  let inserted: Set<NSManagedObject>? = notification.userInfo?.value(for: .insertedObjects)
  print(inserted)
}

We could take this even further with an extension on Notfication specifically for Core Data related notifications:

extension Notification {
  var insertedObjects: Set<NSManagedObject>? {
    return userInfo?.value(for: .insertedObjects)
  }
}

This notification would be used as follows:

@objc func didSave(_ notification: Notification) {
  // handle the save notification
  let inserted = notification.insertedObjects
  print(inserted)
}

I like how clean the callsite is here . The main downside is that we can't constrain the extension to Core Data related notifications only, and we'll need to manually add computed properties for every notification key. For example, to extract all updated objects through a Notification extension you'd have to add the following property to the extension I showed you earlier:

var updatedObjects: Set<NSManagedObject>? {
  return userInfo?.value(for: .updatedObjects)
}

It's not a big deal to add these computed properties manually, and it can clean up your code quite a bit so it's worth the effort in my opinion. Whether you want to use an extension like this is really a matter of preference so I'll leave it up to you to decide whether you think this is a good idea or not.

Let's get bakc on topic, this isn't a section about building convenient extensions after all. It's about observing managed object context changes.

The code I showed you earlier subscribed to the NSManagedObjectContext.didSaveObjectsNotification in a way that would notify you every time any managed object context would save. You can constrain this to a specific notification as follows:

let didSaveNotification = NSManagedObjectContext.didSaveObjectsNotification
let targetContext = persistentContainer.viewContext
NotificationCenter.default.addObserver(self, selector: #selector(didSave(_:)),
                                        name: didSaveNotification, object: targetContext)

By passing a reference to a managed object context you can make sure that you're only notified when a specific managed object context was saved.

Imagine that you have two managed object contexts. A viewContext and a background context. You want to update your UI whenever one of your background contexts saves, triggering a change in your viewContext. You could subscribe to all managed object context did save notifications and simply update your UI when any context got saved.

This would work fine if you have set automaticallyMergesChangesFromParent on your viewContext to true. However, if you've set this property to false you find that your viewContext might not yet have merged in the changes from the background context which means that updating your UI will not always show the lastest data.

You can make sure that a managed object context merges changes from another managed object context by subscribing to the didSaveObjectsNotification and merging in any changes that are contained in the received notification as follows:

@objc func didSave(_ notification: Notification) {
  persistentContainer.viewContext.mergeChanges(fromContextDidSave: notification)
}

Calling mergeChanges on a managed object context will automatically refresh any managed objects that have changed. This ensures that your context always contains all the latest information. Note that you don't have to call mergeChanges on a viewContext when you set its automaticallyMergesChangesFromParent property to true. In that case, Core Data will handle the merge on your behalf.

In addition to knowing when a managed object context has saved, you might also be interested in when its objects changed. For example, because the managed object merged in changes that were made in another context. If this is what you're looking for, you can subscribe to the didChangeObjectsNotification.

This notification has all the same characteristics as didSaveObjectsNotification except it's fired when a context's objects change. For example when it merges in changes from another context.

The notifications that I've shown you so far always contain managed objects in their userInfo dictionary, this provides you full access to the changed objects as long as you access these objects from the correct managed object context.

This means that if you receive a didSaveObjectsNotification because a context got saved, you can only access the included managed objects on the context that generated the notification. You could manage this by extracting the appropriate context from the notifiaction as follows:

@objc func didSave(_ notification: Notification) {
  guard let context = notification.object as? NSManagedObjectContext,
        let insertedObjects = notification.insertedObjects as? Set<ToDoItem> else {
    return
  }
  context.perform {
    for object in insertedObjects {
      print(object.dueDate)
    }
  }
}

While this works, it's not always appropriate.

For example, it could make perfect sense for you to want to access the inserted objects on a different managed object context for a variety of reasons.

Extracting managed object IDs from a notification

When you want to pass managed objects from a notification to a different context, you could of course extract the managed object IDs and pass them to a different context as follows:

@objc func didSave(_ notification: Notification) {
  guard let insertedObjects = notification.insertedObjects else {
    return
  }

  let objectIDs = insertedObjects.map(\.objectID)

  for id in objectIDs {
    if let object = try? persistentContainer.viewContext.existingObject(with: id) {
      // use object in viewContext, for example to update your UI
    }
  }
}

This code works, but we can do better. In iOS 14 it's possible to subscribe to Core Data's notifications and only receive object IDs. For example, you could use the insertedObjectIDs notification to obtain all new object IDs that were inserted.

The Notification extension property to get convenient access to insertedObjectIDs would look as follows:

extension Notification {
  // other properties

  var insertedObjectIDs: Set<NSManagedObjectID>? {
    return userInfo?.value(for: .insertedObjectIDs)
  }
}

You would then use the following code to extract managed object IDs from the notification and use them in your viewContext:

@objc func didSave(_ notification: Notification) {
  guard let objectIDs = insertedObjects.insertedObjectIDs else {
    return
  }

  for id in objectIDs {
    if let object = try? persistentContainer.viewContext.existingObject(with: id) {
      // use object in viewContext, for example to update your UI
    }
  }
}

It doesn't save you a ton of code but I do like that this notification is more explicit in its intent than the version that contains full managed objects in its userInfo.

In Summary

Notifications can be an incredibly useful tool when you're working with any number of managed object contexts, but I find them most useful when working with multiple managed object contexts. In most cases you'll be interested in the didChangeObjectsNotification for the viewContext only. The reason for this is that it's often most useful to know when your viewContext has merged in data that may have originated in another context. Note that didChangeObjectsNotification also fires when you save a context.

This means that when you subscribe to didChangeObjectsNotification on the viewContext and you insert new objects into the viewContext and then call save(), the didChangeObjectsNotification for your viewContext will fire.

When you use NSFetchedResultsController or SwiftUI's @FetchRequest you may not need to manually listen for notifications often. But it's good to know that these notifications exist, and to understand how you can use them in cases where you're doing more complex and custom work.

If you have any questions about this post, or if you have feedback for me you can reach out to me on Twitter.

Categories

Core Data