肘子的 Swift 记事本

Core Data with CloudKit: Sharing Data in the iCloud

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In this article, we will explore how to create applications that share data with multiple iCloud users using Core Data with CloudKit.

This is the last article in this series, and it will involve a lot of knowledge mentioned before. It’s best to have read the previous articles before reading this one).

Many friends may have used the iOS’s built-in shared photo album or shared notes feature. These features are implemented based on the CloudKit shared data API launched by Apple a few years ago. In WWDC 2021, Apple integrated this feature into Core Data with CloudKit, finally allowing us to create applications with the same functionality using Core Data operations with minimal use of CloudKit APIs.

As mentioned in the WWDC session Build apps that share data through CloudKit and Core Data, implementing shared data functionality is far more complex than syncing private and public databases. Although Apple has provided many new APIs to simplify this operation, fully implementing this functionality in an application still presents significant challenges.

Basics

This section mainly introduces the sharing mechanism under Core Data with CloudKit, which differs in some ways from native CloudKit sharing.

Owners and Participants

In each shared data relationship, there is an owner (owner) and several participants (participant). Both owners and participants must be iCloud users and can only operate on Apple devices that have logged into a valid iCloud account.

The owner initiates the sharing and sends a sharing link to the participants. After participants click on the sharing link, their device will automatically open the corresponding app and import the shared data.

Owners can specify specific participants, or set the share to be accessible by anyone who clicks on the sharing link. These two options are mutually exclusive and can be switched; when switching from specific participants to anyone, the system will delete all specific participant information.

Owners can set data operation permissions for participants, read-only or read-write, and these permissions can be modified later.

CKShare

CKShare is a special record type for managing shared record collections. It includes information about the root record or custom area to be shared, as well as information about the owner and participants in this sharing relationship.

In Core Data with CloudKit mode, the process of the owner setting a managed object instance (NSManagedObject) as shared is actually the creation of a CKShare instance.

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

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

All data corresponding to the managed object relationship (relationship) will automatically be shared.

Any modifications to the managed objects after sharing will automatically sync to the devices of both the owner and participants. Under the current Core Data with CloudKit mechanism, we cannot add top-level managed objects (like note in the above code) after sharing.

Cloud Sharing Mechanism

Before WWDC 2021, CloudKit’s mechanism was to implement sharing through a rootRecord. The owner creates a CKShare for a CKRecord, sharing a single record (including its relationship data).

Swift
let user = CKRecord(recordType:"User")
let share = CKShare(rootRecord: user)

WWDC 2021 introduced a new sharing mechanism in CloudKit—sharing custom areas (Zones). The owner creates a new custom area in their private database and creates a CKShare for that area. Participants will share all the data in that area.

Swift
init(recordZoneID: CKRecordZone.ID)

This method of sharing is more suitable for applications with large data sets and complex relationships. Core Data with CloudKit data sharing uses this sharing mechanism.

In the previous synchronization of private databases, we introduced that custom areas in private databases can create CKDatabaseSubscription, and participants get timely updates of shared data changes through this subscription.

Once the owner creates a shared relationship, the system automatically creates a new custom area in the private database (com.apple.coredata.cloudkit.share.xxx-xx-xx-xx-xxx) for them and moves the shared data (including its relationship data) from the private database’s com.apple.coredata.cloudkit.zone to the newly created Zone. This process is automatically completed by NSPersistentCloudContainer.

Each sharing relationship creates a new custom area.

image-20210911110311850

Participants will see a custom area in their network shared database with the same name as the new Zone created above (as previously introduced, the shared database is a projection of other users’ private database data).

All operations by the owner on the data are carried out in their own network private database custom area, while participants perform operations in the corresponding custom area of their network shared database.

Each user may initiate or accept shares, and regardless of the role in a sharing relationship,

Local Storage Mechanism

In previous articles, we have introduced how to create multiple persistent stores using multiple NSPersistentStoreDescriptions. Similarly to the cloud side, on the user’s device, sharing data through Core Data with CloudKit also requires creating two local SQLite databases. These two databases correspond to the cloud’s private and shared databases, respectively.

From the perspective of the owner in a shared relationship, all data created by the owner is saved in the local private database. Even if the data is shared, modifications by other participants are also saved in the owner’s private database.

From the perspective of a participant, any data shared by the owner is saved in the participant’s local shared database file, even if the participant themselves adds or modifies the data.

The above behavior is completely consistent with the logic on the cloud side.

Apple has done a lot of work behind the scenes to implement the above functionality. When syncing data, NSPersistentCloudContainer needs to perform a lot of work for each piece of data, such as judging and converting between the cloud’s custom areas and local persistent storage. Therefore, in actual use, synchronization speed is slower than syncing a local database alone.

Since the network shared library is a projection of the network private library’s data, the two databases use exactly the same data model. Thus, in terms of code implementation, it’s basically accomplished with a simple Copy.

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

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

Swift
shareDescOption.databaseScope = .shared

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

The logic for data saving on the cloud side and local side is as follows:

Shared Database Diagram

