Sharing Core Data Objects Between iCloud Users
Implement the flow to share data between iCloud users using Core Data CloudKit.
Overview
More and more people own multiple devices and use them to share digital assets or collaborate work. They expect seamless data synchronization across their devices and an easy way to share data with privacy and security in mind. Apps can support such use cases by moving user data to CloudKit and implementing a data sharing flow that includes features like share management and access control.
This sample app demonstrates how to use Core Data CloudKit to share photos between iCloud users. Users who share photos, called owners, can create a share, send out an invitation, manage the permissions, and stop the sharing. Users who accept the share, called participants, can view or edit the photos, or stop participating the share.
Configure the Sample Code Project
Before building the sample app, perform the following steps in Xcode:
- In the General pane of the
CoreDataCloudKitShare
target, update the Bundle Identifier field with a new identifier. - In the Signing & Capabilities pane, select the applicable team from the Team drop-down menu to let Xcode automatically manage the provisioning profile. See Assign a project to a team for details.
- Make sure the iCloud capability is present and the CloudKit option is in a selected state, then select the iCloud container with your bundle identifier from step 1 from the Containers list. If the container doesn’t exist, click the Add button (+), enter the container name (iCloud.<bundle identifier>), and click OK to let Xcode create the container and associate it with the app.
- If you prefer using an existing container, select it from the Containers list.
- Specify your iCloud container for the
gCloudKitContainerIdentifier
variable in PersistenceController.swift. An iCloud container identifier is case-sensitive and must begin with “iCloud.
“. - Similar to step 1, change the bundle identifiers and the developer team for the WatchKit app and WatchKit Extension targets. The bundle identifiers must be
<The iOS app bundle ID>.watchkitapp
and<The iOS app bundle ID>.watchkitapp.watchkitextension
respectively. - Similar to step 2, specify the iCloud container for the WatchKit Extension target. To synchronize data across iCloud, the iOS app and WatchKit extension must share the same iCloud container.
- Open the Info.plist file of the WatchKit app target, then change the value of WKCompanionAppBundleIdentifier key to
<The iOS app bundle ID>
. - Open the Info.plist file of the WatchKit Extension target, then change the value of NSExtension > NSExtensionAttributes > WKAppBundleIdentifier key to
<The iOS app bundle ID>.watchkitapp
.
To run the sample app on a device, configure the device as follows:
- Log in with an Apple ID. For the CloudKit private database to synchronize, the Apple ID must be the same on the devices. (For an Apple Watch, log in at the Watch app on the paired iPhone, then make sure the Apple ID shows up on the Settings app on the watch.)
- For an iOS device, choose Settings > Apple ID > iCloud, and turn on iCloud Drive, if it is off.
- After running the sample app on the device, go to Settings > Notifications, and make sure “Allow Notifications” is on. For an Apple Watch, use the Watch app on the paired iPhone to make sure that notifications are on for the app.
To create and configure a new project that uses Core Data CloudKit, see Setting Up Core Data with CloudKit.
Create the CloudKit Schema for Apps
CloudKit apps must have a schema to declare the data types they use. When apps create a record in the CloudKit development environment, CloudKit automatically creates the record type if it doesn’t exist. In the production environment, CloudKit doesn’t have that capability, nor does it allow removing an existing record type or field, so after finalizing the schema, be sure to deploy it to the production environment. Without doing that, apps that work in the production environment, like the App Store or TestFlight ones, would not work. For more information, see Deploying an iCloud Container’s Schema.
Core Data CloudKit apps can use initializeCloudKitSchema(options:)
to create the CloudKit schema that matches their Core Data model, or keep it up to date every time their model changes. The method works by creating fake data for the record types and then delete it, which can take some time and blocks the other CloudKit operations. Apps must not call it in the production environment, or in the normal development process that doesn’t include model changes.
To create the CloudKit schema for this sample app, pick the “InitializeCloudKitSchema” target from Xcode’s target menu, and run it. Having a target dedicated on CloudKit schema creation separates the initializeCloudKitSchema(options:)
call from the normal flow. After running the target, be sure to check with CloudKit Console if every Core Data entity and attribute has a CloudKit counterpart. See Reading CloudKit Records for Core Data for the detailed mapping rules.
For apps that use CloudKit public database, manually add a Queryable
index for the recordName
and modifiedAt
fields of all record types, including the CDMR
type that Core Data generates to manage many-to-many relationships.
For more information on this topic, see Creating a Core Data Model for CloudKit
Try out the Sharing Flow With the Sample App
To create and share a photo using the sample app, follow these steps:
- Prepare two iOS devices, A and B, and log in with a different Apple ID.
- Use Xcode to build and run the sample app on the devices.
- On device A, tap the Add(+) button to show the photo picker, then pick a photo and add it to the Core Data store.
- Long press the photo to show the action menu, then tap the “Create New Share” button to present the CloudKit sharing UI.
- Follow the UI to send a link to the Apple ID on device B. Try to use iMessage because it’s easier to set up.
- After receiving the link on device B, tap it to accept and open the share, which launches the sample app and shows the photo.
To discover more features of the sample app:
- On device A, add another photo, long press it and tap the “Add to Existing Share” button, then pick a share and tap the “Add” button. See the photo soon appears on Device B.
- On device B, long press the photo, tap the “Manage Participation” button to present the CloudKit sharing UI, then pick the Apple ID that has “(Me)” suffix and tap “Remove Me” to remove the participation. See the photo disappears.
- Tap the “Manage Shares” button, then pick the share, and try to manage its participants using
UICloudSharingController
or the app UI.
It may take some time (minutes or longer) for one user to see the changes from the others. Core Data CloudKit is not for real-time synchronization. When users change the store on their device, it is up to the system to determine when to synchronize the change. There is no API for apps to speed up, slow down, or choose the timing for the synchronization.
Set up the Core Data Stack
Every CloudKit container has a private database and a shared database. To mirror these databases, set up a Core Data stack with two stores, and set the store’s database scope to .private
and .shared
respectively.
When setting up the store description, enable persistent history tracking and turn on remote change notifications by setting the NSPersistentHistoryTrackingKey
and NSPersistentStoreRemoteChangeNotificationPostOptionKey
options to true
. Core Data relies on the persistent history to track the store changes, and apps need to update their UI when remote changes occur.
- CodeListing: setOption
For apps (under the same developer team) to synchronize data through CloudKit, they must use the same CloudKit container. This sample app explicitly specifies the same container for its iOS and watchOS apps when setting up the CloudKit container options:
- CodeListing: NSPersistentCloudKitContainerOptions
Share a Core Data object
Sharing a Core Data object between iCloud users includes the following tasks:
- On the owner side, create a share with an appropriate permission.
- Invite participants by making the share link available to them.
- On the participant side, accept the share.
- On both sides, manage shares. Owners can stop sharing the object, change the share permission for a participant. Participants can stop their participation.
NSPersistentCloudKitContainer
provides methods for creating a share (CKShare
) for Core Data objects and managing the interaction between the share and the associated objects. UICloudSharingController
implements the share invitation and management. Apps can implement a sharing flow using these two APIs.
To create a share for Core Data objects, call share(_:to:completion:)
. Apps can choose creating a new share, or adding the objects to an existing share. Core Data uses CloudKit zone sharing so each share has its own record zone on the CloudKit server. (For more details, see WWDC21 session 10015: Build Apps that Share Data Through CloudKit and Core Data and WWDC21 session 10086: What’s new in CloudKit.) CloudKit has a limit on how many record zones a database can have. To avoid hitting the limit, consider using an existing share if appropriate.
See the following method for how this sample app shares a photo:
- CodeListing: shareObject
NSPersistentCloudKitContainer
doesn’t automatically handle the changes UICloudSharingController
(or other CloudKit APIs) makes on a share. When the kind of changes happen, apps must update the Core Data store by calling persistUpdatedShare(_:in:completion:)
. The sample app implements the following UICloudSharingControllerDelegate
method to persist a updated share.
- CodeListing: cloudSharingControllerDidSaveShare
Similarly, when owners tap the “Stop Sharing” button or participants tap the “Remove Me” button in the CloudKit sharing UI, NSPersistentCloudKitContainer
doesn’t immediately know the change. To avoid stale UI in this case, implement the following delegate method to purge the Core Data objects and CloudKit records associated with the share using purgeObjectsAndRecordsInZone(with:in:completion:)
.
- CodeListing: cloudSharingControllerDidStopSharing
Core Data doesn’t support cross-share relationships. That is, it doesn’t allow relating objects associated with different shares. When sharing an object, Core Data moves the whole object graph (including the object and all its relationships) to the share’s record zone. When users stop a share, Core Data deletes the object graph. In the case where apps need to reserve the data when users stopping a share, make a deep copy of the object graph and make sure no object in the graph is associated with any share.
Detect Relevant Changes by Consuming Store Persistent History
When importing data from CloudKit, NSPersistentCloudKitContainer
records the changes on Core Data objects in the store’s persistent history, and triggers remote change notifications (.NSPersistentStoreRemoteChange
) so apps can keep their state up to date if necessary. The sample app observes the notification and does the followings in the notification handler:
- Gather the relevant history transactions (
NSPersistentHistoryTransaction
), and notify the views that remote changes happen. Note that the changes on shares don’t generate any transactions. - The views that present photos merge the transactions to the
viewContext
of the persistent container, which triggers a SwiftUI update. Views relevant to shares fetch the shares from the stores, and update with them. - Detect the new tags from CloudKit, and remove duplicate tags if necessary.
To process the persistent history more effectively, the app:
- Maintains the token of the last transaction it consumes for each store, and uses it as the starting point of next run.
- Maintains a transaction author, and uses it to filter the transactions irrelevant to Core Data CloudKit.
- Only fetches and consumes the history of the relevant persistent store.
This is the code that sets up the history fetch request (NSPersistentHistoryChangeRequest
):
- CodeListing: fetchHistory
For more information about persistent history processing, see Consuming Relevant Store Changes.
Remove Duplicate Data
In the CloudKit environment, duplicate data is sometimes inevitable:
- Different peers can create same data. In this sample app, owners can share a photo with a permission that allows participants to tag it. When owners and participants simultaneously create a same tag, a duplicate occurs.
- Apps rely on some initial data and there is no way to allow only one peer to preload it. Duplicates occur when multiple peers preload the data at the same time.
To remove duplicate data (or deduplicate), implement a way that allows all peers to eventually reserve the same winner and remove others. The sample app removes duplicate tags in the following way:
- Give every tag a universally unique identifier (UUID). Tags that meet the following criteria are duplicates and only one should exist:
- They have a same tag name. (Their UUIDs are still different.)
- They are associated with a same share, and so are in the same CloudKit record zone.
- Detect new tags from CloudKit by looking into the persistent history every time a remote change notification occurs.
- For each new tag, fetch the duplicates from the same persistent store, and sort them with their UUID so the tag with the smallest UUID goes first.
- Pick the first tag as the winner and remove the others. Because UUID is globally unique and every peer picks the first tag, all peers eventually reach to the same winner, which is the tag that has the globally smallest UUID.
The sample app only detects and removes duplicate tags from the owner side because participants may not have write permission. That is, deduplication only applies to the private persistent store.
See the following method for the code that deduplicate tags:
- CodeListing: deduplicateAndWait
Implement a Custom Sharing Flow
When UICloudSharingController
is unavailable or doesn’t fit the app UI, consider implementing a custom sharing flow if necessary. (UICloudSharingController
is unavailabe on watchOS. On macOS, use NSSharingService
with the .cloudSharing
service.) To do that, here are the steps and relevant APIs:
- On the owner side, pick the Core Data objects to share, and create a share with them using
share(_:to:completion:)
. - Configure the share with appropriate permissions, and add participants if it’s a private share.
A share is private if itspublicPermission
is more permissive than.none
. For shares that have.none
public permission (called public shares), users can participate by tapping the share link, hence no need to add participants beforehand. Look up the participants usingfetchParticipants(matching:into:completion:)
orCKFetchShareParticipantsOperation
, then add them to the share by callingaddParticipant(_:)
. Configure the participant permission usingCKShare.ParticipantPermission
. - Implement a mechanism for the owner to deliver the share link (
CKShare.url
). - On the participant side, accept the share.
After receiving the share link, participants tap it to accept the share and open the app. The system callswindowScene(_:userDidAcceptCloudKitShareWith:)
(oruserDidAcceptCloudKitShare(with:)
on watchOS) when launching the app in this context, and the app accepts the share usingacceptShareInvitations(from:into:completion:)
orCKAcceptSharesOperation
. After the acceptance synchronizes, the objects the owner shares are available in the participant’s store that mirrors the CloudKit shared database. - On the owner side, manage the participants of the share using
addParticipant(_:)
andremoveParticipant(_:)
, or stop the sharing by callingpurgeObjectsAndRecordsInZone(with:in:completion:)
. - On the participant side, stop the participation by calling
purgeObjectsAndRecordsInZone(with:in:completion:)
.
In the whole process, whenever changing a share using CloudKit APIs, call persistUpdatedShare(_:in:completion:)
so Core Data persists the change to the store and synchronize it with CloudKit. As an example, this sample uses the following code to add a participant
-
CodeListing: addParticipant
-
Note: To be able to accept a share when users tap a share link, the app’s
info.plist
file must contain theCKSharingSupported
key and its value must betrue
.