Integrating 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 entities @property (nonatomic, strong) NSString *ckRecordName; @property (nonatomic, strong) NSDate *modificationDate;

//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 managedObject in inserted) { managedObject.modificationDate = [NSDate date]; }

for (id managedObject in updated) { managedObject.modificationDate = [NSDate date]; } } 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 managedObject in inserted) { managedObject.modificationDate = [NSDate date]; }

for (id managedObject in updated) { managedObject.modificationDate = [NSDate date]; } } cloudKitRecordInRecordZone:

- (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 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 object in insertedObjects) { CKRecord *record = [object cloudKitRecordInRecordZone:zone]; [savedRecords addObject:record]; } 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 the deleted objects into an array of CKRecordIDs Convert to CKRecordID

CKRecordZoneID *zone = // NSMutableArray *deletedRecords = [NSMutableArray array]; for (id managedObject in deletedObjects) { CKRecordID *deletedRecordID = [[CKRecordID alloc] initWithRecordName:managedObject.ckRecordName zoneID:zone]; [deletedRecords addObject:deletedRecordID]; } } 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 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 cloudKitEntity = (id)entity; [cloudKitEntity managedObjectFromRecord:record context:backgroundContext]; } } Deleting a CKRecordID for (CKRecordID *recordID in self.recordsToDelete) { NSString *recordName = recordID.recordName; NSString *recordType = [[recordName componentsSeparatedByString:@"|~|"] firstObject];

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