Cocoa Tutorial: Sync Services without Core Data
Sync Services have come a long way in Leopard. Before Leopard it was an extremely complex operation that was almost completely manual. Needless to say, this sucked and it was probably one of the reasons it was shunned by most developers.
If you are using Core Data in a Leopard application then Sync Services is so trivial that you should be syncing if it makes sense. In this article we are going to cover syncing in a non-Core Data situation as that is quite a bit more complex.
If you have read the Sync Services documentation then you know it is complex. Let me dispel an illusion right away. It is hard. It is not poor documentation, syncing is very hard and very few people get it right. Take a look at Omnifocus to see an example of a company thinking it is easy and losing data. Therefore if you are expecting this subject to be trivial you will be disappointed.
In this example we will be syncing with the bookmarks schema and displaying them in a simple outline view. The outline view itself will be editable and those edits can be synced back. Not terribly useful but provides a very simple example.
Sync Services Setup
There are a few different ways to implement sync services inside of your application. You can set up a sync client and handle everything manually just like we did in Tiger but to be honest, unless you need Tiger compatibility, I would not use that approach again. The option we are going to use in this article is new to Leopard and that will be employing a ISyncDriver. The driver is basically a default sync client that handle some of the mess for us. To create the driver, we need to pass it a data source:
syncDriver = [[ISyncSessionDriver sessionDriverWithDataSource:self] retain]; [syncDriver setDelegate:self]; [self setPreferredSyncMode:ISyncSessionDriverModeFast]; SEL syncSEL = @selector(client:willSyncEntityNames:); [[syncDriver client] setSyncAlertHandler:self selector:syncSEL]; [syncDriver sync];
In our example application, we are going to hold on to a reference to the syncDriver object so that we can request syncs whenever it is appropriate. To be a data source for the ISyncSessionDriver class, the object needs to implement the protocol ISyncSessionDriverDataSource. While this protocol has a few optional methods, pretty much all of the methods in the protocol are required. In the example application for this article, the application’s delegate is also the data source and the delegate. I would not recommend this for a production application as it makes the application delegate pretty big; it is sufficient for this example.
Once the syncDriver has been initialized, I want to request a sync immediately on start up. This makes sure that the application is dealing with fresh data as opposed to something that may have changed while the application was not running1. Just before I call [syncDriver sync] though I do want to flag my object as the handler for sync alerts. This is a callback method used by the sync engine to tell my application that a sync is occurring on data that I care about and it gives me the opportunity to be apart of that sync. Once I have set myself up as a receiver for those alerts it is time to perform the first sync.
There are essentially three phases when your application is going to perform a sync. Two of those phases our application will have a direct involvement with. The first phase, called the push phase, is where we send our data to the Truth. The Truth is a database that lives on each Mac and keeps track of all the data that is being synced. The Truth handles all merging of data and each application can request a copy of data from it.
Push
Entity names are similar to bundle identifiers in that they uniquely name a data object. In this example we are syncing with the system bookmarks so our two entity names we are dealing with are com.apple.Bookmark and com.apple.Folder.
In the first phase of a sync, we push our data to the Truth. Since this example application does not retain any data this will be very quick. The first part of this push, our application needs to list all of the data objects it knows about for each entity type. The method that handles this is -recordsForEntityName: moreComing: error:. This method returns a dictionary of all the data for a specific entity name.
In that NSDictionary, the key is the unique identifier for that record which is provided by the Truth. The object is another NSDictionary with the properties of the object contained within. In our example application, this method is implemented as follows:
-(NSDictionary*)recordsForEntityName:(NSString*)entityName moreComing:(BOOL*)moreComing error:(NSError**)outError { NSMutableDictionary *dict = [NSMutableDictionary dictionary]; for (NSString *recordID in [[self recordLookup] allKeys]) { id object = [self objectForRecordIdentifier:recordID]; NSString *objectEntityName = [[object class] entityName]; if (![objectEntityName isEqualToString:entityName]) { continue; } [dict setValue:[object fullRecord] forKey:recordID]; } *moreComing = NO; return dict; }
The more coming flag is used when an application wants to send the data in batches. If it is set to YES then the method will be called repeatedly with the same entityName until a NO is received. NOTE:This method is normally only called on the first sync.
Once our application had told the Truth about all of the data it knows about, the next part of the push phase is to send any changes that have occurred since the last sync. This is handled in one of two methods. If our application can accurately describe each property that has changed then the -changesForEntityName: moreComing: error: method can be used. If, however, we only know that an object has changed but not which specific properties, then the -changedRecordsForEntityName:moreComing:error: method would be used. The former method is easier on the sync since it does not need to resolve that information itself. In our implementation we will be using the latter implementation to make our lives easier.
-(NSDictionary*)changedRecordsForEntityName:(NSString*)entityName moreComing:(BOOL*)moreComing error:(NSError**)outError { NSMutableDictionary *dict = [NSMutableDictionary dictionary]; for (NSString *key in [[self recordLookup] allKeys]) { id object = [[self recordLookup] objectForKey:key]; if (![object updated]) continue; if (![[object entityName] isEqualToString:entityName]) continue; [dict setObject:[object fullRecord] forKey:key]; } return dict; }
Here we are looping through all of our objects (we store them in a NSDictionary as well to make life easier in the demo) and if an object has the right entity name as well as being flagged as updated we send its data back. Again, we can set the moreComing flag to YES if we need to handle the data in batches.
Mingle
Once we have pushed our data and our changes up to the server and all of the other participants of this sync session have done the same, the Truth mingles the data. It will do its best to merge everything and if it fails it will ask the user to decide which change is the correct one.
Clients have no participation in this phase of the sync session. Once the mingle is complete the next phase is started automatically by the ISyncSessionDriver.
Push
Once the data has been mingled, the Truth will then inform each client of any changes that have taken place. It is expected that each client will accept these changes and store them in their local data stores. The method that is called is -applyChange:forEntityName:remappedRecordIdentifier:formattedRecord:error:. This method will be called for each entity that has changed as a result of the mingle so it needs to be as efficient as possible.
When this method is called, a ISyncChange object is passed in. This object contains all of the information about the change that needs to be performed. The first thing that needs to be looked at is what type of change it is. A change can be a new record, deleting an existing record or updating a record. Our implementation of this method is as follows:
-(ISyncSessionDriverChangeResult)applyChange:(ISyncChange*)change forEntityName:(NSString*)entityName remappedRecordIdentifier:(NSString**)outRecordIdentifier formattedRecord:(NSDictionary**)outRecord error:(NSError**)err { NSDictionary *errDict = nil; id record = nil; switch ([change type]) { case ISyncChangeTypeDelete: [[self recordLookup] removeObjectForKey:[change recordIdentifier]]; return ISyncSessionDriverChangeAccepted; case ISyncChangeTypeAdd: if ([entityName isEqualToString:[FolderEntity entityName]]) { record = [[FolderEntity alloc] init]; } else if ([entityName isEqualToString:[BookmarkEntity entityName]]) { record = [[BookmarkEntity alloc] init]; } else { errDict = [NSDictionary dictionaryWithObject:@"Unknown type" forKey:NSLocalizedDescriptionKey]; *err = [NSError errorWithDomain:@"CIMGF" code:8002 userInfo:errDict]; return ISyncSessionDriverChangeError; } [record setRecordIdentifier:[change recordIdentifier]]; [[self recordLookup] setObject:record forKey:[change recordIdentifier]]; break; case ISyncChangeTypeModify: record = [[self recordLookup] objectForKey:[change recordIdentifier]]; break; default: errDict = [NSDictionary dictionaryWithObject:@"Unknown type" forKey:NSLocalizedDescriptionKey]; *err = [NSError errorWithDomain:@"CIMGF" code:8001 userInfo:errDict]; return ISyncSessionDriverChangeError; } for (NSDictionary *changeDict in [change changes]) { NSString *action = [changeDict valueForKey:ISyncChangePropertyActionKey]; NSString *name = [changeDict valueForKey:ISyncChangePropertyNameKey]; if ([name isEqualToString:kRecordEntityName]) { continue; } if ([action isEqualToString:ISyncChangePropertyClear]) { [record setNilValueForKey:name]; continue; } id value = [changeDict valueForKey:ISyncChangePropertyValueKey]; [record setValue:value forKey:name]; } [record setUpdated:NO]; return ISyncSessionDriverChangeAccepted; }
In this method we use a switch to determine what type of change that it is. If it is a delete we remove the record from our global store and return happy. If it is an add then we create a new empty object and set the recordIdentifier. Lastly, if it is a change we retrieve the record from our global store.
Once the object that is going to be changed has been referenced we loop through the changes which are stored in an array inside of the ISyncChange object. Each object in the array is a dictionary containing up three values. The first value has a key of ISyncChangePropertyActionKey and the value can either be ISyncChangePropertySet or ISyncChangePropertyClear. If it is a clear then we are to set the property to nil. Otherwise we change the property to the value stored under key ISyncChangePropertyValueKey. The name of the property is stored under the key ISyncChangePropertyNameKey. If our properties use the same name as the sync objects they represent (and in our case they do) then this step is performed in a loop using KVC to update each property.
Once we have updated the object we return ISyncSessionDriverChangeAccepted to let the sync engine know that everything is happy. We could have returned ISyncSessionDriverChangeIgnored or ISyncSessionDriverChangeRejected if we did not accept the change for some reason. In either of those cases the object and its changes would not be sent to us again unless it changed in the future. Lastly, if there is a problem, we can return ISyncSessionDriverChangeError if there was an error. If we return that, however, the sync engine expects us to populate the NSError pointer before returning.
There are two other pointers that get passed in that should be mentioned. The first one, called outRecordIdentifier in this example is used in the case where we do not want to use the recordIdentifier being passed to us. Perhaps we have our own internal schema or we want to reference this record by another identifier. If we set this pointer then the Truth will remember that change and use the new identifier in the future when talking to us. It will not use this new identifier when talking to anyone else though. This is useful when syncing with a database that uses something other than a guid for uniqueness. The second one, called outRecord in this example, is used to update the object. Perhaps we had a field on new records or change a field; in either case those changes can be sent back to the Truth via this pointer.
Other Data Source methods
That is the major points of a sync. We of course have a lot more flexibility to cover edge cases and we can make changes at any step in the process via the delegate methods but those are the points of contact for passing data back and forth. Other than those complicated touch points, there are a few other methods that need to be implemented to make a sync work. Those methods are more administrative and far less complicated.
- clientIdentifier
This is the unique string that identifies this client to the Truth. I like to use the bundle identifier here just to make things simple. - schemaBundleURLs
This method returns an array of NSURL objects. Each NSURL object points to a schema either on disk or on the net that describes the data to be stored in the Truth. If we are creating a new set of data then we would need to generate a schema (and there is a template in Xcode for that purpose). If not then we need to reference the existing schema. - entityNamesToSync
It is not necessary to sync every entity in a schema (although in our case we do since there are only two). If we only care about a subset of the data then we can request to sync only those entities we care about. In any case we pass back an array of strings here to let the sync engine know which entities we care about. - preferredSyncModeForEntityName
A fast sync is always the goal. However if we need to refresh an object or perform a slow sync (useful when a large amount of data has changed or we lost our copy of the data) then this is the method that determines it. This method will be called once for each entity we want to sync. - clientDescriptionURL
The client description is a plist file that describes this client to the sync engine. For example, the icon used in dialogs is defined in this plist along with the human readable name of the sync client, etc. Also in this plist we define what properties of each entity we want synced. If we only want a subset of the entities we can control that here.
Conclusion
I would not be at all surprised if that was as clear as mud. The sync services framework is complicated, no doubt about that. That complication has a purpose though, it is incredibility powerful as well. Now that I have scratched the surface in this article I will be adding other articles to explain some of the more complicated aspects such as adding properties to an existing schema, mid-sync data changing and more.
If a part of this is unclear, please post a comment and I will update it to help clarify. I would also recommend downloading the example application that is attached as I find the code much easier to understand.
1. There is an option to have a helper tool that sync services will run when your application is not running but that is beyond the scope of this article.
2 comments


You. Are. A. God.
I spent last week banging my head against the wall trying to incorporate syncing into my upcoming Mail Act-On 2 (plug in for Mail) — trying to futz in new entities etc into Mail’s existing schema — and I had basically given up –
This provides a much more clear approach and it may indeed work — fingers crossed.
Thanks Thanks Many Many Thanks
Thanks smorr, glad you liked the article.
BTW, if any who reads this or any other article and feels the need to respond on Twitter — DON’T!
I will not respond to twitter comments about CIMGF. Put the comments here where they can be preserved and used by the other readers.
Thanks!