NSFetchedResultsController extension to observe relationship changes

Apple provides us with great classes like the NSFetchedResultsController to interact with Core Data databases in our apps. The API evolved over the years with additions like support for the new NSDiffableDataSource. However, there are still scenarios where the default API is not helping enough.

At WeTransfer, we heavily make use of Core Data. All our content is displayed through fetched results controllers and is automatically refreshed through NSFetchedResultsControllerDelegate callbacks. An issue we run into was the lack of support for observing relationship changes.

How does an NSFetchedResultsController work?

To fully understand the issue it’s important to first understand how an NSFetchedResultsController works.

An NSFetchedResultsController is often also referred to as “FRC” and makes use of a NSFetchRequest to fetch its data. The FRC delegate can be used to update a UITableView or UICollectionView as we get updates on insertions, deletions, and updates for both sections and cell index paths. Those changes are automatically computed based on a comparison between the old and new datasets inside the FRC.

An example setup could look as follows:

let fetchRequest = NSFetchRequest<Content>(entityName: "Content")
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Content.creationDate, ascending: true)]
let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)

do {
    try controller.performFetch()
} catch {
    fatalError("Failed fetching content items with error: \(error)")
}

In this example, we’re fetching all Content entities and display them in a list. The FRC will make sure that the dataset is automatically updated in the following scenarios:

  • A Content item is inserted
  • A Content item is deleted
  • A Content item is updated

And of those changes will trigger the delegate callbacks from which we can update our UICollectionView or UITableView accordingly.

The problem explained: Updating the dataset when a relationship property changes

As we explained in the above section we now know that an NSFetchedResultsController is updating its dataset whenever an update happens on any of the Content instances. Let’s imagine that we have the following Content entity that belongs to a Bucket entity:

final class Content: NSManagedObject {
    @NSManaged var name: String
    @NSManaged var creationDate: Date
    @NSManaged var bucket: Bucket
}

final class Bucket: NSManagedObject {
    @NSManaged var name: String
    @NSManaged var content: Set<Content>
}

The relationship is a one-to-many as a content item can only have one bucket but a bucket can have many content items.

We have our NSFetchedResultsController configured as before where we use a fetch request that is sorted based on the content creation date:

let fetchRequest = NSFetchRequest<Content>(entityName: "Content")
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Content.creationDate, ascending: true)]
let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)

This results in the fact that updates will pass through once any of the Content entity properties are changing. For example, when we update the name of a content item:

content.name = "Updated name"
try persistentContainer.viewContext.save()

However, imagine that we have the following view:

An example of a UICollectionView that is backed by an NSFetchedResultsController.
An example of a UICollectionView that is backed by an NSFetchedResultsController.

This is a UICollectionView that is backed by an NSFetchedResultsController. It displays all content items that belong to many different buckets.

Within each image cell, we’re displaying both the content as well as the containing bucket name. This means that we need to update a cell when its containing bucket gets a new name:

content.bucket.name = "Updated name"
try persistentContainer.viewContext.save()

Unfortunately, this is not the case as the FRC is designed to monitor changes on the model layer only.

Solutions we found but didn’t like

Some of the solutions that exist on the web suggest to fix this with hard reloads like a reloadData() call. Doing so works but has tremendous performance downsides as we have to redraw all the cells including ones that aren’t affected by this change.

Another solution would be to observe changes to the related bucket from within each cell by adding a NSNotification.Name.NSManagedObjectContextDidSave observer. This would also have performance downsides as we would have multiple observers set for each cell that requires updates.

Obviously, this problem grows once we need to monitor multiple relationships. What if we have a User entity that is the owner of a content item and we’re displaying the user’s name as well? You can imagine that this easily becomes a big problem to maintain.

If at any time you run into debugging issues, consider reading my blog post on Core Data Debugging in Xcode using launch arguments.

Extending the NSFetchedResultsController to observe for relationship changes

It’s time to dive into the solution we’ve written. Even though you might not need this in your project it can still be very valuable to read through this as it shows how you can nicely solve similar issues.

The final implementation of our solutions looks as follows:

let fetchRequest = RichFetchRequest<Content>(entityName: "Content")
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Content.creationDate, ascending: true)]

fetchRequest.relationshipKeyPathsForRefreshing = [
    #keyPath(Content.bucket.name)
]

let controller = RichFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)

There’s a few things that have changed:

  • We now have a RichFetchRequest instance that allows us to enrich the default NSFetchRequest with custom functionality
  • Because of this, we now also have a RichFetchedResultsController that only works with rich fetch requests
  • Finally, we’ve added a new property relationshipKeyPathsForRefreshing that takes an array of relationship key paths to observe for updates

This is great! It looks almost like native API, and it’s discoverable from the fetch request setup. On top of that, we’ve centralized our setup that describes how our dataset is being updated.

Let’s dive in and see how this is created.

The overall structure

It’s good to get an overview of the structure we’ve created here. We have a few instances that all work together to make this happen:

  • RichFetchRequest
  • RichFetchedResultsController
  • RelationshipKeyPath
  • RelationshipKeyPathsObserver

