- April 27, 2024
- Mins Read
CloudCore is an advanced sync engine for CloudKit and Core Data.
userDeletedZone
, zoneNotFound
, changeTokenExpired
, isMore
.NSPersistentCloudKitContainer provides native support for Core Data <-> CloudKit synchronization. Here are some thoughts on the differences between these two approaches, as of May 2022.
Apple very clearly states that NSPersistentCloudKitContainer is a foundation for future support of more advanced features. I’m still waiting to learn which first-party apps use it. #YMMV
CloudCore is built using a “black box” architecture, so it works fairly invisibly for your application. You just need to add several lines to your AppDelegate
to enable it, as well as identify various aspects of your Core Data Model schema. Synchronization and error resolving is managed automatically.
CloudCore.enable
) it pulls changed data from CloudKit and subscribes to CloudKit push notifications about new changes.CloudCore.pull
is called manually or by push notification, CloudCore pulls and saves changed data to Core Data.CloudCore is available through CocoaPods. To install it, simply add the following line to your Podfile:
pod ‘CloudCore’
What would you like to see improved?
CloudCoreScopes
: private
privateRecordData
attribute with Binary
typepublicRecordData
attribute with Binary
typerecordName
attribute with String
typeownerName
attribute with String
typeprivateRecordData
publicRecordData
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// Register for push notifications about changes
application.registerForRemoteNotifications()
// Enable CloudCore syncing
CloudCore.enable(persistentContainer: persistentContainer)
return true
}
// Notification from CloudKit about changes in remote database
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
// Check if it CloudKit’s and CloudCore notification
if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) {
// Fetch changed data from iCloud
CloudCore.pull(using: userInfo, to: persistentContainer, error: nil, completion: { (fetchResult) in
completionHandler(fetchResult.uiBackgroundFetchResult)
})
}
}
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: “YourApp”)
let storeDescription = container.persistentStoreDescriptions.first
storeDescription?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
container.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
}
}
return container
}()
persistentContainer.performBackgroundPushTask { moc in
// make changes to objects, properties, and relationships you want pushed via CloudCore
try? context.save()
}
CloudCore stores CloudKit information inside your managed objects, so you need to add attributes to your Core Data model for that. If required attributes are not found in an entity, that entity won’t be synced.
Required attributes for each synced entity:
Binary
typeBinary
typeString
typeString
typeYou may specify attributes’ names in one of two 2 ways (you may combine that ways in different entities).
The most simple way is to name attributes with default names because you don’t need to map them in UserInfo.
You can map your own attributes to the required service attributes. For each attribute you want to map, add an item to the attribute’s UserInfo, using the key CloudCoreType
and following values:
recordName
.ownerName
.When your entities have relationships, CloudCore will look for the following key:value pair in the UserInfo of your entities:
CloudCoreParent
: name of the to-one relationship property in your entity
Indexed
, to speed up updates in big databases.CKRecord
with system fields only (like timestamps, tokens), so don’t worry about size, no real data will be stored here.You can designate which databases each entity will synchronized with. For each entity you want to synchronize, add an item to the entity’s UserInfo, using the key CloudCoreScope
and following values:
public
= pushed to public databaseprivate
= synchronized with private (or shared) databaseMaintaining two copies of a record means we get all the benefits of a private (and sharable) record, while also automatically maintaining a fully updated public copy.
You can designate attributes in your managed objects to be masked during upload and/or download. For each attribute you want to mask, add an item to the attribute’s UserInfo, using the key CloudCoreMasks
and following values:
upload
= ignored during modify operationsdownload
= ignored during fetch operationsupload,download
= bothBy default, CloudCore will transform assets in your CloudKit records into binary data attributes in your Core Data objects.
But when you’re working with very large files, such as photos, audio, or video, this default mode isn’t optimal.
Cacheable Assets addresses these requirements by leveraging Maskable Attributes to ignore asset fields during sync, and then enabling push and pull of asset fields using long-lived operations.
In order to manage cache state, assets must be stored in their own special entity type in your existing schema, which comform to the CloudCoreCacheable protocol. This protocol defines a number of attributes required to manage cache state:
public protocol CloudCoreCacheable: CloudCoreType {
// fully masked
var cacheStateRaw: String? { get set }
var operationID: String? { get set }
var uploadProgress: Double { get set }
var downloadProgress: Double { get set }
var lastErrorMessage: String? { get set }
// sync’ed
var remoteStatusRaw: String? { get set }
var suffix: String? { get set }
}
The heart of CloudCoreCacheable is implemented using the following properties:
public extension CloudCoreCacheable {
var cacheState: CacheState
var remoteStatus: RemoteStatus
var url: URL
}
Once you’ve configured your Core Data schema to support cacheable assets, you can create and download them as needed.
When you create a new cacheable managed object, you must store its data at the file URL before saving it. The default value of cacheState is “local” and the default value of remoteStatus is “pending”. Once CloudCore pushes the new cacheable record, it sets the cacheState to “upload”, which triggers a long-lived modify operation. On completion, the cacheable managed object will have its cacheState set to “cached” and its remoteStatus set to “available”.
When cacheable records are pulled from CloudKit, the asset field is ignored (because it is masked), and the cacheState will be “remote”. When the remoteStatus is “available”, you can trigger a long-lived fetch operation by setting the cacheState to “download” and saving the object. Once completed, the cacheable object will have its cacheState set to “cached”, and the data will be locally available at the file URL.
Note that cacheState represents a state machine.
(**new**) => local -> (push) -> upload -> uploading -> cached
(pull) => remote -> **download** -> downloading -> cached
See the Example app for specific details. Note, specifically, that I need to override awakeFromInsert and prepareForDeletion for my cacheable managed object type Datafile. If anyone has ideas on how to push this critical implementation detail into CloudCore itself, let me know!
CloudCore has built-in support for CloudKit Sharing. There are several additional steps you must take to enable it in your application.
Add the CKSharingSupported key, with value true, to your info.plist
Implement the appropriate delegate(… userDidAcceptCloudKitShare), something like…
func windowScene(_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
let acceptShareOperation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata])
acceptShareOperation.qualityOfService = .userInitiated
acceptShareOperation.perShareCompletionBlock = { meta, share, error in
CloudCore.pull(rootRecordID: meta.rootRecordID, container: self.persistentContainer, error: nil) { }
}
acceptShareOperation.acceptSharesCompletionBlock = { error in
// N/A
}
CKContainer(identifier: cloudKitShareMetadata.containerIdentifier).add(acceptShareOperation)
}
OR
func application(_ application: UIApplication,
userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
let acceptShareOperation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata])
acceptShareOperation.qualityOfService = .userInitiated
acceptShareOperation.perShareCompletionBlock = { meta, share, error in
CloudCore.pull(rootRecordID: meta.rootRecordID, container: self.persistentContainer, error: nil) { }
}
acceptShareOperation.acceptSharesCompletionBlock = { error in
// N/A
}
CKContainer(identifier: cloudKitShareMetadata.containerIdentifier).add(acceptShareOperation)
}
Note that when a user accepts a share, the app does not receive a remote notification of changes from iCloud, and so it must specifically pull the shared record in.
Use a CloudCoreSharingController to configure a UICloudSharingController for presentation
When a user wants to delete an object, your app must distinguish between the owner and a sharer, and either delete the object or the share.
You can find example application at Example directory, which has been updated to demonstrate sharing, maskable attributes, and cacheable assets.
How to run it:
How to use it:
pull
to fetch data from Cloud. That is only useful for simulators because Simulator unable to receive push notificationsMediaBook is a production-level iOS app being developed, which demonstrates how to handle cacheable assets in collection views.
CloudKit objects can’t be mocked up, that’s why there are 2 different types of tests:
Tests/Unit
here I placed tests that can be performed without CloudKit connection. That tests are executed when you submit a Pull Request.
Tests/CloudKit
here located “manual” tests, they are most important tests that can be run only in configured environment because they work with CloudKit and your Apple ID.
Nothing will be wrong with your account, tests use only private CKDatabase
for application.
Please run these tests before opening pull requests.
To run them you need to:
TestableApp
bundle id.TestableApp
target.CloudKitTests
, they are attached to TestableApp
, so CloudKit connection will work.limitExceeded
error (split saves by relationships).Horizon SDK is a state of the art real-time video recording / photo shooting iOS library. Some of the features ...