This article originally appeared on my blog [Elbow’s Swift Notepad]

In this article, we’ll explore how to use Core Data with CloudKit to create applications that share Data with multiple iCloud users.

This is the final article in this series, and it covers a lot of the information mentioned earlier, so you should have read the previous article before you read it.

I believe there should be a lot of friends have used iOS built-in share album or share memo function. These features are based on the CloudKit Shared Data API that Apple introduced a few years ago. With WWDC 2021, Apple has integrated this functionality into Core Data with CloudKit, and we can finally create applications with the same functionality as Core Data with a few CloudKit apis.

As mentioned in WWDC Session Build Apps that Share Data through CloudKit and Core Data, the implementation of shared data is much more complex than synchronizing private and public databases. Although Apple has provided a number of new apis to simplify this operation, it is still a challenge to fully implement this functionality in an application.

basis

This section mainly describes the sharing mechanism under Core Data with CloudKit. In some places, the sharing mechanism is different from the native CloudKit sharing.

Owners and participants

In each shared data relationship, there is one owner and several participants. Both the owner and the participant must be an iCloud user and can only operate on an Apple device that is already logged into a valid iCloud account.

The owner initiates the share and sends the share link to the participant. After participants click the share link, the device automatically opens the corresponding APP and imports the share data.

The owner can specify specific participants or make the share accessible to anyone who clicks on the share link. The two conditions are mutually exclusive and can be switched. When you switch from a specified participant to any other participant, the system deletes all the information about the participant.

The owner can set data manipulation permissions for participants, read-only or read-write, and permissions can be changed later.

CKShare

CKShare is a dedicated record type that manages a collection of shared records. Contains information about root records or custom regions that need to be shared as well as information about owners and participants in the shared relationship.

In Core Data with CloudKit mode, the owner creates a CKShare instance for the managed object instance (NS-Managed Object) by making it shared.

let (ids, share, ckContainer) = try await stack.persistentContainer.share([note1,note2], to: nil)
Copy the code

We can share multiple managed objects at once in a shared relationship.

All data for managed object relationships is automatically shared.

Any changes made to the shared managed object are automatically synchronized to the owner’s and participant’s devices. Under the current Core Data with CloudKit mechanism, we cannot add the topmost managed object (such as note in the code above) after sharing.

Cloud sharing mechanism

Prior to WWDC 2021, CloudKit’s mechanism was to implement sharing through a rootRecord, with the owner creating A CKShare for a CKRecord, enabling a single record (including its relational data) to be shared.

let user = CKRecord(recordType:"User")
let share = CKShare(rootRecord: user)
Copy the code

In WWDC 2021, CloudKit provides a new sharing mechanism — sharing custom zones. The owner creates a new custom zone in his private database and creates CKShare for the zone. Participants will share all data in that area.

init(recordZoneID: CKRecordZone.ID)
Copy the code

This sharing mode is more suitable for application scenarios with large data sets and complex relationships. The Data sharing mechanism of Core Data with CloudKit is adopted.

As we described earlier in synchronous private databases, CKDatabaseSubscription can be created in a custom zone of a private database and formally used by participants to receive timely changes to shared data.

After the owners to create a Shared relationship, the system will automatically in private for the database to create a new custom area (com. Apple. Coredata. Cloudkit. Share. – – – xx xx xx – XXX XXX). And will share data (including the relational data) from a private database com. Apple. Coredata. Cloudkit. Zone move into the new zone. The process for NSPersistentCloudContainer automatically.

Each shared relationship creates a new custom region.

The participant will see a custom Zone with the same name as the newly created Zone in his network shared database (which, as described in the previous article, is a projection of data from other users’ private databases).

The owner operates on the data in the custom zone of his network private database, while the participant operates in the custom zone of his network shared database.

Each consumer may initiate or accept a share, and the logic for storing data remains the same regardless of the user’s role in a shared relationship.

Local storage mechanism

In the previous article, we have introduced how to create multiple through multiple NSPersistentStoreDescription persistent storage. Similar to the network side, sharing Data through Core Data with CloudKit on the user’s device side also requires the creation of two local Sqlite databases. The two databases correspond to the private and shared databases on the network side respectively.

