Integrating Core Data and CloudKit Jared Sorge Scorebook Remember - - PowerPoint PPT Presentation
Integrating Core Data and CloudKit Jared Sorge Scorebook Remember - - PowerPoint PPT Presentation
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
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
CKContainer Public CKDatabase Private CKDatabase
CloudKit Stack
CKContainer Public CKDatabase Private CKDatabase CKRecordZone Default Zone Custom
CloudKit Stack
CKContainer Public CKDatabase Private CKDatabase CKRecordZone Default Zone Custom CKRecord
CloudKit Stack
CKContainer Public CKDatabase Private CKDatabase CKRecordZone Default Zone CKRecord CKSubscription (optional) Custom
CloudKit Stack
CKContainer Public CKDatabase Private CKDatabase CKRecordZone Default Zone CKRecord CKSubscription (optional) Custom
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"
- wnerName:CKOwnerDefaultName];
[privateDB saveRecordZone:newZone completionHandler:^(CDRecordZone *zone, NSError *error) { //Error handling //Additional configuration }]; CKContainer *container = [CKContainer sharedContainer]; CKDatabase *privateDB = [container privateCloudDatabase]; CKRecordZoneID *newZone = [[CKRecordZoneID alloc] initWithZoneName:@"ScorebookData"
- wnerName:CKOwnerDefaultName];
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); CKRecordZoneID *zone = // CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName:self.ckRecordName zoneID:zone]; CKRecord *gameRecord = [[CKRecord alloc] initWithRecordType:[SBGame entityName] recordID:recordID];
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; CKRecordID *personRecordID = // CKReference *personReference = [[CKReference alloc] initWithRecordID:personRecordID action:CKReferenceActionDeleteSelf];
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
- ptions:0];
} [privateDB saveSubscription:scorebookDataSubscription completionHandler:^(CKSubscription *sub, NSError *error) { //handle error }]; }]; CKSubscription *subscription = [subs firstObject]; if (subscription == nil) { subscription =[[CKSubscription alloc] initWithZoneID:userRecordZone
- ptions:0];
}
CloudKit Stack
CKContainer Public CKDatabase Private CKDatabase CKRecordZone Default Zone CKRecord CKSubscription (optional) Custom
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 <NSObject> //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<SBCloudKitCompatible> managedObject in inserted) { managedObject.modificationDate = [NSDate date]; } for (id<SBCloudKitCompatible> managedObject in updated) { managedObject.modificationDate = [NSDate date]; } } NSManagedObjectContext *context = // monitored context NSSet *inserted = [context insertedObjects]; NSSet *updated = [context updatedObjects]; if (inserted.count == 0 && updated.count == 0) { return; }
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<SBCloudKitCompatible> managedObject in inserted) { managedObject.modificationDate = [NSDate date]; } for (id<SBCloudKitCompatible> managedObject in updated) { managedObject.modificationDate = [NSDate date]; } } for (id<SBCloudKitCompatible> managedObject in inserted) { 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; } CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName:self.ckRecordName zoneID:zone]; NSString *entityName = [SBPerson entityName]; CKRecord *personRecord = [[CKRecord alloc] initWithRecordType:entityName recordID:recordID];
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]; … SBMatch *match = [SBMatch matchWithCloudKitRecordName:recordID.recordName managedObjectContext:context];
managedObjectFromRecord:context:
… CKReference *gameRef = ckRecord[SBMatchGameKEY]; if (gameRef != nil) { SBGame *game = [SBGame gameWithCloudKitRecordID:gameRef.recordID managedObjectContext:context]; match.game = game; } return match; } CKReference *gameRef = ckRecord[SBMatchGameKEY]; if (gameRef != nil) { SBGame *game = [SBGame gameWithCloudKitRecordID:gameRef.recordID managedObjectContext:context]; match.game = game; }
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<SBCloudKitCompatible> 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<SBCloudKitCompatible> managedObject in deletedObjects) { CKRecordID *deletedRecordID = [[CKRecordID alloc] initWithRecordName:managedObject.ckRecordName zoneID:zone]; [deletedRecords addObject:deletedRecordID]; } } for (id<SBCloudKitCompatible> 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]; CKModifyRecordsOperation *modifyRecords = [[CKModifyRecordsOperation alloc] initWithRecordsToSave:savedRecords recordIDsToDelete:deletedRecords];
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 }; SBCloudKitDownloader *downloader = [[SBCloudKitDownloader alloc] initWithCloudKitRecordsToMake:objectsToMake recordIDsToDelete:objectsToDelete managedObjectContext:// ]; [downloader processIncoming];
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<SBCloudKitCompatible> cloudKitEntity = (id<SBCloudKitCompatible>)entity; [cloudKitEntity managedObjectFromRecord:record context:backgroundContext]; } } Class entity = NSClassFromString(record.recordType); if ([entity conformsToProtocol:@protocol(SBCloudKitCompatible) ]) { id<SBCloudKitCompatible> cloudKitEntity = (id<SBCloudKitCompatible>)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]; } } NSString *recordName = recordID.recordName; NSString *recordType = [[recordName componentsSeparatedByString:@"|~|"] firstObject];