Articles 📜, notes 📒, experiments 🤯 about iOS Development

Sharing Core Data Storage with App Extensions

Core data is a great API for leveraging local storage requirements, supports objective-oriented declarations, and provides neat functionalities. In a modern application, we might need to have some app extensions such as widget extension or share extension. For these extensions, we may need to reach our local storage from the original app target. For instance, you may want to show local storage-related data on your widgets, in that case, you should be able to get the data from the original app target. Let's quickly go over the steps to share our single storage with app extensions.

Creating App Group

An App Group allows us to have shared containers for multiple applications and extensions. So, we can create a group to share data between our apps, extensions, or app clip. For our problem, we need an app group container to store our database, then we can reach this single database from our main app target and our extension. To be able to create an app group, visit apple developer website and create a new identifier for an app group, such as: group.com.myApp.

Alt

You can see the created app group identifier by filtering identifiers at the top right corner.

Alt

Adding App Groups Capability

In this step, we will add a new capability for app groups. In the main project directory, select your target and tap plus button under Signing & Capabilities section. Then filter for App Groups:

Alt

Then, add your app group identifier. Note that you also need to add App Groups capability and define group identifier for the target extensions.

Alt

Xcode will automatically enable App Group capability for your target's bundle identifier. Visit apple developer website. Find your bundle identifier under identifiers (from Certificates, Identifiers & Profiles) and tap it to see details

Alt

Using Shared Container for Core Data Storage

Core data has a default place to store sqlite file. To be able to see this directory, add core data debug, -com.apple.CoreData.SQLDebug 1, argument to your scheme. Thanks to that, you will be seeing a directory of sqlite file in the logs when you run the app.

Alt

To be able to use our app group's container to store our database, we need to define the directory of the container to save our database into.

final class PersistenceManager {    
    static let shared = PersistenceManager()
    let container: NSPersistentContainer
    
    private init() {
        container = NSPersistentContainer(name: "db")
     
        // Put your own model name and app group identifier here.        
        let appGroupStoreURL = FileManager.storeURL(
            for: "group.com.myApp",
            databaseName: "db"
        )
        let storeDescription = NSPersistentStoreDescription(url: appGroupStoreURL)
        container.persistentStoreDescriptions = [storeDescription]
        ...
    }
}
extension FileManager {
    static func storeURL(for appGroup: String, databaseName: String) -> URL {
        guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
            fatalError("Container couldn't be created, please check your configurations.")
        }

        return fileContainer.appendingPathComponent("\(databaseName).sqlite")
    }
}

That's it! Amazingly, that we can create a shared database with a few lines of code.

Note that you will need a singleton object to be able to reach your persistent store.

If you get a fatal error for container URL, check your app group settings in Xcode.

Migration Old Data to New Shared Container

You will soon enough notice that when you create a container for existing local storage, you will be losing your all data. This is because the app does not automatically merge existing storage with our new container. So, we need to manually merge existing storage into our new container. But this is not always required, this is needed only if you have the existing data. We may directly move existing sqlite file using FileManager but sqlite file does not consist of a single file, we may have potential losses in that case. So, we will use NSPersistentStoreCoordinator to handle this migration.

First of all, let's create a new class to avoid boilerplate code to handle this migration process:

final class MigrationHelper {
    let container: NSPersistentContainer
    let appGroupStoreURL: URL

        init(container: NSPersistentContainer, appGroupStoreURL: URL) {
        self.container = container
        self.appGroupStoreURL = appGroupStoreURL
    }
}

Let's define some computed properties for our migration helper:

extension MigrationHelper {
    var currentStoreURL: URL? {
        guard let storeDescription = container.persistentStoreDescriptions.first,
              let url = storeDescription.url else {
            return nil
        }
        return url
    }
    
    var isStorageFileExistsForCurrentStore: Bool {
        guard let currentStoreURL = currentStoreURL else { return false }
        return FileManager.default.fileExists(atPath: currentStoreURL.path)
    }
    