From the perspective of the owner in the shared relationship, all data created by the owner is stored locally in a private database. Even if the data is shared, changes made to the data by other participants are kept in the owner’s private database.

From the perspective of the data participant, any data shared by the owner is stored in the local shared database file of the participant, even if it is added or modified by the participant himself, it is also stored in the local shared database file.

The above behavior is exactly the same as the network logic.

Apple has done a lot of work behind the scenes to achieve the above features. NSPersistentCloudContainer in synchronous data, the need for each of the data network and local custom area of persistent storage, conversion, etc. A lot of work. So in practice, synchronization is slower than simply synchronizing a local database.

Because the network shared library is a projection of the network private library data, the data model used by the two databases is exactly the same. So in code implementation, basically is to use a simple Copy to complete.

guard let shareDesc = privateDesc.copy() as? NSPersistentStoreDescription else {
            fatalError("Create shareDesc error")}Copy the code

Apple added the databasScope property to cloudKitContainerOptions last year to support private and public, and this year added the shared option to support shared data types.

shareDescOption.databaseScope = .shared
Copy the code

Since all shared data requires corresponding CKRecord information, the local private database must also support network synchronization.

The data storage logic on the network and local ends is as follows:

Like synchronizing public databases, Core Data with CloudKit saves ckRecords corresponding to NS-managed Object in local database files in order to shorten the time of querying CloudKit Data through the network. In the case of using shared Data function, The corresponding custom zone and all CKShare information are also stored locally.

The above measures not only greatly improve the efficiency of data query, but also put forward higher requirements for maintaining the effectiveness of local Catch data. Apple provides a partial API to address the Catch freshness issue, but it’s not perfect and requires developers to write a lot of extra code. In addition, the built-in UICloudSharingController still does not support the Catch update (Xcode 13 Beta 5).

The new API

Apple made a major update to the CloudKit API this year, adding Async/Await versions to all callback asynchronous methods. At the same time, Core Data with CloudKit was updated and a number of methods were added to support Data sharing. In the previous article, we have already mentioned, apple has been a sharp increase in the presence of NSPersistentCloudContainer, the newly added method, mostly increase in NSPersistentCloudContainer.

  • acceptShareInvitations

    The participant accepts the invitation, and this method runs in an AppDelegate

  • share

    Create CKShare for managed objects

  • fetchShares(in:)

    Get all CKShares in the persistent store

  • fetchShares(matching:)

    Gets CKShare for the specified managed object

  • fetchParticipants

    Through CKUserIdentity. LookupInfo Participant in a Shared relationship information. Such as by email or phone number

  • persistUpdatedShare

    Update CKShare in local Catch. After modifying CKShare by code, the developer should persist the network updated CKShare to the local Catch. The current UICloudSharingController lacks this step, resulting in bugs after stopping updating.

  • purgeObjectsAndrecordsInZone

    Deletes the specified custom region and all managed objects corresponding to the local region. In the current version (XCode 13 Beta 5), not enough cleanup was done after the owner stopped updating. As a result, CKShare is still stored in the local Catch, the managed object cannot evoke UICloudSharingController, and the data on the network side is still stored in the custom Zone created for the share (it should be moved back to the normal custom Zone).

UICloudShareingController

UICloudShareingController is UIKit provides a used to add and remove personnel from CloudKit Shared view controller. With a small amount of code, developers can have the following features:

  • Invite people to view or collaborate on shared records

  • Set up access to determine who can access the shared record (only people who are invited or anyone with a shared link).

  • Set general or individual permissions (read only or read/write).

  • Revoking the access rights of one or more participants

  • Stop participating if the user is a participant.

  • Stop sharing with all participants if the user is the owner of the shared record.

UICloudSharingController provides two constructors, one for cases where CKShare has been generated and the other for cases where CKShare has not been generated.

Under SwiftUI for has not yet been generated CKShare circumstance of constructor when using UIViewControllerRepresentable packaging is unusual, therefore, recommended the first to use the code under SwiftUI manually generated CKShare for managed objects (share), Then use another constructor for the generated CKShare.

