App Groups were introduced in iOS 8 to allow data to be shared between separate processes running on an iOS device. An app group can have multiple members — one or more standard iOS apps, extensions or Watch apps.

Membership of an App Group gives access to a shared NSUserDefaults suite, a shared folder… and nothing else. App Group processes have no channels to communicate, there is no shared notification centre (OS X has NSDistributedNotificationCenter) or other mechanism of sending messages specific to App Groups. Updates to the shared user defaults do not generate a NSUserDefaultsDidChangeNotification.

An SQLite-backed Core Data persistent store can live quite happily in the shared folder, where all members of the App Group can access it. However, if one member makes changes to the store, they are not picked up automatically by the others.

If one member is doing work in the background (for example, getting updates from a Bluetooth peripheral or performing network activity) then this can cause a problem if another member is also running. This article discusses strategies for dealing with Core Data updates across the members of an App Group.

Communication

There are two methods of communicating between members of an App Group:

Core Data - the sledgehammer approach

Once a member has been alerted that the persistent store has been updated, the simplest way to obtain those updates is to reset the managed object context. Any references to existing managed objects will become invalid at that point, so fetch requests will need to be re-run, table views reloaded and so forth. The Darwin Notification Centre can be used with this approach since a simple indication of change is all that is required.

This approach is effective but can make for a jarring user experience. If the user is viewing a table of data which is reloaded without warning this can be confusing, particularly if different data are suddenly shown with no animation to give a visual clue about what just happened.

Core Data with finesse

In an ideal world the specific set of changes would be applied to the context, bringing it in line with what is already on disk. These changes would be picked up by fetched results controllers and anything else listening to notifications from the context, updating the UI properly.

Core Data already supports the situation where an external process alters a persistent store on disk; this is precisely how iCloud works.

When records are imported from iCloud, the NSPersistentStoreDidImportUbiquitousContentChangesNotification is posted. Note that the notification is related to the persistent store, not a context - the context knows nothing about these changes at this point.

The notification’s userInfo dictionary contains keys for updated, inserted and deleted objects. Against each key is a collection of NSManagedObjectIDs representing the objects affected by each change. You bring the context up to date by passing the notification to the context’s mergeChangesFromContextDidSaveNotification: method.

When changes are merged like this, the updates are applied to the context, the appropriate notifications are sent out, and the UI is updated smoothly. Can this be done for updates coming from other App Group members instead of iCloud?

Faking iCloud updates

The appropriate time to send a fake iCloud notification is when an App Group member saves to the persistent store. At this point the file on disk has been updated. Listening for the NSManagedObjectContextDidSaveNotification will yield a userInfo dictionary with a similar structure to the iCloud notification, except it contains managed objects instead of object IDs. This dictionary contains the information you need to pass on, but with no direct way of communicating between members it must first be written to disk.

Neither NSManagedObject nor NSManagedObjectID implement NSCoding. However, an object ID can be serialized via the absolute string of its URIRepresentation. To convert the userInfo dictionary to a form that can be serialized, the following code is required:


- (NSDictionary*)serializableDictionaryFromSaveNotification:(NSNotification*)saveNotification
{
  NSMutableDictionary *saveInfo = [NSMutableDictionary dictionary];
  for (NSString *key in @[NSInsertedObjectsKey,NSDeletedObjectsKey,NSUpdatedObjectsKey]) {
    NSArray *managedObjects = saveNotification.userInfo[key];
    if (!managedObjects) continue;

    NSMutableArray *objectIDRepresentations = [NSMutableArray array];
    for (NSManagedObject *object in managedObjects) {
      NSManagedObjectID *objectID = [object objectID];
      NSURL *URIRepresentation = [objectID URIRepresentation];
      NSString *objectIDValue = [URIRepresentation absoluteString];
      [objectIDRepresentations addObject:objectIDValue];
    }

    saveInfo[key] = [objectIDRepresentations copy];
  }
  return [saveInfo copy];
}

This dictionary can then be written to a specified location in the shared folder. The write has to be atomic, to ensure that the other processes don’t start reading the file before it is written. NSDictionary has a writeToURL: atomically: method that can be used for this.

Interested members of the app group can then detect the writing of this file by monitoring a folder using GCD. The details of this can be seen in the FolderWatcher class in the linked project.

The files should be processed in time order, though in most cases there will only be a single file to process. The following code gives a date-sorted array of the contents of a folder:


