Saving A New Object With Core Data L5.4.4

Using The Core Data Stack To Save

Once a new object is created it is not necessarily saved to disk, most likely it will be saved to volatile memory so with each restart of the app the changes will be lost.

Assuming we have a struct like the one below making use of the Core Data stack we will need to add an explicit method to persist and changes made.

import CoreData

// MARK: - CoreDataStack

struct CoreDataStack {

    // MARK: Properties

    private let model: NSManagedObjectModel
    internal let coordinator: NSPersistentStoreCoordinator
    private let modelURL: URL
    internal let dbURL: URL
    let context: NSManagedObjectContext

    // MARK: Initializers

    init?(modelName: String) {

        // Assumes the model is in the main bundle
        guard let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd") else {
            print("Unable to find \(modelName)in the main bundle")
            return nil
        }
        self.modelURL = modelURL

        // Try to create the model from the URL
        guard let model = NSManagedObjectModel(contentsOf: modelURL) else {
            print("unable to create a model from \(modelURL)")
            return nil
        }
        self.model = model

        // Create the store coordinator
        coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)

        // create a context and add connect it to the coordinator
        context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
        context.persistentStoreCoordinator = coordinator

        // Add a SQLite store located in the documents folder
        let fm = FileManager.default

        guard let docUrl = fm.urls(for: .documentDirectory, in: .userDomainMask).first else {
            print("Unable to reach the documents folder")
            return nil
        }

        self.dbURL = docUrl.appendingPathComponent("model.sqlite")

        // Options for migration
        let options = [NSInferMappingModelAutomaticallyOption: true,NSMigratePersistentStoresAutomaticallyOption: true]

        do {
            try addStoreCoordinator(NSSQLiteStoreType, configuration: nil, storeURL: dbURL, options: options as [NSObject : AnyObject]?)
        } catch {
            print("unable to add store at \(dbURL)")
        }
    }

    // MARK: Utils

    func addStoreCoordinator(_ storeType: String, configuration: String?, storeURL: URL, options : [NSObject:AnyObject]?) throws {
        try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: dbURL, options: nil)
    }
}

// MARK: - CoreDataStack (Removing Data)

internal extension CoreDataStack  {

    func dropAllData() throws {
        // delete all the objects in the db. This won't delete the files, it will
        // just leave empty tables.
        try coordinator.destroyPersistentStore(at: dbURL, ofType:NSSQLiteStoreType , options: nil)
        try addStoreCoordinator(NSSQLiteStoreType, configuration: nil, storeURL: dbURL, options: nil)
    }
}

We will add the save method in an extension. Notice that saveContext is a wrapper function for context.save() which is doing the real "work" of saving new objects.

context.save() is a function of the class NSManagedObjectContext and attempts to commit any unsaved changes on registered objects to the receiver's parent store. In our case this app only has one store so that's where new objects will be committed.

extension CoreDataStack {

    func saveContext() throws {
        if context.hasChanges {
            try context.save()
        }
    }
}

What Happens On context.save()

Every time this method is called three things happen.

  1. All properties and relationships are validated.
  2. All new and modified objects are saved to disk.
  3. All deleted objects are removed from the database.

When And Where To Save

So we know that we need to call context.save() to save our changes but we need to know where and when to call it.

These three steps depending on the app and database size could take a human perceivable amount of time, because of this it's important to carefully choose when and where to call this function.


Calling Save In The Foreground

If we call it in the foreground after every change the user makes the app will become sluggish and possibly time out, but if we call it too seldom we risk the user potentially losing data.

We look to the methods in the AppDelegate for some good times to save. The following two methods are called at times that present a good opportunity for the app to save it's data.

Good

  • applicationWillResignActive - this happens when the app is still in the background but is no longer active. Not being active means that user input such as taps don't reach the app any more. One example of a time this method would be called is when a user is using the app and they get a phone call.

  • applicationDidEnterBackground - this means that the app has been sent to the background because the user has switched to use a different app.

Bad

  • applicationWillTerminate is called when the either the user or iOS decides to kill the app. It might seem like it's a good place to save data but it's not. Once this method is called there isn't much time to execute a save and could fail without warning.

  • application(didFinishLaunchingWithOptions:), applicationWillEnterForeground and applicationDidBecomeActive are all called when the app is being launched so there won't be any new data to save.


Auto Save (Calling Save In The Background)

Instead of relying on the app's method it calls when it's about to be closed to save data, a stronger approach is to implement a time delayed "auto save" method which will save data every few seconds.

Notice in our saveContext wrapper method that context.save() is not necessarily called when some object executes saveContext, first it checks the context to see if there is anything that has changed.

func saveContext() throws {  
    if context.hasChanges {
        try context.save()
    }
}

We want to make use of that logic in a separate method that will call our wrapper method on a time delay. To create the delay we use DispatchQueue.main.asyncAfter(deadline:) which takes a closure and runs it after a certain amount of milliseconds. If we call our new method autoSave inside that closure we have what we're looking for - a loop that saves, waits for a certain amount of time and then saves again.

It's important to note that we are not really saving for each pass of our autoSave loop but rather only when changes to the context have been made thanks to our if try logic in saveContext.

func autoSave(_ delayInSeconds : Int) {

    if delayInSeconds > 0 {
        do {
            try saveContext()
            print("Autosaving")
        } catch {
            print("Error while autosaving")
        }

        let delayInNanoSeconds = UInt64(delayInSeconds) * NSEC_PER_SEC
        let time = DispatchTime.now() + Double(Int64(delayInNanoSeconds)) / Double(NSEC_PER_SEC)

        DispatchQueue.main.asyncAfter(deadline: time) {
            self.autoSave(delayInSeconds)
        }
    }
}

While our new auto save functionality makes some improvements to the timing of when to save data rather than solely relying on the app to close to persist data we do have some issues to watch out for.

Remembering that when the app executes a save is when what is being saved is validated against the data model means that if object that the app is trying to save doesn't have all attributes the model says it should or it doesn't fit what the data model says it should be, the app will crash.

One of the most common places for an issue to cause an app crash of this nature to arise is during deletion of an object - specifically in the delete rule Cascade. This implies that when an object is deleted that all of the objects that are associated with it through relationships such as one-to-one or one-to-many will also be deleted.

In our case if we delete a Notebook we want all the associated notes to also be deleted.

cascade rule on notebooks

If we look at the inverse relationship, the notebook relationship of a note. The delete rule is set to Nullify. This means that when a Note is deleted all the references to that note are removed - essentially the notebook that it's associated with just forgets about it.

nullify rule for note

If by mistake we set the Note delete rule to Cascade, and delete a Notebook. Core Data will try to delete the notebook and the references to all it's notes. But as Core Data tries to delete the notes it will also try to delete all of it's references which include the notebook that has already been deleted and the result will be an app crash.