The UICloudSharingController provides several delegate methods in which we need to do some cleanup after stopping sharing.

The current version of UICloudSharingController (Xcode 13 Beta 5) still has a Bug. Hope to fix it soon.

The instance

I wrote a Demo and put it on Github, which I’ll focus on in this article.

Project Settings

info.plist

Add CKSharingSupported to info.plist to add the ability to open shared links to your application. Xcode 13 can be added directly to info.

Signing&Capablilities

As with synchronizing local data, add the corresponding features (iCloud, background) in Signing&Capabilities, and add CKContainer.

Set the AppDelegate

In order for the application to accept the share invitation, we must respond to the incoming share metadata in the UIApplicationDelegate. In UIKit lifeCycle mode, just add code in the AppDelegate like this:

    func application(_ application: UIApplication.userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
        let shareStore = CoreDataStack.shared.sharedPersistentStore
        let persistentContainer = CoreDataStack.shared.persistentContainer
        persistentContainer.acceptShareInvitations(from: [cloudKitShareMetadata], into: shareStore, completion: { metas,error in
            if let error = error {
                print("accepteShareInvitation error :\(error)")}}}Copy the code

Using NSPersistentCloudContainer acceptShareInvitations method receives CKShare. Metadata.

In SwiftUI lifeCycle mode, this response occurs in UIWindowSceneDelegate. So you need to redirect in the AppDelegate.

final class AppDelegate:NSObject.UIApplicationDelegate{
    func application(_ application: UIApplication.configurationForConnecting connectingSceneSession: UISceneSession.options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        sceneConfig.delegateClass = SceneDelegate.self
        return sceneConfig
    }
}

final class SceneDelegate:NSObject.UIWindowSceneDelegate{
    func windowScene(_ windowScene: UIWindowScene.userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
        let shareStore = CoreDataStack.shared.sharedPersistentStore
        let persistentContainer = CoreDataStack.shared.persistentContainer
        persistentContainer.acceptShareInvitations(from: [cloudKitShareMetadata], into: shareStore, completion: { metas,error in
            if let error = error {
                print("accepteShareInvitation error :\(error)")}})}}Copy the code

Core Data Stack

The CoreDataStack setup is basically the same as in the previous articles, but note that private And sharedPersistentStore have been added to the Stack layer to make it easier to determine persistent storage. Saves local private database persistent storage and shared database persistent storage.

        let dbURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!

        let privateDesc = NSPersistentStoreDescription(url: dbURL.appendingPathComponent("model.sqlite"))
        privateDesc.configuration = "Private"
        privateDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: ckContainerID)
        privateDesc.cloudKitContainerOptions?.databaseScope = .private

        guard let shareDesc = privateDesc.copy() as? NSPersistentStoreDescription else {
            fatalError("Create shareDesc error")
        }
        shareDesc.url = dbURL.appendingPathComponent("share.sqlite")
        let shareDescOption = NSPersistentCloudKitContainerOptions(containerIdentifier: ckContainerID)
        shareDescOption.databaseScope = .shared
        shareDesc.cloudKitContainerOptions = shareDescOption
Copy the code

Local shared databases are created using a Description Copy of the private database. Two persistent storage set URL, respectively, and to share the Description Settings shareDescOption. DatabaseScope =. Shared

Added convenience methods to Stack to facilitate logical judgment in the view.

Such as:

The following code determines whether a managed managed object is shared data. To speed up the determination, first determine whether the data is stored in a local shared database, and then use fetchShares to check whether CKShares have been generated.

    func isShared(objectID: NSManagedObjectID) -> Bool {
        var isShared = false
        if let persistentStore = objectID.persistentStore {
            if persistentStore = = sharedPersistentStore {
                isShared = true
            } else {
                let container = persistentContainer
                do {
                    let shares = try container.fetchShares(matching: [objectID])
                    if shares.first ! = nil {
                        isShared = true}}catch {
                    print("Failed to fetch share for \(objectID): \(error)")}}}return isShared
    }
