Have you ever considered using more than one managed object context in a Core Data application? In this episode of Building the Perfect Core Data Stack, we explore the options you have and the benefits of a Core Data stack with multiple managed object contexts.
Why Is Two Better Than One?
While many applications can get away with a single managed object context, there will be times when one managed object context won't cut it. The setup I have come to appreciate looks like this.
The Core Data stack doesn't look overly complex. Right? Let me explain how it works and what the benefits are.
Even though the Core Data stack includes one persistent store coordinator and two managed object contexts, only one managed object context has a reference to the persistent store coordinator. This managed object context is private and only accessible by the class that manages the Core Data stack, the CoreDataManager
class we created in the previous episode of this series.
The second managed object context is a child of the private managed object context or, put differently, the private managed object context is the parent managed object context of the second managed object context. The child managed object context doesn't know about the persistent store coordinator.
Why is this necessary? What are the benefits of using two managed object contexts? The private managed object context operates on a background queue. This means that it doesn't block the main thread, the thread on which the user interface is updated, when it performs operations.
The second managed object context, which is a child of the private managed object context, operates on the main thread, which makes it ideal for any operations that involve the user interface. I talk more about threading and concurrency later in this series.
Using parent and child managed object contexts is also known as nesting managed object contexts. When using nested managed object contexts, there are a number of consequences you need to be aware of.
Saving Changes
When a managed object context saves its changes, it pushes those changes to the persistent store coordinator it is linked to and the persistent store coordinator pushes the changes to the persistent store(s) it manages.
But what happens if a managed object context isn't linked to a persistent store coordinator? The main managed object context in the above diagram doesn't know about the persistent store coordinator of the Core Data stack. If a managed object context is a child of another managed object context, the changes are pushed to its parent managed object context when a save operation is performed. This implies that the changes aren't pushed to the persistent store(s) when a save operation occurs.
This is good, though. Writing data to disk can take up a non-trivial amount of time. If this happens on the main thread, then it is blocked as long as the write operation is ongoing. Remember that the user interface is also updated on the main thread and, therefore, a write operation could temporarily freeze the user interface, which is something we want to avoid at any cost.
By using a private managed object context that operates on a background thread, we can push changes to the persistent store coordinator without blocking the main thread. The changes that are pushed from the child managed object context to the private managed object context won't have a significant impact on the performance or responsiveness of the application because no data is written to disk.
While the Core Data stack is a bit more complex, the benefits far outweigh the slight increase in complexity. This is especially true if we take concurrency into account.
Updating the Core Data Stack
There are a few changes we need to make to the CoreDataManager
class. Revisit the project we created in the previous episode and open it in Xcode.
Adding and Updating Properties
In the CoreDataManager
class, we change the name of the managedObjectContext
property to privateManagedObjectContext
. We make the property private and set the concurrency type of the managed object context to privateQueueConcurrencyType
. By passing in privateQueueConcurrencyType
as the first argument of the initializer, the managed object context is tied to a private queue, which is what we want.
private lazy var privateManagedObjectContext: NSManagedObjectContext = {
// Initialize Managed Object Context
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
// Configure Managed Object Context
managedObjectContext.persistentStoreCoordinator = self.persistentStoreCoordinator
return managedObjectContext
}()
We need to create a property for the main managed object context, which is tied to the main thread. This is the managed object context that we use for any operations related to the user interface and we also use this managed object context to create new NSManagedObjectContext
instances, for example, for background operations.
public private(set) lazy var mainManagedObjectContext: NSManagedObjectContext = {
// Initialize Managed Object Context
let managedObjectContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
// Configure Managed Object Context
managedObjectContext.parent = self.privateManagedObjectContext
return managedObjectContext
}()
The implementation looks very similar. The main difference is that the main managed object context is publicly accessible and has no reference to a persistent store coordinator. Note that the parent
property of the main managed object context is set to the private managed object context we created a moment ago.
Update the implementation of application(_:didFinishLaunchingWithOptions:)
as shown below and run the application in the simulator.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
print(coreDataManager.mainManagedObjectContext)
return true
}
Saving Changes
To end this episode, I'd like to show you how to save changes when working with multiple managed object contexts. Because the main managed object context is a child of the private managed object context, invoking save()
on the main managed object context only pushes the changes to the parent managed object context, not to the persistent store. While that's fine for most operations, you also need to push the changes to the persistent store at some point in the application's life cycle to make sure they're written to disk.
The saveChanges()
method you see below is a public instance method of the CoreDataManager
class that:
- pushes the changes from the main managed object context to the private managed object context
- pushes the changes of the private managed object context to the persistent store coordinator
There are several details worth pointing out.
// MARK: - Public API
public func saveChanges() {
mainManagedObjectContext.performAndWait {
do {
if self.mainManagedObjectContext.hasChanges {
try self.mainManagedObjectContext.save()
}
} catch {
print("Unable to Save Changes of Main Managed Object Context")
print("\(error), \(error.localizedDescription)")
}
}
privateManagedObjectContext.perform {
do {
if self.privateManagedObjectContext.hasChanges {
try self.privateManagedObjectContext.save()
}
} catch {
print("Unable to Save Changes of Private Managed Object Context")
print("\(error), \(error.localizedDescription)")
}
}
}
Notice that we first save the changes of the main managed object context. This is important because we need to make sure the private managed object context includes the changes of its child managed object context.
For this reason, we use performAndWait(_:)
instead of perform(_:)
. The latter asynchronously executes the block we hand it, but that isn't what we want. We first want to make sure the changes of the main managed object context are pushed to the private managed object context before pushing the changes of the private managed object context to the persistent store coordinator. To save the changes of the private managed object context, it's fine to use perform(_:)
.
Because the save()
method is a throwing method, we wrap it in a do-catch
block. Also note that we only invoke the save()
method if the managed object context has any changes. We don't want to waste resources if there are no changes to save.
It's up to you when and how frequently you invoke the saveChanges()
method. As an example, I've updated the AppDelegate
class to invoke saveChanges()
when the application is pushed to the background and when it's about to be terminated.
// MARK: - Application Life Cycle
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
print(coreDataManager.mainManagedObjectContext)
return true
}
func applicationWillResignActive(_ application: UIApplication) {
coreDataManager.saveChanges()
}
func applicationWillTerminate(_ application: UIApplication) {
coreDataManager.saveChanges()
}
To further optimize the implementation, you could start a background task to make sure Core Data is given enough time by the system to push the changes to the persistent store(s), because such an operation can take up a non-trival amount of time.
What's Next?
By making use of two managed object contexts, we've built a robust foundation which we can expand upon. In the next episode, we take a closer look at concurrency, a key aspect of working with Core Data.