Scorebook Sync Log 09 – CloudKit Xcoders Talk

Last night I gave my first Xcoders talk (and first tech talk in general), going over how I’ve brought CloudKit into Scorebook. The response was overwhelmingly positive from the group and I’m really happy about that. The talk had most all of the content that I’ve written so far in this series, but also went pretty in depth on CloudKit in general and has my upload and download processes as well as lots of code samples.

The slides can be found here.

Some notes:

  • I’m bummed that I haven’t shipped the Scorebook update yet, but I’m planning to in the next month or at the latest by the end of July.
  • I didn’t talk about the user switching things from my last post. Mostly because I haven’t finished the implementation and I didn’t want to talk about it in that state. I’ll keep writing about it here as I make progress though.

There was video recorded as well, and once that is posted to the Xcoders Vimeo account I’ll be sure to update this post with a link to it.

Lastly, I love talking about this stuff. If you have questions hit me up on Twitter or via email.

Scorebook Sync Log 08 – User Switching

I’m feeling really confident in the things that I’ve put into sync in Scorebook. But now I’m starting to handle edge cases. Things like what happens when a user uses Scorebook without a current iCloud account, or how do I handle it when they log out and/or login again as a different user?

So I came up with a handy flowchart that I threw at Twitter and the Core Intuition Slack channel. Check it out:

Sync is hard.

I got some feedback on Slack from a guy who is doing something similar to what I am, but he’s accounting for the need to reset either the local store from the cloud or vice versa. The assumption is that at some point one or the other will become wrong and need to go back to square one. I may be overconfident here, but I’m leaning against doing that. My desired UI for syncing is a single switch on the settings screen. I think that’s how I’m going to launch, and I’ll have to see what the feedback is from users.

I’ve started writing the code to handle all of this and so far it’s not as bad as I thought it would be. I need to do a lot of testing still, though (and I’ve yet to start in on merging).

Do you see anything in the chart that might need to be changed? I’d love to hear about it!

Scorebook Sync Log 07 – Responding to Deletions

(Thanks to David Hoang for the encouragement to post this)

I thought I was close to being done, and then I remembered that I haven’t implemented responding to deletions from CloudKit locally. Here’s how the process works:

  1. Delete the object from Core Data & save the context
  2. Respond to the context did save notification and send CloudKit CKRecordID objects representing the deleted record to CloudKit
  3. CloudKit will send a notification to all subscribed devices letting them know of the deletion, with the corresponding CKRecordID object.
  4. Figure out what entity that object belongs to, and delete the instance from the database
  5. Save the updated database.

Pretty easy, right? Except, consider this: there isn’t a way to know the recordType of the CKRecordID coming down. The recordType is the database equivalent of a table, and it’s a property on CKRecord, not CKRecordID. When I’m creating CKRecords from my managed objects, I set the recordType property to a string representing my entity. That keeps things clean (i.e. my SBGame instances would all have record types of SBGame).

I haven’t talked about uniquing objects in CloudKit yet, so let’s go back to the beginning there. When you create a CKRecord you can specify it’s unique ID (made up of a recordName property which is a string, and a zone if it’s going to a custom zone). Those 2 factors combine with the CKRecord’s recordType property to create a primary key. Good database practice so far. You can also choose not to specify a recordID at the CKRecord’s creation, and it will be created when saved to the server.

For ease, I have decided to create a unique ID as a UUID string that is set on -awakeFromInsert on all of my entities. That way I always know what their unique ID will be, and I don’t have to worry about saving them back to the database when they are saved to the cloud.

The first step to determining what record type the CKRecordID I’m responsible for deleting, I made a change to each entity’s -recordName property, so that it includes the entity name as the first part of the record name. Then it’s followed by a delimiter (I’m using “|~|”) and then the UUID. Here’s how that now looks:

- (void)awakeFromInsert
{
    [super awakeFromInsert];
    self.ckRecordName = [NSString stringWithFormat:@"SBGame|~|%@", [[NSUUID UUID] UUIDString]];
}

A sample recordName for an SBGame instance would be “SBGame|~|386c1919-5f25-4be2-975f-5b34506c51db”, with |~| being the delimiter between the entity name and the UUID. A bit hacky, but it works.

On the downloader I explode the string into an array using he same delimiter and grab the first object, which will be “SBGame”. Once I have the entity and the recordName to search for, I can put this in a fetch request to grab the object that needs deletion. Here’s what that looks like:

NSString *recordName = recordID.recordName;
NSString *recordType = [[recordName componentsSeparatedByString:@"|~|"] firstObject];
        
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"ckRecordName = %@", recordName];
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:recordType];
fetchRequest.predicate = predicate;
        
NSArray *foundObjects = [backgroundContext executeFetchRequest:fetchRequest error:nil];
id foundObject = [foundObjects firstObject];
if (foundObject != nil) {
    [backgroundContext deleteObject:foundObject];
}

In my limited initial testing this is working well so far, and it’s a simple solution to the problem.

Scorebook Sync Log 06 – Fun with Protocols

In my usage of CloudKit I had to determine early on how I was going to upload batches of records and deal with batches coming down. I’m dealing entirely with CKRecord instances, so all of my entities will need to know how to handle those; both in how to create one from themselves, and how to turn turn one into itself.