    var isMigrationNeeded: Bool {
        guard let currentStoreURL = currentStoreURL else { return false }
        return isStorageFileExistsForCurrentStore && (currentStoreURL.absoluteString != appGroupStoreURL.absoluteString)
    }
}

currentStoreURL returns URL for current store. We will use this URL to check if there is an existing storage file already with isStorageFileExistsForCurrentStore property and finally we will decide if we need a migration process. Note that we need to check app group's container URL and current store URL to make sure they are different.

Also, note that the container has a property called persistentStoreDescriptions that describes stores loaded in the coordinator, and also contains URL for the storage.

Let's create a method to set app group's container URL for our core data stack. We will do this operation if migration is not needed because we will need an existing store for the migration process in further steps.

Debug app group's container URL and existing default store url. Notice app group's URL has extra directory /Shared in the link.

extension MigrationHelper {
    func setStoreURLIfNeeded() {
        guard !isMigrationNeeded else { return }
        let description = NSPersistentStoreDescription(url: appGroupStoreURL)
        container.persistentStoreDescriptions = [description]
    }
}

Let's update the initialization of the persistence manager with the following code block:

extension PersistenceManager {
    init() {
        // Put your own model name here.
        container = NSPersistentContainer(name: "db")

        // Put your own model name and app group identifier here.        
        let appGroupStoreURL = FileManager.storeURL(
            for: "group.com.myApp",
            databaseName: "db"
        )
        let migrationHelper = MigrationHelper(
            container: container,
            appGroupStoreURL: appGroupStoreURL
        )

        migrationHelper.setStoreURLIfNeeded()
        ...
    }
}

Now, it is time to migrate our existing storage using store coordinator which is NSPersistentStoreCoordinator type:

extension MigrationHelper {
    func migratePersistentStoreIfNeeded() {
        guard isMigrationNeeded,
              let currentStoreURL = currentStoreURL else { return }
        
        guard let oldStore = container.persistentStoreCoordinator.persistentStore(for: currentStoreURL) else {
            return
        }
        
        do {
            try container.persistentStoreCoordinator.migratePersistentStore(
                oldStore,
                to: appGroupStoreURL,
                options: nil,
                withType: NSSQLiteStoreType
            )
        } catch {
            // Handle error
        }
        
        deleteOldStore()
    }
}

Then, add a new function,deleteOldStore, to remove existing storage file. It's required because we will check if this file exists each time to figure out if migration is needed or not.

extension MigrationHelper {
    private func deleteOldStore() {
        guard let currentStoreURL = currentStoreURL else { return }
        
        let fileCoordinator = NSFileCoordinator(filePresenter: nil)
        fileCoordinator.coordinate(
            writingItemAt: currentStoreURL,
            options: .forDeleting,
            error: nil,
            byAccessor: { url in
                do {
                    try FileManager.default.removeItem(at: url)
                } catch {
                    // Handle error
                }
            }
        )
    }
}

That's it for MigrationHelper 🎉 Let's move to our persistence manager and update the initialization of core data stack for our migration operation:

extension PersistenceManager {
    init() {
        container = NSPersistentContainer(name: "db")
        
        let appGroupStoreURL = FileManager.storeURL(
            for: "group.com.myApp",
            databaseName: "db"
        )
        let migrationHelper = MigrationHelper(
            container: container,
            appGroupStoreURL: appGroupStoreURL
        )

        migrationHelper.setStoreURLIfNeeded()
            
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Error occured: \(error), \(error.userInfo)")
            }
                            
                
            migrationHelper.migratePersistentStoreIfNeeded()
        })
    }
}

Note that we need to start to migration operation after loadPersistentStores is done with core data stack setup.

Conclusion

Core data is a powerful persistency solution for iOS and has powerful migration abilities even for complex cases. Also, core data can be used in a shared way for your app extensions, clips and thanks to migration capabilities, we can migrate existing storage to a shared place.

Tagged with: