Observing the result of saving a background managed object context with Combine

Published on: December 7, 2020

I 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's Futures, check out my post on using Future 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.

Categories

Combine Core Data

Subscribe to my newsletter