The fetch request defines the relationship key paths to observe and passes them into the FRC. The FRC sets up a RelationshipKeyPathsObserver that is responsible for setting up the required observations and also triggers the required refresh if needed.

Creating the RichFetchRequest and RichFetchedResultsController

These classes are kind of simple and are only used for passing through the relationship key paths into the observer.

/// An enhanced `NSFetchRequest` that has extra functionality.
public final class RichFetchRequest<ResultType>: NSFetchRequest<NSFetchRequestResult> where ResultType: NSFetchRequestResult {

    /// A set of relationship key paths to observe when using a `RichFetchedResultsController`.
    public var relationshipKeyPathsForRefreshing: Set<String> = []
}

This is the RichFetchRequest that takes a generic ResultType. As we subclass from an Objective-C class we need to set a strong type for our NSFetchRequest base class. Here we can also see the relationshipKeyPathsForRefreshing property that collects the relationship key paths to observe.

/// An enhanced `NSFetchedResultsController` that has extra functionality.
public class RichFetchedResultsController<ResultType: NSFetchRequestResult>: NSFetchedResultsController<NSFetchRequestResult> {

    /// The relationship key paths observer that is only initialised if the fetch request has a `relationshipKeyPathsForRefreshing` set.
    private var relationshipKeyPathsObserver: RelationshipKeyPathsObserver<ResultType>?

    public init(fetchRequest: RichFetchRequest<ResultType>, managedObjectContext context: NSManagedObjectContext, sectionNameKeyPath: String?, cacheName name: String?) {
        super.init(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: sectionNameKeyPath, cacheName: name)

        relationshipKeyPathsObserver = RelationshipKeyPathsObserver<ResultType>(keyPaths: fetchRequest.relationshipKeyPathsForRefreshing, fetchedResultsController: self)
    }

}

This is the RichFetchedResultsController that retains the observer and uses the rich fetch request for setting up the observer itself. We only need to create a new initializer that allows taking the RichFetchRequest as input.

Building a relationship key paths observer

This is the class where all the magic happens. The relationship key paths observer is responsible for setting up observations on the given context, matching it to our relationship key paths, and finally making sure that the right entities are refreshed.

The class works with a RelationshipKeyPath struct that is basically parsing the given relationship key path into an instance that gives information on the relationship with our FRC core entity. In our example, this is the relationship to our core Content entity.

/// Describes a relationship key path for a Core Data entity.
public struct RelationshipKeyPath: Hashable {

    /// The source property name of the relationship entity we're observing.
    let sourcePropertyName: String

    let destinationEntityName: String

    /// The destination property name we're observing
    let destinationPropertyName: String

    /// The inverse property name of this relationship. Can be used to get the affected object IDs.
    let inverseRelationshipKeyPath: String

    public init(keyPath: String, relationships: [String: NSRelationshipDescription]) {
        let splittedKeyPath = keyPath.split(separator: ".")
        sourcePropertyName = String(splittedKeyPath.first!)
        destinationPropertyName = String(splittedKeyPath.last!)

        let relationship = relationships[sourcePropertyName]!
        destinationEntityName = relationship.destinationEntity!.name!
        inverseRelationshipKeyPath = relationship.inverseRelationship!.name

        [sourcePropertyName, destinationEntityName, destinationPropertyName].forEach { property in
            assert(!property.isEmpty, "Invalid key path is used")
        }
    }
}

These properties are all used to identify changes in the managed object context that should trigger a refresh.

Our basic class setup looks as follows:

/// Observes relationship key paths and refreshes Core Data objects accordingly once the related managed object context saves.
public final class RelationshipKeyPathsObserver<ResultType: NSFetchRequestResult>: NSObject {
    private let keyPaths: Set<RelationshipKeyPath>
    private unowned let fetchedResultsController: RichFetchedResultsController<ResultType>

    private var updatedObjectIDs: Set<NSManagedObjectID> = []