Copy the code

The following code determines whether the current user is the owner of the shared data:

    func isOwner(object: NSManagedObject) -> Bool {
        guard isShared(object: object) else { return false }
        guard let share = try? persistentContainer.fetchShares(matching: [object.objectID])[object.objectID] else {
            print("Get ckshare error")
            return false
        }
        if let currentUser = share.currentUserParticipant, currentUser = = share.owner {
            return true
        }
        return false
    }

Copy the code

Packaging UICloudSharingController

Want to learn more about UIViewControllerRepresentable method of use, please read my another article used in SwiftUI UIKit views.

The UICloudShareingController packaging is not difficult, but need to pay attention to the following:

  • Ensure that the managed object to be shared has created CKShare.

    Because UICloudShareingController aimed at creating CKShare constructor which no performance after UIViewControllerRepresentable exception, for the first time Shared hosting object, we need to create CKShare in the code for its first. Creating CKShare usually takes a few seconds, which has an impact on the user experience. I also in the Demo shows another UIViewControllerRepresentable call UICloudSharingController ways.

The code to create CKShare is as follows:

func getShare(_ note: Note) -> CKShare? {
        guard isShared(object: note) else { return nil }
        guard let share = try? persistentContainer.fetchShares(matching: [note.objectID])[note.objectID] else {
            print("Get ckshare error")
            return nil
        }
        share[CKShare.SystemFieldKey.title] = note.name
        return share
    }
Copy the code
  • Need to ensure CKShareCKShare.SystemFieldKey.titleMetadata has a value, otherwise it cannot be shared by mail, messages, etc. Content can be self-defined, and it should be clear what you want to share
func makeUIViewController(context: Context) -> UICloudSharingController {
        share[CKShare.SystemFieldKey.title] = note.name
        let controller = UICloudSharingController(share: share, container: container)
        controller.modalPresentationStyle = .formSheet
        controller.delegate = context.coordinator
        context.coordinator.note = note
        return controller
    }
Copy the code
  • The Coordinator of the life cycle than UIViewControllerRepresentable.

    Because a share operation requires a network operation, it usually takes several seconds to return results. UICloudSharingController after sending Shared links that will be destroyed, if the Coordinator is defined in UIViewControllerRepresentable, can lead to return to the results, not commissioned by the callback methods.

  • The delegate method itemTitle needs to return the content, otherwise the mail share will not wake up

  • In the delegate method cloudSharingControllerDidStopSharing help address the stop sharing problem

A Shared

Before calling UICloudSharingController on the managed object, you need to check whether CKShare has been created for it. If not, you need to create CKShare first. Call UICloudSharingController on the managed object that has been shared. The view will display information about all participants of the current sharing relationship, and you can modify the sharing mode and user permissions.

        if isShared {
              showShareController = true
          } else {
              Task.detached {
                 await createShare(note)
                      }
          }
Copy the code

Use task.detached to avoid thread blocking when generating CKShare.

In addition, there is a way to call UICloudSharingController directly in the Demo (commented out), which gives a better user experience but is not very SwiftUI.

private func openSharingController(note: Note) {
        let keyWindow = UIApplication.shared.connectedScenes
            .filter { $0.activationState = = .foregroundActive }
            .map { $0 as? UIWindowScene }
            .compactMap { $0 }
            .first?.windows
            .filter { $0.isKeyWindow }.first

        let sharingController = UICloudSharingController{(_, completion: @escaping (CKShare? .CKContainer? .Error?). ->Void) in

            stack.persistentContainer.share([note], to: nil) { _, share, container, error in
                if let actualShare = share {
                    note.managedObjectContext?.performAndWait {
                        actualShare[CKShare.SystemFieldKey.title] = note.name
                    }
                }
                completion(share, container, error)
            }
        }

        keyWindow?.rootViewController?.present(sharingController, animated: true)}Copy the code

Check the permissions