I didn’t have any clever idea at first, so I punted. For saving records to CloudKit, I’m monitoring the NSManagedObjectContextDidSaveNotification (which isn’t verbose enough) and I get NSSets of managed objects. Here’s what my initial implementation looked like:

- (CKRecord *)ckRecordForManagedObject:(NSManagedObject *)managedObject
{
    CKRecordZoneID *userZone = [[SBSyncController sharedSyncController] userRecordZone];
    CKRecord *recordToReturn;
    
    if ([managedObject isKindOfClass:[SBPerson class]]) {
        recordToReturn = [(SBPerson *)managedObject cloudKitRecordInRecordZone:userZone];
    } else if ([managedObject isKindOfClass:[SBGame class]]) {
        recordToReturn = [(SBGame *)managedObject cloudKitRecordInRecordZone:userZone];
    } else if ([managedObject isKindOfClass:[SBMatch class]]) {
        recordToReturn = [(SBMatch *)managedObject cloudKitRecordInRecordZone:userZone];
    } else if ([managedObject isKindOfClass:[SBPlayer class]]) {
        recordToReturn = [(SBPlayer *)managedObject cloudKitRecordInRecordZone:userZone];
    } else if ([managedObject isKindOfClass:[SBScore class]]) {
        recordToReturn = [(SBScore *)managedObject cloudKitRecordInRecordZone:userZone];
    } else if ([managedObject isKindOfClass:[SBMatchImage class]]) {
        recordToReturn = [(SBMatchImage *)managedObject cloudKitRecordInRecordZone:userZone];
    } else if ([managedObject isKindOfClass:[SBMatchLocation class]]) {
        recordToReturn = [(SBMatchLocation *)managedObject cloudKitRecordInRecordZone:userZone];
    }
    
    return recordToReturn;
}

Yuck.

And similarly, I captured the -recordType property of an incoming CKRecord, then ran -isEqualToString: against it to determine which entity it represented. And then something interesting happened.

I got to the place where I was working through how to handle conflicts and needed a -modificationTimestamp property to get that done, and realized that I could use a protocol to declare uniform conformance across all of my entities. I could make sure that the class conforms to the protocol, and then set the property without having to do the ugliness of something like the above snippet.

And thus, SBCloudKitCompatible was born. This protocol defines 4 things in the interface:

@protocol SBCloudKitCompatible <NSObject>
@property (nonatomic, strong) NSString *ckRecordName;
@property (nonatomic, strong) NSDate *modificationDate;

- (CKRecord *)cloudKitRecordInRecordZone:(CKRecordZoneID *)zone;
+ (NSManagedObject *)managedObjectFromRecord:(CKRecord *)record context:(NSManagedObjectContext *)context;
@end

By conforming to this protocol, I’ve been able to cut out a bunch of code. Here’s what the snippet above now looks like:

- (CKRecord *)ckRecordForManagedObject:(NSManagedObject *)managedObject
{
    CKRecord *recordToReturn = nil;
    
    if ([managedObject conformsToProtocol:@protocol(SBCloudKitCompatible)]) {
        id<SBCloudKitCompatible> object = (id<SBCloudKitCompatible>)managedObject;
        recordToReturn = [object cloudKitRecordInRecordZone:self.zoneID];
    }
    
    return recordToReturn;
}

I think we can agree that it looks way, way better.

Scorebook Sync Log 05 - Plan Accordingly

When I decided to begin sync implementation with CloudKit, I just started going. I watched the WWDC videos (mostly) and read through the getting started, then started coding.

That wasn't the best tactic. Turns out.

There are a couple of questions that I should have asked myself. Do I want to have record changes pushed to me automatically? Do I want atomic operations so that all of the items have to succeed?

(As a slight aside, take note that neither of those things can be done on the public database, this is only for private data)

Well, I do want at least the record changes. So I need to implement a custom record zone. This is done with a CKRecordZone object, and is kind of like the equivalent of a database schema. Instead of using the default zone (which can't have push notifications or atomic actions), I create my own like this:

CKRecordZoneID *zoneID = [[CKRecordZoneID alloc] initWithZoneName:@"ScorebookData" ownerName:CKOwnerDefaultName];
CKRecordZone *zone = [[CKRecordZone alloc] initWithZoneID:zoneID];

The zone name can be "ASCII characters and must not exceed 255 characters". The zone name needs to be unique within the user's private database (which makes using a single string as the zone name possible). The owner name can just have the string constant CKOwnerDefaultName, which I'm pretty sure gets changed server-side to be some sort of user identifier. The docs aren't super clear about that part, but the method notes say:

To specify the current user, use the CKOwnerDefaultName constant.

Poking inside that constant at runtime expands it to __defaultOwner__. CloudKit seems to know what to do, though.

The next part I had to figure out was how do I get records into this zone? You don't save anything to the zone object, but instead use a different initializer on the CKRecordID objects. So I added a paramteter to my -cloudKitRecord methods in my model categories to take a zone. Now the methods are - (CKRecord *)cloudKitRecordInRecordZone:(CKRecordZoneID *)zone, and the implememtation uses the -initWithRecordName:zoneID initializer to create the record ID.

The thing I'm going to talk about next time is setting this whole stack up. I'm coming to realize that CloudKit, like Core Data, is a stack. I'm building a sync controller (which is what injects the CKRecordZoneID into the model objects' zone parameter) and that is working well so far. Stay tuned.