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:
- The Darwin Notification Centre (a
CFNotificationCenter
) can send system-wide notifications. Unfortunately, the notifications have no content other than the name. - The shared group folder (or a subfolder) can be used as a GCD event source. The source is given an event handler block which it executes whenever the contents of the folder change. This means that information can be transferred by serialising data and writing it to the folder, where other members will be alerted and can read it in.
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 NSManagedObjectID
s 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 NSManagedObjectID
s 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:
Limitations
- When creating a context and linking it to a persistent store for the first time (for example, when an app is starting up), the folder should be cleared, since any updates not processed will already be present.
- For more complex setups each listening process should have its own folder, and each writing process should write changes to all of the folders.
- It is not a supported use of the notification or the merge method and may fail to work in a future version of iOS.
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.