Before modifying or deleting a managed object in an application program, check the operation permissions. Enable the modification function only for data that has read and write permission.

   if canEdit {
         Button {
            withAnimation {
                stack.addMemo(note)
              }
         }
         label: {
             Image(systemName: "plus")}}func canEdit(object: NSManagedObject) -> Bool {
        return persistentContainer.canUpdateRecord(forManagedObjectWith: object.objectID)
    }
Copy the code

You can download the entire code on my Github.

Debugging information

Debugging shared data is harder and more mind-testing than synchronizing local and public databases.

Since you can’t debug on the emulator, you need to have at least two devices with different iCloud accounts.

Probably still in the testing phase, shared synchronization is much slower than simply synchronizing a local private database. Typically, creating a piece of data locally takes tens of seconds to synchronize to a private database in the cloud. After a participant receives a synchronization invitation, the CKShare data on both devices is refreshed for a period of time.

If you feel that the data is not synchronized after a certain amount of time, switch the application to the background and back again, and sometimes even cold start the application.

In addition, some known bugs can also lead to abnormal conditions. Please read the following known problems before debugging to avoid the pits I stepped on during debugging.

Known issues

  1. When sharing, if it is set up for anyone to receive, the participant will not be able to obtain the relational data of the previously shared managed object, and it will only be displayed in the participant’s application after the shared managed object is modified (or new relational data is added). I don’t know if it’s a Bug or apple is doing this on purpose.

  2. When sharing, if it is set to receive by anyone, do not directly send it to another valid iCloud account in UICloudSharingController by message or email. Otherwise, it is highly likely that the share link cannot be opened and the sharing is cancelled. You can choose to copy the link and then send it via message or email.

  3. Try to open shared links via message or system mail (this will start Deep link). Other means may directly access the link through the browser, resulting in the failure to accept the invitation.

  4. After the record owner stops the share permission of a participant through the UICloudSharingController, the UICloudSharingController cannot refresh the modified CKShare. As a result, the UICloudSharingController cannot wake up again. Because there is no corresponding delegate method, there is currently no direct solution. The normal logic is that after modifying CKShare, the server returns a new CKShare that updates the local Catch through the persistUpdatedShare

  5. After the data owner stops sharing with the UICloudSharingController (stopping all sharing), the UICloudSharingController has a similar problem to the previous one — it does not delete the CKShare in the local Catch. This problem can be in cloudSharingControllerDidStopSharing managed objects of stop sharing Deep Copy (Deep Copy, containing all the data), And then execute purgeObjectsAndRecordsInZone solution. If there is a large amount of data, the solution takes a long time to execute. Hopefully apple will come up with a more direct way to deal with the aftermath.

  6. An actor’s CKShare refresh is incomplete after the owner revokes his share permission. The shared data on the participant’s device may or may not disappear (which it certainly will after the application’s next cold launch). If a participant performs operations on the shared data, the application crashes, affecting user experience.

  7. After participants cancel their share through UICloudSharingController, CKShare is not refreshed completely. The phenomenon is the same as the previous one. But this problem can be cloudSharingControllerDidStopSharing participants by removing devices managed objects to solve.

Among them, items 4, 5 and 7 can be avoided by creating your own Implementation of UICloudSharingController.

I have submitted feedback to Apple for all the problems and anomalies. If you have similar or other abnormal situations during debugging, I hope you can timely submit feedback, urge and help Apple to correct in time.

conclusion

Sharing Data using Core Data with CloudKit is still an amazing feature, although it’s still not fully mature. I am full of expectations and confidence for its performance in Health Note 3.

When I started this series, I had no idea that the process would take so much time and effort. However, I also benefited a lot from the sorting and writing process, and deepened my understanding of the knowledge that WAS not solid before through repeated strengthening.

I hope you found this series of articles helpful.

We also hope that more developers can understand and use Core Data & CloudKit.

This article originally appeared on my blog [Elbow’s Swift Notepad]

Welcome to subscribe to my public number: Elbow Swift Notepad

Other recommendations:

Core Data with CloudKit 1-6

How to preview SwiftUI view with Core Data elements in Xcode

www.fatbobman.com/posts/uikit…

Enhance SwiftUI’s navigation view with NavigationViewKit

@ AppStorage research