Observing the result of saving a background managed object context with Combine
Published on: December 7, 2020I love posts where I get to put write about two of my favorite frameworks at the moment; Combine and Core Data.
When you're working with Core Data, it's common to perform save operations asynchronously using a background context. You could even perform an asynchronous save on the main managed object context.
Consider the following method that I added to an object that I wrote called StorageProvider
:
public extension StorageProvider {
func addTask(name: String, description: String,
nextDueDate: Date, frequency: Int,
frequencyType: HouseHoldTask.FrequencyType) {
persistentContainer.performBackgroundTask { context in
let task = HouseHoldTask(context: context)
task.name = name
task.taskDescription = description
task.nextDueDate = nextDueDate
task.frequency = Int64(frequency)
task.frequencyType = Int64(frequencyType.rawValue)
do {
try context.save()
} catch {
print("Something went wrong: \(error)")
context.rollback()
}
}
}
}
My StorageProvider
has a property called persistentContainer
which is an NSPersistentContainer
and it contains several useful features like this convenient method to create a new instance of a HouseHoldTask
model. The contents and details of this model are not relevant per se.
It's the asynchronous nature of this method that I want you to consider. Note that even if I use persistentContainer.viewContext.perform
, the contents of the perform
closure are not executed synchronously; addTask
returns before the save
is completed in both cases.
Now consider the following SwiftUI code:
struct AddTaskView: View {
// a bunch of properties
/// Passed in by the parent. When set to false this view is dismissed by its parent
@Binding var isPresented: Bool
let storageProvider: StorageProvider
var body: some View {
NavigationView {
Form {
// A form that's used to configure a task
}
.navigationTitle("Add Task")
.navigationBarItems(leading: Button("Cancel") {
isPresented = false
}, trailing: Button("Save") {
// This is the part I want you to focus on
storageProvider.addTask(name: taskName, description: taskDescription,
nextDueDate: firstOccurrence, frequency: frequency,
frequencyType: frequencyType)
isPresented = false
})
}
}
}
I've omitted a bunch of code in this example and I added a comment that reads This is the part I want you to focus on
for the most interesting part of this code.
When the user taps Save, I create a task and dismiss the AddTaskView
by setting its isPresented
property to false. In my code the view that presents AddTaskView
passes a binding to AddTaskView
, allowing the parent of AddTaskView
to dismiss this view when appropriate.
However, since addTask
is asynchronous, we can't respond to any errors that might occur.
If you want to prevent dismissing AddTaskView
before the task is saved you would usually use the viewContext
to save your managed object using performAndWait
. That way your code is run on the viewContext
's queue, your code will also wait for the closure passed to performAndWait
to complete. That way, you could return a Result<Void, Error>
from your addTask
method to communicate the result of your save operation to the user.
Usually, a save operation will be quite fast, and running it on the viewContext
doesn't do much harm. Of course, there are exceptions where you want your save operation to run in the background to prevent blocking the main thread. And since most save operations will probably succeed, you might even want to allow the UI to continue operating as if the save operation has already succeeded, and show an alert to the user in the (unlikely) scenario that something went wrong. Or maybe you even want to present an alert in case the save operation succeeded.
An interesting way to achieve this is through Combine. You can wrap the Core Data save operation in a Future
and use it to update a StateObject
in the main view that's responsible for presenting AddTaskView
.
I'll show you the updated addTask
method first, and then we'll work our way up to addTask
from the main view up.
Here's the adjusted addTask
method:
public extension StorageProvider {
func addTask(name: String, description: String,
nextDueDate: Date, frequency: Int,
frequencyType: HouseHoldTask.FrequencyType) -> Future<Void, Error> {
Future { promise in
self.persistentContainer.performBackgroundTask { context in
let task = HouseHoldTask(context: context)
task.name = name
task.taskDescription = description
task.nextDueDate = nextDueDate
task.frequency = Int64(frequency)
task.frequencyType = Int64(frequencyType.rawValue)
do {
try context.save()
promise(.success(()))
} catch {
print("Something went wrong: \(error)")
promise(.failure(error))
context.rollback()
}
}
}
}
}
This setup is fairly straightforward. I create a Future
and fulfill it with a success
if everything is good and Error
if something went wrong. Note that my Output
for this Future
is Void
. I'm not really interested in publishing any values when everything went okay. I'm more interested in failures.
Tip:
If you're not familiar with Combine'sFutures
, check out my post on usingFuture
in Combine.
Next, let's take a look at the main view in this scenario; TasksOverview
. This view has an Add Task button and presents the AddTaskView
:
struct TasksOverview: View {
static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
return formatter
}()
@FetchRequest(fetchRequest: HouseHoldTask.sortedByNextDueDate)
var tasks: FetchedResults<HouseHoldTask>
@State var addTaskPresented = false
// !!
@StateObject var addTaskResult = AddTaskResult()
let storageProvider: StorageProvider
var body: some View {
NavigationView {
List(tasks) { (task: HouseHoldTask) in
VStack(alignment: .leading) {
Text(task.name ?? "--")
if let dueDate = task.nextDueDate {
Text("\(dueDate, formatter: Self.dateFormatter)")
}
}
}
.listStyle(PlainListStyle())
.navigationBarItems(trailing: Button("Add new") {
addTaskPresented = true
})
.navigationBarTitle("Tasks")
.sheet(isPresented: $addTaskPresented, content: {
// !!
AddTaskView(isPresented: $addTaskPresented,
storageProvider: storageProvider,
resultObject: addTaskResult)
})
.alert(isPresented: $addTaskResult.hasError) {
// !!
Alert(title: Text("Could not save task"),
message: Text(addTaskResult.error?.localizedDescription ?? "unknown error"),
dismissButton: .default(Text("Ok")))
}
}
}
}
I added three comments in the code above to the places where you should focus your attention. First, I create an @StateObject
that holds an AddTaskResult
object. I will show you this object in a moment but it'll be used to determine if we should show an error alert and it holds information about the error that occurred.
The second comment I added shows where I initialize my AddTaskView
and you can see that I pass the addTaskResult
state object to this view.
The third and last comment shows how I present the error alert.
For posterity, here's what AddTaskResult
looks like:
class AddTaskResult: ObservableObject {
@Published var hasError = false
var error: Error?
}
It's a simple object with a simple published property that's used to determine whether an error alert should be shown.
Now all we need is a way to link together the Future
that's created in addTask
and the TasksOverview
which will show an alert if needed. This glue code is written in the AddTaskView
.
struct AddTaskView: View {
// this is all unchanged
// a new property to hold AddTaskResult
@ObservedObject var resultObject: AddTaskResult
var body: some View {
NavigationView {
Form {
// form to create a task
}
.navigationTitle("Add Task")
.navigationBarItems(leading: Button("Cancel") {
isPresented = false
}, trailing: Button("Save") {
// this is where it gets interesting
storageProvider.addTask(name: taskName, description: taskDescription,
nextDueDate: firstOccurrence, frequency: frequency,
frequencyType: frequencyType)
.map { return false }
.handleEvents(receiveCompletion: { completion in
if case let .failure(error) = completion {
self.resultObject.error = error
}
})
.replaceError(with: true)
.receive(on: DispatchQueue.main)
.assign(to: &resultObject.$hasError)
// this view is still dismissed as soon as Save is tapped
isPresented = false
})
}
}
}
In the code above the most important differences are that AddTaskView
now has a resultObject
property, and I've added some Combine operators after addTask
.
Since addTask
now returns a Future
, we can apply operators to this Future
to transform its output. First, I map the default Void
output to false
. This means that no errors occurred. Then I use a handleEvents
operator with a receiveCompletion
closure. This allows me to intercept errors and assign the intercepted error to the resultObject
's error
property so it can be used in TasksOverviewView
later.
Next, I replace any errors that may have occurred with true
which means that an error occurred. Since all UI mutations in SwiftUI must originate on the main thread I use receive(on:)
to ensure that the operator that follows it will run on the main thread.
Lastly, I use Combine's assign(to:)
subscriber to assign the transformed output (a Bool
) of the Future
to &resultObject.$hasError
. This will modify the TasksOverview
's @StateObject
and trigger my alert to be shown if hasError
was set to true
.
Because I use an object that is owned by TasksOverview
in my assign(to:)
the subscription to my Future
is kept alive even after AddTaskView
is dismissed. Pretty neat, right?
In Summary
In this post, you saw an example of how you can wrap an asynchronous operation, like saving a background managed object context, in a Combine Future
. You saw how you can use @StateObject
in SwiftUI to determine if an when an error should be presented, and you saw how you can wire everything up so a Core Data save operation ultimately mutates a property on your state object to present an alert.
Complex data flows like these are a lot of fun to play with, and Combine is an incredibly useful tool when you're dealing with situations like the one I described in this article.
If you have any questions about this article, or if you have any feedback for me, don't hestitate to send me a message on Twitter.