    public init?(keyPaths: Set<String>, fetchedResultsController: RichFetchedResultsController<ResultType>) {
        guard !keyPaths.isEmpty else { return nil }

        let relationships = fetchedResultsController.fetchRequest.entity!.relationshipsByName
        self.keyPaths = Set(keyPaths.map { keyPath in
            return RelationshipKeyPath(keyPath: keyPath, relationships: relationships)
        })
        self.fetchedResultsController = fetchedResultsController

        super.init()

        NotificationCenter.default.addObserver(self, selector: #selector(contextDidChangeNotification(notification:)), name: NSNotification.Name.NSManagedObjectContextObjectsDidChange, object: fetchedResultsController.managedObjectContext)
        NotificationCenter.default.addObserver(self, selector: #selector(contextDidSaveNotification(notification:)), name: NSNotification.Name.NSManagedObjectContextDidSave, object: fetchedResultsController.managedObjectContext)
    }
}

It’s taking care of parsing the key paths into our RelationshipKeyPath instance and adds two observers to the NSFetchedResultsController context. Let’s break down these two notifications and describe what they do.

Observing managed object context changes

Our first observer makes use of the NSManagedObjectContextObjectsDidChange notification and is responsible for tracking any changes in the context. We make use of this notification as it allows us to read out changed values using the NSManagedObject.changedValues() method. This method would not return anything in the “did save” notification as the changes would already be merged.

The notification callback looks as follows:

@objc private func contextDidChangeNotification(notification: NSNotification) {
    guard let updatedObjects = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> else { return }
    guard let updatedObjectIDs = updatedObjects.updatedObjectIDs(for: keyPaths), !updatedObjectIDs.isEmpty else { return }
    self.updatedObjectIDs = self.updatedObjectIDs.union(updatedObjectIDs)
}

This method is responsible for collecting the updated NSManagedObjectID instances into a Set. We make use of a Set as it allows us to keep a collection of unique instances and it prevents us later on for refreshing a cell multiple times.

Note that we’re only considering updated objects as we don’t care about newly inserted or deleted instances. These are handled correctly by the NSFetchedResultsController and don’t need any custom reloading.

The methods used look as follows:

extension Set where Element: NSManagedObject {

    /// Iterates over the objects and returns the object IDs that matched our observing keyPaths.
    /// - Parameter keyPaths: The keyPaths to observe changes for.
    func updatedObjectIDs(for keyPaths: Set<RelationshipKeyPath>) -> Set<NSManagedObjectID>? {
        var objectIDs: Set<NSManagedObjectID> = []
        forEach { object in
            guard let changedRelationshipKeyPath = object.changedKeyPath(from: keyPaths) else { return }

            let value = object.value(forKey: changedRelationshipKeyPath.inverseRelationshipKeyPath)
            if let toManyObjects = value as? Set<NSManagedObject> {
                toManyObjects.forEach {
                    objectIDs.insert($0.objectID)
                }
            } else if let toOneObject = value as? NSManagedObject {
                objectIDs.insert(toOneObject.objectID)
            } else {
                assertionFailure("Invalid relationship observed for keyPath: \(changedRelationshipKeyPath)")
                return
            }
        }

        return objectIDs
    }
}

private extension NSManagedObject {

    /// Matches the given key paths to the current changes of this `NSManagedObject`.
    /// - Parameter keyPaths: The key paths to match the changes for.
    /// - Returns: The matching relationship key path if found. Otherwise, `nil`.
    func changedKeyPath(from keyPaths: Set<RelationshipKeyPath>) -> RelationshipKeyPath? {
        return keyPaths.first { keyPath -> Bool in
            guard keyPath.destinationEntityName == entity.name! || keyPath.destinationEntityName == entity.superentity?.name else { return false }
            return changedValues().keys.contains(keyPath.destinationPropertyName)
        }
    }
}

This is quite a lot to digest but by breaking it down this should be possible.

  • First, we try to fetch the changed key path for the updated object. If it doesn’t match any of our observing key paths, we can directly skip this object.
    • The match is found based on entity matching
    • Subentities are considered as well. For example, an ImageContent that inherits from a Content entity
    • Finally, the changedValues() method is used to verify that the change contains our observing key path
  • Secondly, we get the object IDs from our instance by taking the inverse relationship property. This makes sure that we get all object IDs in the case of a to-many relationship. For example, our Bucket entity that has many Content items will all be added to our Set of object ids.

Observing managed object context saves

Our final piece of code exists in monitoring saves to our context. This is the only moment that we actually want to trigger reloads of our cells as the data is finally committed. It results in better performance as we would otherwise reload a cell on every uncommitted change.

The callback of the observation looks as follows:

@objc private func contextDidSaveNotification(notification: NSNotification) {
    guard !updatedObjectIDs.isEmpty else { return }
    guard let fetchedObjects = fetchedResultsController.fetchedObjects as? [NSManagedObject], !fetchedObjects.isEmpty else { return }

    fetchedObjects.forEach { object in
        guard updatedObjectIDs.contains(object.objectID) else { return }
        fetchedResultsController.managedObjectContext.refresh(object, mergeChanges: true)
    }
    updatedObjectIDs.removeAll()
}

We make sure that we have updated objects to refresh. After that, we take our current fetched dataset and we start iterating over them to see whether we find a matched updated object identifier.

Whenever we find one, we trigger the refresh(_:mergeChanges:) method with mergeChanges set to true. This makes sure that any uncommitted changes are not thrown away but instead merged with the newly available data. This refresh method is also responsible for triggering the NSFetchedResultsController as its being picked up automatically. All your delegate methods will be called accordingly just as if we were updating the name of our core Content entity.

Conclusion

That was it! We’ve extended the NSFetchedResultsController and created a top-level API that is discoverable and easy to use. By observing the changes in a centralized RelationshipKeyPathsObserver observer class we allow ourselves to create a well-performing solution to keep our content up to date for relationship changes.

If you consider more performant updates to your app you can consider reading my blog post on Using NSBatchDeleteRequest to delete batches in Core Data.

If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!