CloudCore: a framework that manages syncing between iCloud (CloudKit) and Core Data written on native Swift
CloudCore
CloudCore is a framework that manages syncing between iCloud (CloudKit) and Core Data written on native Swift.
Features
- Leveraging NSPersistentHistory, local changes are pushed to CloudKit when online
- Pull manually or on CloudKit remote notifications.
- Differential sync, only changed object and values are uploaded and downloaded.
- Core Data relationships are preserved
- private database and shared database push and pull is supported.
- public database push is supported
- Parent-Child relationships can be defined for CloudKit Sharing
- Respects Core Data options (cascade deletions, external storage).
- Knows and manages CloudKit errors like
userDeletedZone
,zoneNotFound
,changeTokenExpired
,isMore
. - Available on iOS and iPadOS (watchOS and tvOS haven't been tested)
- Sharing can be extended to your NSManagedObject classes, and native SharingUI is implemented
CloudCore vs NSPersistentCloudKitContainer?
NSPersistentCloudKitContainer provides native support for Core Data <-> CloudKit synchronization. Here are some thoughts on the differences between these two approaches.
NSPersistentCloudKitContainer
- Simple to enable
- Support for Private, Shared, and Public databases
- Synchronizes All Records
- No CloudKit Metadata (e.g. recordName, systemFields, owner)
- Record-level Synchronization (entire objects are pushed)
- Offline Synchronization is opaque, but doesn't appear to require NSPersistentHistoryTracking
- All Core Data names are preceeded with "CD_" in CloudKit
- Core Data Relationships are mapped thru CDMR records in CloudKit
CloudCore
- Support requires specific configuration in the Core Data Model
- Support for Private, Shared, and Public databases
- Selective Synchronization (e.g. can delete local objects without deleting remote records)
- Explicit CloudKit Metadata
- Field-level Synchronization (only changed attributes are pushed)
- Offline Synchronziation via NSPersistentHistoryTracking
- Core Data names are mapped exactly in CloudKit
- Core Data Relationships are mapped to CloudKit CKReferences
During their WWDC presentation, Apple very clearly stated that NSPersistentCloudKitContainer is a foundation for future support of more advanced features #YMMV
How it works?
CloudCore is built using a "black box" architecture, so it works 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 stores change tokens from CloudKit, so only changed data is downloaded.
- When CloudCore is enabled (
CloudCore.enable
) it pulls changed data from CloudKit and subscribes to CloudKit push notifications about new changes. - When
CloudCore.pull
is called manually or by push notification, CloudCore pulls and saves changed data to Core Data. - When data is written to your persistent container (parent context is saved) CloudCore finds locally changed data and pushes to CloudKit.
- By leveraging NSPersistentHistory, changes can be queued when offline and pushed when online.
Installation
CocoaPods
CloudCore is available through CocoaPods. To install
it, simply add the following line to your Podfile:
pod 'CloudCore'
How to help?
What would you like to see improved?
Quick start
- Enable CloudKit capability for you application:
- For each entity type you want to sync, add this key: value pair to the UserInfo record of the entity:
CloudCoreScopes
:private
- Also add 4 attributes to each entity:
privateRecordData
attribute withBinary
typepublicRecordData
attribute withBinary
typerecordName
attribute withString
typeownerName
attribute withString
type
- And enable 'Preserve After Deletion' for the following attributes
privateRecordData
publicRecordData
- Make changes in your AppDelegate.swift file:
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)
})
}
}
- If you want to enable offline support, enable NSPersistentHistoryTracking when you initialize your Core Data stack
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
}()
- To identify changes from your app that should be pushed, save from a background ManagedObjectContext named
CloudCorePushContext
, or use the convenience function performBackgroundPushTask
persistentContainer.performBackgroundPushTask { moc in
// make changes to objects, properties, and relationships you want pushed via CloudCore
try? context.save()
}
- Make first run of your application in a development environment, fill an example data in Core Data and wait until sync completes. CloudKit will create needed schemas automatically.
Service attributes
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:
- Private Record Data attribute with
Binary
type - Public Record Data attribute with
Binary
type - Record Name attribute with
String
type - Owner Name attribute with
String
type
You may specify attributes' names in one of two 2 ways (you may combine that ways in different entities).
Default names
The most simple way is to name attributes with default names because you don't need to map them in UserInfo.
Mapping via 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:
- Private Record Data value is
privateRecordData
. - Public Record Data value is
publicRecordData
. - Record Name value is
recordName
. - Owner Name value is
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
? Tips
- I recommend to set the Record Name attribute as
Indexed
, to speed up updates in big databases. - Record Data attributes are used to store archived version of
CKRecord
with system fields only (like timestamps, tokens), so don't worry about size, no real data will be stored here.
CloudKit Sharing
CloudCore now 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 = .userInteractive
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 = .userInteractive
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.
Example application
You can find example application at Example directory, which has been updated to demonstrate sharing.
How to run it:
- Set Bundle Identifier.
- Check that embedded binaries has a correct path (you can remove and add again CloudCore.framework).
- If you're using simulator, login at iCloud on it.
How to use it:
- + button adds new object to local storage (that will be automatically synced to Cloud)
- *Share button presents the CloudKit Sharing UI
- refresh button calls
pull
to fetch data from Cloud. That is only useful for simulators because Simulator unable to receive push notifications - Use CloudKit dashboard to make changes and see it at application, and make change in application and see ones in dashboard. Don't forget to refresh dashboard's page because it doesn't update data on-the-fly.
Tests
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:
- Change
TestableApp
bundle id. - Run in simulator or real device
TestableApp
target. - Configure iCloud on that device: Settings.app → iCloud → Login.
- Run
CloudKitTests
, they are attached toTestableApp
, so CloudKit connection will work.
Roadmap
- [ ] Add methods to clear local cache and remote database
- [ ] Add error resolving for
limitExceeded
error (split saves by relationships).
Authors
deeje cooley, deeje.com
- added NSPersistentHistory and CloudKit Sharing Support
Vasily Ulianov, va...@me.com
Open for hire / relocation.
- implemented version 1 and 2, with dynamic mapping between CoreData and CloudKit
Oleg Müller
- added full support for CoreData relationships