Like syncing with the public database, Core Data with CloudKit saves the CKRecord corresponding to NSManagedObject in the local database file to reduce the time querying CloudKit data over the network. In cases where shared data functionality is used, the local database also saves corresponding custom areas and all CKShare information.

These measures greatly improve the efficiency of data queries, but also place higher demands on maintaining the validity of local Catch data. Apple provides some APIs to solve the freshness issue of Catch, but they are not perfect and still require developers to write a lot of additional code. Additionally, the system’s built-in UICloudSharingController still does not support Catch updates (as of Xcode 13 beta 5).

New APIs

This year, Apple made significant updates to the CloudKit API, adding Async/Await versions for all callback-based asynchronous methods. At the same time, they updated and added several methods to Core Data with CloudKit to support data sharing. As mentioned in the previous article, Apple has significantly enhanced the presence of NSPersistentCloudContainer, with most new methods being added there.

  • acceptShareInvitations

    Participants accept invitations, and this method runs in AppDelegate.

  • share

    Create CKShare for managed objects.

  • fetchShares (in:)

    Get all CKShares in persistent storage.

  • fetchShares (matching:)

    Get CKShare for specified managed objects.

  • fetchParticipants

    Get participant information in a shared relationship through CKUserIdentity.LookupInfo, such as searching by email or phone number.

  • persistUpdatedShare

    Update CKShare in local Catch. After developers modify CKShare through code, the CKShare updated over the network should be persisted in the local Catch. The current UICloudSharingController lacks this step, leading to bugs after stopping updates.

  • purgeObjectsAndrecordsInZone

    Delete a specified custom area and all corresponding local managed objects. In the current version (XCode 13 beta 5), after the owner stops updating, enough follow-up work is not completed. Local Catch still retains CKShare, and the managed object cannot invoke UICloudSharingController, while the cloud side data remains in the custom area created for sharing (it should be moved back to the normal custom Zone).

UICloudShareingController

IMG_1886

UICloudShareingController is a view controller provided by UIKit for adding and removing people from CloudKit shared records. With minimal code, developers can have the following functionalities:

  • Invite people to view or collaborate on shared records.

  • Set access permissions, determining who can

access shared records (only invited people or anyone with a sharing link).

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

  • Cancel access permissions for 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, for situations where CKShare has already been generated and where it hasn’t.

In SwiftUI, the constructor for situations where CKShare has not been generated is problematic when wrapped with UIViewControllerRepresentable. Therefore, it is recommended in SwiftUI to first manually generate CKShare for managed objects using code (share), and then use the other constructor for situations where CKShare has already been generated.

UICloudSharingController offers several delegate methods, where some follow-up work needs to be done after stopping sharing.

The current version (Xcode 13 beta 5) of UICloudSharingController still has bugs, and it is hoped they will be fixed soon.

Example

I have created a demo and placed it on Github. In this article, I will only explain the key points.

Project Setup

info.plist

In info.plist, add CKSharingSupported to give the application the ability to open shared links. In Xcode 13, this can be added directly in the info section.

image-20210911162206667

Signing & Capabilities

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

image-20210911162525003

Setting AppDelegate

To enable the application to accept shared invitations, we must respond to incoming shared metadata in UIApplicationDelegate. In UIKit lifeCycle mode, simply add code similar to the following in AppDelegate:

Swift
    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)")
            }
        })
    }

Use NSPersistentCloudContainer’s acceptShareInvitations method to receive CKShare.Metadata.

In SwiftUI lifeCycle mode, this response occurs in UIWindowSceneDelegate. Therefore, it needs to be relayed in AppDelegate.

Swift
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)")
            }
        })
    }
}

Core Data Stack

The setup of CoreDataStack is basically similar to the settings in the previous few articles. It is important to note that, for convenience in determining persistent storage, privatePersistentStore and sharedPersistentStore have been added at the Stack level, saving local private database persistent storage and shared database persistent storage.

Swift
        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

The local shared database is created using a copy of the private database’s description. URLs are set for both persistent stores, and for the shared description, shareDescOption.databaseScope = .shared is set.

Convenient methods have been added to the Stack to facilitate logic judgments in views.

For example:

The code below is to determine whether a managed object is shared data. To speed up judgment, it first checks if the data is saved in the local shared database and then uses fetchShares to check if CKShare has been generated.

Swift
    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
    }

The code below is to determine whether the current user is the owner of the shared data:

Swift
    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
    }

Wrapping UICloudSharingController

To learn more about using UIViewControllerRepresentable, read my other article Using UIKit Views in SwiftUI.

Wrapping UICloudShareingController is not difficult, but the following points need attention:

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

    Since UICloudShareingController’s constructor for cases without a created CKShare behaves erratically when used with UIViewControllerRepresentable, for managed objects being shared for the first time, we need to create a CKShare in code. Creating a CKShare usually takes a few seconds, which can affect the user experience. My demo also demonstrates another way to call UICloudSharingController without using UIViewControllerRepresentable.

The code for creating CKShare is as follows:

