Integrating Core Data and CloudKit Jared Sorge Scorebook Remember Your Games Core Data Paul Goracke – “Core Data Potpurri”, February 2014
http://bit.ly/1A5fWGr
Marcus Zarra – “My Core Data Stack”, March 2015
http://bit.ly/1KQaibt
TaphouseKit – GitHub Project
http://bit.ly/1e4AEwo CloudKit OS X Yosemite & iOS 8
Transport layer
No black magic CloudKit Used by Apple
iCloud Drive & iCloud Photo Library
Used by third parties
1Password CloudKit Stack
CKContainer CloudKit Stack
Public CKDatabase Private CKDatabase
CKContainer CloudKit Stack
CKRecordZone Default Zone Custom
Public CKDatabase Private CKDatabase
CKContainer CloudKit Stack
CKRecord
CKRecordZone Default Zone Custom
Public CKDatabase Private CKDatabase
CKContainer CloudKit Stack
CKSubscription (optional)
CKRecord
CKRecordZone Default Zone Custom
Public CKDatabase Private CKDatabase
CKContainer CloudKit Stack
CKSubscription (optional)
CKRecord
CKRecordZone Default Zone Custom
Public CKDatabase Private CKDatabase
CKContainer CKRecord Store data using key/value pairs
NSString, NSNumber, NSData, NSDate, NSArray, CLLocation, CKAsset, CKReference
Use constant strings for keys
recordType property is like a database table name CKRecord Initializers
initWithRecordType:
initWithRecordType:zoneID:
initWithRecordType:recordID: CKRecordID 2 properties
recordName, zoneID
Initializers
initWithRecordName:
initWithRecordName:zoneID: CKRecordZoneID initWithZoneName:ownerName:
Use CKOwnerDefaultName for ownerName
Zone name is a string
Use CKRecordZoneDefaultName for the default zone CKRecordZoneID
CKContainer *container = [CKContainer sharedContainer]; CKDatabase *privateDB = [container privateCloudDatabase];
CKRecordZoneID *newZone = [[CKRecordZoneID alloc] initWithZoneName:@"ScorebookData" ownerName:CKOwnerDefaultName];
[privateDB saveRecordZone:newZone completionHandler:^(CDRecordZone *zone, NSError *error) { //Error handling //Additional configuration }]; CKRecord Creation
CKRecordZoneID *zone = // CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName:self.ckRecordName zoneID:zone]; CKRecord *gameRecord = [[CKRecord alloc] initWithRecordType:[SBGame entityName] recordID:recordID];
[gameRecord setObject:self.title forKey SBGameTitleKEY]; gameRecord[SBGameScoringTypeKEY] = @(self.scoringType); gameRecord[SBGamePointsToWinKEY] = @(self.pointsToWin); CKAsset Blob storage
Single initializer
initWithFileURL:
No support for NSData
Attach to CKRecord instance as a value CKAsset NSURL *fileURL = // … url of path to file CKAsset *asset = [[CKAsset alloc] initWithFileURL:fileURL];
CKRecord *record = // record[@“asset”] = asset; CKReference Relate records with separate record types
Relationships in a single zone only
1:many relationships
many:many not officially supported
Associate on the many side of the relationship
Person Player CKReference Initializers
-initWithRecordID:action:
-initWithRecord:action:
Set the delete action on the initializer
CKReferenceActionDeleteSelf
CKReferenceActionNone CKReference
CKRecordID *personRecordID = // CKReference *personReference = [[CKReference alloc] initWithRecordID:personRecordID action:CKReferenceActionDeleteSelf];
CKRecord *playerRecord = // playerRecord[SBPlayerPersonKEY] = personReference; CKSubscription Subscribes to changes of a record type or custom zone
Can use a search predicate to determine matches
Uses push notifications to alert you of changes
Uses the remote notification system in iOS CKSubscription Save to the database for activation on a device
Save once, then retrieve on other devices CKSubscription
CKRecordZoneID *userRecordZone = // [privateDB fetchAllSubscriptionsWithCompletionHandler:^(NSArray *subs, NSError *error) { CKSubscription *subscription = [subs firstObject];
if (subscription == nil) { subscription =[[CKSubscription alloc] initWithZoneID:userRecordZone options:0]; }
[privateDB saveSubscription:scorebookDataSubscription completionHandler:^(CKSubscription *sub, NSError *error) { //handle error }]; }]; CloudKit Stack
CKSubscription (optional)
CKRecord
CKRecordZone Default Zone Custom
Public CKDatabase Private CKDatabase
CKContainer Putting it together Sync is about upload, download, and conflict handling Make a Plan Database: public, private, or both?
Using a custom record zone?
Are you happy with your Core Data object graph?
How to make specific things generic, and generic things specific? SBCloudKitCompatible
@protocol SBCloudKitCompatible
//Add to categories on the model objects - (CKRecord *)cloudKitRecordInRecordZone:(CKRecordZoneID *)zone; + (NSManagedObject *)managedObjectFromRecord:(CKRecord *)record context:(NSManagedObjectContext *)context; @end ckRecordName - (void)awakeFromInsert { [super awakeFromInsert];
NSString *uuid = [[NSUUID UUID] UUIDString]; NSString *recordName = [NSString stringWithFormat: @“SBGame|~|%@“, uuid];
/* SBGame|~|386c1919-5f25-4be2-975f-5b34506c51db */
self.ckRecordName = recordName; } modificationDate
- (void)processCoreDataWillSaveNotification:(NSNotification *)notification { NSManagedObjectContext *context = // monitored context NSSet *inserted = [context insertedObjects]; NSSet *updated = [context updatedObjects]; if (inserted.count == 0 && updated.count == 0) { return; }
for (id
for (id
- (void)processCoreDataWillSaveNotification:(NSNotification *)notification { NSManagedObjectContext *context = // monitored context NSSet *inserted = [context insertedObjects]; NSSet *updated = [context updatedObjects]; if (inserted.count == 0 && updated.count == 0) { return; }
for (id
for (id
- (CKRecord *)cloudKitRecordInRecordZone:(CKRecordZoneID *)zone { CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName:self.ckRecordName zoneID:zone]; NSString *entityName = [SBPerson entityName]; CKRecord *personRecord = [[CKRecord alloc] initWithRecordType:entityName recordID:recordID];
personRecord[SBPersonFirstNameKEY] = self.firstName; personRecord[SBPersonLastNameKEY] = self.lastName; personRecord[SBPersonEmailAddressKEY] = self.emailAddress;
if (self.imageURL) { CKAsset *imageAsset = [[CKAsset alloc] initWithFileURL:[self urlForImage]]; personRecord[SBPersonAvatarKEY] = imageAsset; }
return personRecord; } managedObjectFromRecord:context:
+ (instancetype)managedObjectFromRecord:(CKRecord *)ckRecord context:(NSManagedObjectContext *)context { CKRecordID *recordID = ckRecord.recordID; SBMatch *match = [SBMatch matchWithCloudKitRecordName:recordID.recordName managedObjectContext:context];
if (match.modificationDate != nil && ckRecord.modificationDate < match.modificationDate) { return match; }
match.date = ckRecord[SBMatchDateKEY]; match.monthYear = ckRecord[SBMatchMonthYearKEY]; match.finished = [ckRecord[SBMatchFinishedKEY] boolValue]; match.note = [ckRecord objectForKey:SBMatchNoteKEY];
… managedObjectFromRecord:context:
…
CKReference *gameRef = ckRecord[SBMatchGameKEY]; if (gameRef != nil) { SBGame *game = [SBGame gameWithCloudKitRecordID:gameRef.recordID managedObjectContext:context]; match.game = game; }
return match; } Upload to CloudKit Monitor NSManagedObjectContextDidSaveNotification on the main thread NSManagedObjectContext
Gather the objects from the userInfo dictionary in the posted notification
Convert the inserted/updated objects into an array of CKRecords Convert to CKRecord CKRecordZoneID *zone = // NSMutableArray *savedRecords = [NSMutableArray array]; for (id
Gather the objects from the userInfo dictionary in the posted notification
Convert the inserted/updated objects into an array of CKRecords
Convert the deleted objects into an array of CKRecordIDs Convert to CKRecordID
CKRecordZoneID *zone = // NSMutableArray *deletedRecords = [NSMutableArray array]; for (id
Gather the objects from the userInfo dictionary in the posted notification
Convert the inserted/updated objects into an array of CKRecords
Convert the deleted objects into an array of CKRecordIDs
Use a CKModifyRecordsOperation to send the arrays to CloudKit Upload to CloudKit
CKDatabase *database = // CKModifyRecordsOperation *modifyRecords = [[CKModifyRecordsOperation alloc] initWithRecordsToSave:savedRecords recordIDsToDelete:deletedRecords];
//Called only on the saved records array modifyRecords.perRecordCompletionBlock = ^(CKRecord *record, NSError *error) { //Handle Error }; modifyRecords.modifyRecordsCompletionBlock = ^(NSArray *saved, NSArray *deleted, NSError *error) { //Handle error, perform cleanup }; modifyRecords.savePolicy = CKRecordSaveAllKeys; [database addOperation:modifyRecords]; Upload to CloudKit – Errors Up to the developer to handle
CKRecord & CKRecordID conform to NSSecureCoding
Failed records persist to disk Upload to CloudKit – Errors Before uploading, check to see if there are records on disk
If so, convert them back to their original state and add to proper array
Remove the files from disk Download from CloudKit Download from CloudKit (how I do it) CKFetchRecordChangesOperation initWithRecordZoneID:previousServerChangeToken:
Set some block properties
void (^recordChangedBlock)(CKRecord *record) recordChangedBlock NSMutableArray *objectsToMake = [NSMutableArray array]; fetchChanged.recordChangedBlock = ^(CKRecord *record) { [objectsToMake addObject:record]; }; CKFetchRecordChangesOperation initWithRecordZoneID:previousServerChangeToken:
Set some block properties
void (^recordChangedBlock)(CKRecord *record)
void (^recordWithIDWasDeletedBlock)( CKRecordID *recordID) recordWithIDWasDeletedBlock NSMutableArray *objectsToDelete = [NSMutableArray array]; fetchChanged.recordWithIDWasDeletedBlock = ^(CKRecordID *recordID) { [objectsToDelete addObject:recordID]; }; CKFetchRecordChangesOperation initWithRecordZoneID:previousServerChangeToken:
Set some block properties
void (^recordChangedBlock)(CKRecord *record)
void (^recordWithIDWasDeletedBlock)( CKRecordID *recordID)
void (^fetchRecordChangesCompletionBlock)(CKServerChangeToken *serverChangeToken, NSData *clientChangeTokenData, NSError *operationError) fetchRecordChangesCompletionBlock fetchChanged.fetchRecordChangesCompletionBlock = ^(CKServerChangeToken *serverChangeToken, NSData *clientChangeTokenData, NSError *operationError) { if (operationError) { //Handle error }
if (objectsToMake.count > 0 || objectsToDelete.count > 0) { SBCloudKitDownloader *downloader = [[SBCloudKitDownloader alloc] initWithCloudKitRecordsToMake:objectsToMake recordIDsToDelete:objectsToDelete managedObjectContext:// ]; [downloader processIncoming];
}
//Save the new change token }; CKFetchRecordChangesOperation initWithRecordZoneID:previousServerChangeToken:
Set some block properties
void (^recordChangedBlock)(CKRecord *record)
void (^recordWithIDWasDeletedBlock)( CKRecordID *recordID)
void (^fetchRecordChangesCompletionBlock)(CKServerChangeToken *serverChangeToken, NSData *clientChangeTokenData, NSError *operationError)
Add the operation to the database CKRecord to NSManagedObject
NSManagedObjectContext *context = // NSManagedObjectContext *backgroundContext = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType]; backgroundContext.parentContext = context; for (CKRecord *record in self.records) { Class entity = NSClassFromString(record.recordType); if ([entity conformsToProtocol:@protocol(SBCloudKitCompatible) ]) { id
NSPredicate *predicate = [NSPredicate predicateWithFormat: @"ckRecordName = %@", recordName]; NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:recordType]; fetchRequest.predicate = predicate;
NSError *searchError = nil; NSArray *foundObjects = [backgroundContext executeFetchRequest:fetchRequest error:&searchError]; id foundObject = [foundObjects firstObject]; if (foundObject != nil) { [backgroundContext deleteObject:foundObject]; } } Summary Upload Process
NSManagedObjectContextDidSaveNotification
Convert NSManagedObjects to CKRecord/CKRecordID
Upload to CloudKit
Handle Errors Download Process
Fetch changes in zone from previous change
Convert CKRecords & IDs using background context
Save the context
Save the change token WWDC! No significant API changes
(unless you count nullable/nonnull annotations)
But, there’s a Javascript library! Thank you!
Jared Sorge @jsorge http://jsorge.net