NSError *error = nil;
NSFileManager *fileManager = [NSFileManager defaultManager];
NSArray *files = [fileManager contentsOfDirectoryAtURL:url includingPropertiesForKeys:@[NSURLAddedToDirectoryDateKey] options:0 error:&error];
if (error) NSLog(@"Error getting directory contents %@", error);

if ([files count] == 0) return;

NSArray *sortedFiles = [files sortedArrayUsingComparator:^NSComparisonResult(NSURL* obj1, NSURL* obj2) {

NSDate *added1 = nil, *added2 = nil;
NSError *error1 = nil, *error2 = nil;

BOOL extracted1 = [obj1 getResourceValue:&added1 forKey:NSURLAddedToDirectoryDateKey error:&error1];
BOOL extracted2 = [obj2 getResourceValue:&added2 forKey:NSURLAddedToDirectoryDateKey error:&error2];
if (extracted1 && extracted2) {
  return [added1 compare:added2];
}
NSLog(@"Error extracting: %@ and/or %@", error1, error2);
return NSOrderedSame;
}];

By asking for the NSURLAddedToDirectoryDateKey up front, it can be used for sorting. The above code could be shortened but is displayed in full for readability.

Each file in the list can be converted back into a dictionary using NSDictionary’s initWithContentsOfURL: method. The string representations of managed object IDs can be converted to NSManagedObjectIDs using an inverse of the process above:


-(NSNotification*)importNotificationFromDictionary:(NSDictionary*)updateDictionary coordinator:(NSPersistentStoreCoordinator*)coordinator
{
  NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
  for (NSString *key in [updateDictionary allKeys]) {
    NSMutableArray *newObjectIDs = [NSMutableArray array];
    for (NSString *objectID in updateDictionary[key]) {
      NSURL *URIRepresentation = [NSURL URLWithString:objectID];
      NSManagedObjectID *managedObjectID = [coordinator managedObjectIDForURIRepresentation:URIRepresentation];
      [newObjectIDs addObject:managedObjectID];
    }
    userInfo[key] = newObjectIDs;
  }

  NSNotification *importNotification = [NSNotification notificationWithName:NSPersistentStoreDidImportUbiquitousContentChangesNotification object:coordinator userInfo:userInfo];
  return importNotification;
}

The NSPersistentStoreCoordinator is required both to convert the URI representations to managed object IDs and to act as the object for the notification itself.

The sorted list of files is processed and merged in order:


[self.context performBlockAndWait:^{
  NSPersistentStoreCoordinator *coordinator = self.context.persistentStoreCoordinator;

  for (NSURL *fileURL in sortedFiles) {
    NSDictionary *updateDictionary = [[NSDictionary alloc] initWithContentsOfURL:fileURL];

    NSNotification *importNotification = [self importNotificationFromDictionary:updateDictionary coordinator:coordinator];
    [self.context mergeChangesFromContextDidSaveNotification:importNotification];
    NSError *deleteError = nil;
    [fileManager removeItemAtURL:fileURL error:&deleteError];
    if (deleteError) NSLog(@"Error removing %@ : %@",fileURL,deleteError);
  }
}];
 

performBlockAndWait: is used because removing files from the directory also triggers the GCD event handler. By blocking until the current batch of files are cleared, multiple reads or overlaps are prevented.

This diagram summarizes the process:

Passing updates between members of an App Group

Limitations

The real solution

The limitations of the Darwin Notification Centre and the complexities of writing to and reading from a file make the above solution suitable for only a small number of cases.

A better solution would be for an NSNotificationCenter equivalent of the shared user defaults object. This would allow NSNotification objects to be passed between members of an App Group. Then, you can pass and merge the NSManagedObjectContextDidSaveNotification directly. Greater communication between app group members would have many other benefits as well. This is filed as rdar://21118929.

Coming Changes

Announced at WWDC 2015, NSManagedObjectContext has a new method, mergeChangesFromRemoteContextSave: intoContexts:, which makes the above process somewhat easier by allowing the dictionary of URL objects to be used directly instead of having to convert them back into managed object IDs, and prevents the need to use the incorrect notification. However, there is still no straightforward method of transmitting this information between app group members.

Sample code referenced in this post can be downloaded here.

Richard Turton

Cocoa Engineer

MartianCraft is a US-based mobile software development agency. For nearly two decades, we have been building world-class and award-winning mobile apps for all types of businesses. We would love to create a custom software solution that meets your specific needs. Let's get in touch.