Swift
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
    }
  • Ensure the CKShare.SystemFieldKey.title metadata in CKShare has a value, otherwise sharing via email or message will not be possible. The content can be defined by yourself, as long as it clearly represents what you want to share.
Swift
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
    }
  • The lifecycle of the Coordinator should be longer than that of the UIViewControllerRepresentable.

    Since sharing operations require network activity, it usually takes several seconds to return results. UICloudSharingController will destroy itself after sending the share link. If the Coordinator is defined within UIViewControllerRepresentable, it results in an inability to call back delegate methods after returning results.

  • The delegate method itemTitle needs to return content, otherwise email sharing cannot be activated.

  • Handle the follow-up issues of stopping sharing in the delegate method cloudSharingControllerDidStopSharing.

Initiating Sharing

Before calling UICloudSharingController on a managed object, first check whether a CKShare has already been created for it. If not, create a CKShare first. Calling UICloudSharingController on a managed object that is already shared will display information about all participants in the current sharing relationship, and allows modification of the sharing method and user permissions.

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

Using Task.detached prevents thread blocking while generating CKShare.

Additionally, the demo includes another direct way to call UICloudSharingController (which is commented out). This approach offers a better user experience but is not very SwiftUI-like.

Swift
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)
    }

Checking Permissions

In the application, before modifying or deleting a managed object, always first check for operation permissions. Only enable modification features for data with read-write permissions.

Swift
   if canEdit {
         Button {
            withAnimation {
                stack.addMemo(note)
              }
         }
         label: {
             Image(systemName: "plus")
              }
   }

    func canEdit(object: NSManagedObject) -> Bool {
        return persistentContainer.canUpdateRecord(forManagedObjectWith: object.objectID)
    }

You can download the complete code from my Github.

Debugging Tips

Compared to syncing local databases and public

databases, debugging shared data is more challenging and tests the developer’s patience more.

Since it’s impossible to debug on a simulator, developers need at least two devices with different iCloud accounts.

Perhaps because it’s still in the testing stage, the response speed of shared synchronization is much slower than syncing a local private database alone. Typically, creating a piece of data locally takes dozens of seconds to sync to the cloud’s private database. After participants accept the sync invitation, it also takes time for the CKShare data to refresh on both devices.

If you feel that the data has not synced after a certain period, switch the application to the background and then back again. Sometimes, even a cold start of the application is necessary.

Additionally, some known bugs can cause unusual situations. Please read the known issues below before debugging to avoid the pitfalls I encountered during my debugging.

Known Issues

  1. When sharing, if set to allow anyone to receive, participants will not be able to access the relationship data of the managed object before sharing, and it will only appear in the participant’s application after the shared managed object is modified (or new relationship data is added). It’s unclear whether this is a bug or intentional by Apple.

  2. When sharing, if set to allow anyone to receive, try not to send the share link directly to another valid iCloud account through UICloudSharingController via message or email, as it will most likely not open the shared link, showing the share as canceled. Copying the link and then sending it through message or email can solve this problem.

  3. Preferably open the share link via message or system email (which will activate Deep link). Other means might directly access the link through a browser, making it impossible to accept the invitation.

  4. After the record owner stops a participant’s sharing permissions through UICloudSharingController, UICloudSharingController cannot refresh the modified CKShare normally, preventing UICloudSharingController from being invoked again. Since there is no corresponding delegate method, there is currently no direct solution. The normal logic should be that after modifying CKShare, the server returns a new CKShare, which is updated in the local Catch using persistUpdatedShare.

  5. After the data owner stops sharing through UICloudSharingController (stopping all sharing), UICloudSharingController will encounter a problem similar to the previous one—it does not delete the CKShare in the local Catch. This problem can currently be solved by performing a Deep Copy of the managed object for which sharing is stopped (including all relationship data) in cloudSharingControllerDidStopSharing, and then executing purgeObjectsAndRecordsInZone. If the amount of data is large, this solution will take longer to execute. Hopefully, Apple will introduce a more direct method for follow-up.

  6. After the owner cancels a participant’s sharing permissions, the participant’s CKShare refresh is incomplete. Shared data on the participant’s device may disappear (definitely after the next cold start of the application) or may not disappear. At this time, if the participant operates on the shared data, it will cause the application to crash, affecting the user experience.

  7. After a participant cancels their own sharing through UICloudSharingController, CKShare refresh is incomplete, with the same phenomenon as the previous issue. However, this problem can be solved in cloudSharingControllerDidStopSharing by deleting the managed object on the participant’s device.

Items 4, 5, and 7 can be avoided by creating your own UICloudSharingController.

I have submitted all issues and exceptions to Apple as feedback. If you encounter similar or other exceptions during debugging, I hope you can also submit feedback promptly, urging and helping Apple to correct them in time.

Conclusion

Although not yet fully mature, using Core Data with CloudKit to share data is still a surprising feature. I am full of anticipation and confidence in its performance in Health Notes 3.

Since starting this series of articles, I never expected the entire process to take so much time and effort. However, I have also benefited a lot from the process of organizing and writing, deepening my understanding of previously not well-mastered knowledge through repeated reinforcement.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways