Migrating a Core Data store to an App Group shared container

Sponsored

Codemagic logo
Codemagic CI/CD for mobile teams

What do you get when you put love for iOS and DevOps together? Answer: Codemagic CI/CD

If you have a Core Data store set up for your iOS app in its default location, you must know that widgets won’t be able to access it.

The reason for this is that widgets run in a separate process, and they can’t access the app’s directories, where Core Data stores are created by default.

Thankfully, there is a way to set up a shared location where we can move the store to and that both the app and the widget can access by using an App Group.

Creating an app group

Creating an app group is straightforward and can be done directly in Xcode. To do so, go to your target’s Signing & Capabilities section and press on the + Capability button.

Xcode's target settings focused in the signing and capabilities section

Then, search for App Groups and press on double-click on the result to add it to your target.

Highlighting App Groups from the list of capabilities

Finally, enter a name and create the App Group. If everything has gone well, you should see the App Group in the list of capabilities for the target:

A list of target capabilities showing AppGroup as one of them

Note that container IDs must be prefixed with group. and then be followed by a custom string in reverse DNS notation. If you’d like to find out more, please check out Apple’s documentation on the topic.

Creating a store in the App Group folder

Now that you have created an App Group, you can create a store in the group’s shared container with just a few lines of code:

CoreDataManager.swift
final class CoreDataManager {
    let container: NSPersistentContainer

    init?(inMemory: Bool = false) {
        let storageName = "MigrationDemo"
        let container = NSPersistentContainer(name: storageName)
        // 1
        guard let storeLocation = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.dev.polpiella.MigrationDemo")?.appendingPathComponent("\(storageName).sqlite") else {
            return nil
        }
        // 2
        let description = NSPersistentStoreDescription(url: storeLocation)
        // 3
        container.persistentStoreDescriptions = [description]

        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                print("Unresolved error \(error), \(error.userInfo)")
            }
        })

        self.container = container
    }
}

The code above does the following to create a store in the App Group:

  1. Retrieve the URL of the app group folder by using the FileManager’s containerURL(forSecurityApplicationGroupIdentifier:) method.
  2. Create an NSPersistentStoreDescription object and give it the App Group folder URL you have just retrieved. This will override the default store location and will allow the widgets to access persistent data.
  3. Set the NSPersistentStoreDescription you have just created in the container.

I would like to give credit to this awesome video by Flo Writes Code that helped me figure out what to do here.

Play: Video

Migrating a current store to the app group folder

In the previous section I showed you how you can create a Core Data store in a shared App Group container from scratch but, let’s say you already have a store in a different location and you replace your code with the one above, what will happen?

The answer is that you will lose all your data as a new store will be created in a different location and no migration will happen out of the box.

Thankfully, there is a way to migrate the data from the current store to new store but it requires a bit more code:

CoreDataManager.swift
final class CoreDataManager {
    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        let storageName = "MigrationDemo"
        let container = NSPersistentContainer(name: storageName)
        let fileManager = FileManager.default

        // 1
        guard let sharedStoreLocation = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.dev.polpiella.MigrationDemo")?.appendingPathComponent("\(storageName).sqlite"),
            let currentStoreLocation = container.persistentStoreDescriptions.first?.url else {
            fatalError("Expected both locations to exist...")
        }

        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
        } else {
            // 2
            if fileManager.fileExists(atPath: currentStoreLocation.path) && !fileManager.fileExists(atPath: sharedStoreLocation.path) {
                let coordinator = container.persistentStoreCoordinator
                do {
                    // 3
                    try coordinator.replacePersistentStore(at: sharedStoreLocation, destinationOptions: nil, withPersistentStoreFrom: currentStoreLocation, sourceOptions: nil, type: .sqlite)
                    // 4
                    try? coordinator.destroyPersistentStore(at: sharedStoreLocation, type: .sqlite, options: nil)
                } catch {
                    print("\(error.localizedDescription)")
                }
            } else {
                // 5
                let description = NSPersistentStoreDescription(url: sharedStoreLocation)
                container.persistentStoreDescriptions = [description]
            }
        }

        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                print("Unresolved error \(error), \(error.userInfo)")
            }
        })

        self.container = container
    }
}

Note that I was able to get to this solution thanks to this brilliant answer in Stack Overflow on how to migrate a store and this other equally as brilliant answer pointing out that files are not deleted when using the destroyPersistentStore method.

The extra code does the following to achieve the migration:

  1. Get the URLs for both the current Core Data store and the one in the shared container.
  2. Use FileManager to check if the current store exists at the default location and if the store in the App Group folder doesn’t exist. If this is the case, a migration needs to happen to move the data from the current store to the one in the shared container. By default and if not specified otherwise, the store will always be created by CoreData in the Application Support directory.
  3. Call the replacePersistentStore method on the persistentStoreCoordinator to replace the current store with the one in the App Group folder.
  4. Call the destroyPersistentStore method on the persistentStoreCoordinator to delete the store that was created in the Application Support directory. Contrary to what you might think, this method does not delete the underlying database files so you will need to do this manually.
  5. If the new App Group store exists, it means that a migration isn’t needed and the NSPersistentStoreDescription object with the App Group URL can be set up. This step is crucial as otherwise Core Data will create a new store in the Application Support directory.

Next time you run the app you will see that all your existing data has been migrated correctly to the App Group shared container and that your widgets can access it! 🎉

💡 Pro tip: If you’d like to make sure that the migration has happened and the old store is empty, you can open the .sqlite database files in a tool like Core Data Lab by Betamagic.