Taming iCloud - Learning Core Data for iOS (2014)

Learning Core Data for iOS (2014)

15. Taming iCloud

Only those who attempt the absurd can achieve the impossible.

Albert Einstein

In Chapter 14, “iCloud,” the basic steps to integrate Core Data with iCloud were demonstrated. Still, several areas need to be addressed to ensure that the user’s iCloud experience is as seamless as possible. For example, when multiple iCloud-enabled devices are in play, there’s a risk that some data may be duplicated. Techniques for handling data duplication will be discussed as they are implemented in Grocery Dude. You’ll also be shown how to seed iCloud from data found in a local “Non-iCloud” persistent store. This approach ensures that users don’t lose data when they start using iCloud for the first time, even if they had existing data on separate devices.

De-Duplication

When iCloud is used across multiple devices, there’s a risk that duplicate items may be created by the user or seeded to iCloud. In particular, when an item is created on an offline device, it won’t appear on other devices until Internet connectivity is restored. In the meantime, if an item with the same name is created on another device, then duplicate items will result. To deal with this scenario, a new Deduplicator class will be used to selectively delete the oldest of the duplicate items. In your own applications, you may wish to add other logic to determine the most appropriate duplicate to delete. For example, you may wish to compare attribute values such as a NSUUID or even take into account relationships before choosing which duplicate to delete. Beyond that, you could even merge values with custom logic, if that makes sense for your application.


Note

To continue building the sample application, you’ll need to have added the previous chapter’s code to Grocery Dude. Alternatively, you may download, unzip, and use the project up to this point from http://www.timroadley.com/LearningCoreData/GroceryDude-AfterChapter14.zip. Any time you start using an Xcode project from a ZIP file, it’s good practice to click Product > Clean. This practice ensures there’s no residual cache from previous projects using the same name.

You will also need to ensure that an appropriate profile has been selected as the Code Signing Identity in the Code Signing section of the Build Settings tab of the Grocery Dude target.

If you are using the provided sample code, toggle the iCloud Capability off and then on again to ensure that your own development team is used.


Update Grocery Dude as follows to add the Deduplicator class:

1. Select the Generic Core Data Classes group.

2. Click File > New > File....

3. Create a new iOS > Cocoa Touch > Objective-C class and then click Next.

4. Set Subclass of to NSObject and Class name to Deduplicator and then click Next.

5. Ensure the Grocery Dude target is ticked and then create the class by clicking Create in the Grocery Dude project directory.

Before objects can be de-duplicated, they must first be identified as duplicates. To identify an object as a duplicate, you’ll need to choose an attribute that can be used to determine uniqueness. In other words, choose an attribute whose value should always be unique. In the case of Grocery Dude, the unique attributes for each entity where de-duplication will be enabled are shown in Table 15.1.

Image

Table 15.1 Unique Attribute Names

Once a unique attribute has been chosen, it can be used to create a list showing how many objects have the unique value. Ideally, only one object should have the same unique attribute value. If more than one object has the same unique attribute value, it is considered a duplicate. Listing 15.1shows the code involved in retrieving an array of duplicate attribute values for a given entity.

Listing 15.1 Deduplicator.m: duplicatesForEntityWithName


+ (NSArray*)duplicatesForEntityWithName:(NSString*)entityName
withUniqueAttributeName:(NSString*)uniqueAttributeName
withContext:(NSManagedObjectContext*)context {

// GET UNIQUE ATTRIBUTE
NSDictionary *allEntities =
[[context.persistentStoreCoordinator managedObjectModel] entitiesByName];
NSAttributeDescription *uniqueAttribute =
[[[allEntities objectForKey:entityName] propertiesByName]
objectForKey:uniqueAttributeName];

// CREATE COUNT EXPRESSION
NSExpressionDescription *countExpression = [NSExpressionDescription new];
[countExpression setName:@"count"];
[countExpression setExpression:
[NSExpression expressionWithFormat:@"count:(%K)",uniqueAttributeName]];
[countExpression setExpressionResultType:NSInteger64AttributeType];

// CREATE AN ARRAY OF _UNIQUE_ ATTRIBUTE VALUES
NSFetchRequest *fetchRequest =
[[NSFetchRequest alloc] initWithEntityName:entityName];
[fetchRequest setIncludesPendingChanges:NO];
[fetchRequest setFetchBatchSize:100];
[fetchRequest setPropertiesToFetch:
[NSArray arrayWithObjects:uniqueAttribute, countExpression, nil]];
[fetchRequest setPropertiesToGroupBy:[NSArray arrayWithObject:uniqueAttribute]];
[fetchRequest setResultType:NSDictionaryResultType];

NSError *error;

NSArray *instances = [context executeFetchRequest:fetchRequest error:&error];
if (error) {NSLog(@"Fetch Error: %@", error);}

// RETURN AN ARRAY OF _DUPLICATE_ ATTRIBUTE VALUES
NSArray *duplicates = [instances filteredArrayUsingPredicate:
[NSPredicate predicateWithFormat:@"count > 1"]];
return duplicates;
}


The duplicatesForEntityWithName method returns an array of attribute values that have been identified as duplicates. This method requires that an entity name, unique attribute name, and context be given. To provide the duplicates array, a fetch request is created that returns each unique attribute value alongside a count of how many objects have these unique values. Any unique attribute value that is seen more than once is considered a duplicate. Duplicate attribute values are added to a predicate filtered array, which is then returned from the method.

Update Grocery Dude as follows to implement duplicatesForEntityWithName:

1. Add #import <CoreData/CoreData.h> to the top of Deduplicator.h.

2. Add the code from Listing 15.1 to the bottom of Deduplicator.m before @end.

The next step is to add a new method called deDuplicateEntityWithName to Deduplicator. This new method will be used to de-duplicate duplicated objects. The de-duplication process will be performed in the import context, which runs in the background. This ensures the user interface isn’t impacted when a substantial amount of duplicates are being processed. The other benefit of using the import context is that the changes are immediately reflected in the main context once the import context is saved. This is due to the context hierarchy, which has been in place since Chapter 11, “Background Processing.”

The first thing that the deDuplicateEntityWithName method will do is to use the existing duplicatesForEntityWithName method to obtain an array of duplicate attribute values. Those values are then used when creating a fetch request configured with an IN predicate. An IN predicate constrains the fetch request to return only objects that have a unique attribute value matching any value in the list of given duplicate values.

Once the fetch is performed, an array of all duplicate objects is returned that will be used to remove duplicate objects. Cleanup involves saving the context hierarchy and turning each object back into a fault using the existing Faulter class. Listing 15.2 shows the code involved.

Listing 15.2 Deduplicator.m: deDuplicateEntityWithName


+ (void)deDuplicateEntityWithName:(NSString*)entityName
withUniqueAttributeName:(NSString*)uniqueAttributeName
withImportContext:(NSManagedObjectContext*)importContext {

[importContext performBlock:^{

NSArray *duplicates =
[Deduplicator duplicatesForEntityWithName:entityName
withUniqueAttributeName:uniqueAttributeName
withContext:importContext];
// FETCH DUPLICATE OBJECTS
NSFetchRequest *fetchRequest =
[[NSFetchRequest alloc] initWithEntityName:entityName];
NSArray *sortDescriptors =
[NSArray arrayWithObjects:
[NSSortDescriptor sortDescriptorWithKey:uniqueAttributeName
ascending:YES],nil];
[fetchRequest setSortDescriptors:sortDescriptors];
[fetchRequest setPredicate:[NSPredicate predicateWithFormat:@"%K IN (%@.%K)",
uniqueAttributeName, duplicates, uniqueAttributeName]];
[fetchRequest setFetchBatchSize:100];
[fetchRequest setIncludesPendingChanges:NO];
NSError *error;
NSArray *duplicateObjects =
[importContext executeFetchRequest:fetchRequest error:&error];
if (error) {NSLog(@"Fetch Error: %@", error);}

// DELETE DUPLICATES
NSManagedObject *lastObject;
for (NSManagedObject *object in duplicateObjects) {
if (lastObject) {
if ([[object valueForKey:uniqueAttributeName]
isEqual:[lastObject valueForKey:uniqueAttributeName]]) {

// Add deletion logic here

// Save & fault objects
[Faulter faultObjectWithID:object.objectID
inContext:importContext];
[Faulter faultObjectWithID:lastObject.objectID
inContext:importContext];
}
}
lastObject = object;
}
}];
}


Update Grocery Dude as follows to implement deDuplicateEntityWithName:

1. Add #import "Faulter.h" to the top of Deduplicator.m.

2. Add the code from Listing 15.2 to the bottom of Deduplicator.m before @end.

3. Add the code shown in Listing 15.3 to Deduplicator.h before @end. This code ensures that deDuplicateEntityWithName can be called from other classes.

Listing 15.3 Deduplicator.h: deDuplicateEntityWithName


+ (void)deDuplicateEntityWithName:(NSString*)entityName
withUniqueAttributeName:(NSString*)uniqueAttributeName
withImportContext:(NSManagedObjectContext*)importContext;


When deciding which duplicate object to delete, it’s important to ensure that any device presented with the same duplicates will delete the same objects. To this end, the Deduplicator class will be updated to compare a new Date attribute called modified. This attribute will indicate which object is the oldest and therefore should be deleted. As a second line of defense against duplicates, objects with the least non-nil attribute values will be deleted if the modified dates match. Failing that, de-duplication will be skipped.

To add the modified attribute without causing existing installations to crash with incompatible models, a new model called Model 9 will be added.


Note

If a user runs multiple versions of an iCloud application with different data models, synchronization of this data between his or her devices will not work. The users will need to upgrade all of their devices to the latest version in order for synchronization to resume. Be aware of this constraint during your testing, too.


Update Grocery Dude to implement the modified attribute:

1. Select Model.xcdatamodeld.

2. Click Editor > Add Model Version....

3. Click Finish to accept Model 9 as the version name.

4. Ensure Model 9.xcdatamodel is selected.

5. Add an attribute called modified to the Item entity and then set its type to Date.

6. With the new modified attribute selected, un-tick Optional using the Data Model Inspector (Option+image+3).

7. Set the Default Value of the modified attribute to 1970-01-01 12:00:00 +0000.

8. Copy the modified attribute from the Item entity to the Unit entity and the Location entity. There’s no need to create the modified attribute in the LocationAtHome and LocationAtShop entities because they inherit from the Location entity. You may need to switch the Editor Style toTable so you can copy and paste attributes, depending on your version of Xcode.

9. Select all entities in Model 9 and then regenerate the NSManagedObject subclasses, replacing the existing files (via Editor > Create NSManagedObject Subclass...). Don’t forget to ensure that the Grocery Dude target is selected before replacing the existing files.

10. Select Model.xcdatamodeld and then set the current model to Model 9 using File Inspector (Option+image+1), as shown on the right of Figure 15.1.

Image

Figure 15.1 Model 9 is now the current model.

With the new model in place, the editing views need to be configured to update the modified date whenever an object is accessed. The existing refreshInterface method found in each view is a reasonable enough place for this new code because it already has a pointer to the object being edited. The code will differ slightly for each editing view, as shown in Table 15.2.

Image

Table 15.2 Code to Update Object Modified

Update Grocery Dude as follows to ensure the modified attribute value is updated to the current date and time when objects are edited:

1. Add item.modified = [NSDate date]; to the refreshInterface method of ItemVC.m on the line after the item object has been created.

2. Add unit.modified = [NSDate date]; to the refreshInterface method of UnitVC.m on the line after the unit object has been created.

3. Add locationAtHome.modified = [NSDate date]; to the refreshInterface method of LocationAtHomeVC.m on the line after the locationAtHome object has been created.

4. Add locationAtShop.modified = [NSDate date]; to the refreshInterface method of LocationAtShopVC.m on the line after the locationAtShop object has been created.

The deletion logic to be added to the deDuplicateEntityWithName method of Deduplicator will require the ability to save the full context hierarchy. To facilitate this, a new method called saveContextHierarchy will be added to Deduplicator. This method walks the context hierarchy all the way up through the parents, processing pending changes and saving each context as required. Listing 15.4 shows the code involved.

Listing 15.4 Deduplicator.m: saveContextHierarchy


#pragma mark - SAVING
+ (void)saveContextHierarchy:(NSManagedObjectContext*)moc {
[moc performBlockAndWait:^{
if ([moc hasChanges]) {
[moc processPendingChanges];
NSError *error;
if (![moc save:&error]) {
NSLog(@"ERROR Saving: %@",error);
}
}
// Save the parent context, if any.
if ([moc parentContext]) {
[self saveContextHierarchy:moc.parentContext];
}
}];
}


Update Grocery Dude as follows to implement context hierarchy saving:

1. Add the code from Listing 15.4 to the bottom of Deduplicator.m before @end.

With the appropriate code in place to ensure objects have a modified date, the code to delete duplicated objects based on this attribute can now be implemented. Listing 15.5 shows the code involved.

Listing 15.5 Deduplicator.m: deDuplicateEntityWithName (Cont.)


NSLog(@"*** Deleting Duplicate %@ with %@ '%@' ***",

entityName, uniqueAttributeName, [object valueForKey:uniqueAttributeName]);

// DELETE OLDEST DUPLICATE...
NSDate *date1 = [object valueForKey:@"modified"];
NSDate *date2 = [lastObject valueForKey:@"modified"];

if ([date1 compare:date2] == NSOrderedAscending) {
[importContext deleteObject:object];
} else if ([date1 compare:date2] == NSOrderedDescending) {
[importContext deleteObject:lastObject];
}

// ..or.. DELETE DUPLICATE WITH LESS ATTRIBUTE VALUES (if dates match)
else if ([[object committedValuesForKeys:nil] count] >
[[lastObject committedValuesForKeys:nil] count]) {
[importContext deleteObject:lastObject];
} else {
NSLog(@"Skipped De-duplication, dates and value counts match");
}
[self saveContextHierarchy:importContext];


The logic to blindly delete the oldest object is a primitive way to achieve de-duplication. In some cases, the results of this hardline logic may annoy the users. For example, if a user created a new “apples” object not realizing that one already existed with a lovely photo of apples, that photo would be lost. In your own projects, consider adding further logic to merge old attribute values where a newer object has a nil value. For brevity, there is little value in adding this to Grocery Dude. However you choose to handle your de-duplication strategy, the fundamentals are now in place to support your desired logic.

Update Grocery Dude as follows to implement duplicate object deletion logic:

1. Add the code from Listing 15.5 to the deDuplicateEntityWithName method of Deduplicator.m by replacing the comment //Add deletion logic here.

The final step in implementing de-duplication is to call deDuplicateEntityWithName at an appropriate time. Listing 15.6 shows the code involved.

Listing 15.6 PrepareTVC.m: viewDidAppear


[cdh.context performBlock:^{
[Deduplicator deDuplicateEntityWithName:@"Item"
withUniqueAttributeName:@"name"
withImportContext:cdh.context];

[Deduplicator deDuplicateEntityWithName:@"Unit"
withUniqueAttributeName:@"name"
withImportContext:cdh.context];

[Deduplicator deDuplicateEntityWithName:@"LocationAtHome"
withUniqueAttributeName:@"storedIn"
withImportContext:cdh.context];

[Deduplicator deDuplicateEntityWithName:@"LocationAtShop"
withUniqueAttributeName:@"aisle"
withImportContext:cdh.context];
}];


Update Grocery Dude as follows to enable de-duplication:

1. Add #import "Deduplicator.h" to the top of PrepareTVC.m.

2. Add the code from Listing 15.6 to the bottom of the viewDidAppear method of PrepareTVC.m.

Run the application and create a new item with the same name as another item. One item should be deleted automatically. The expected result is shown in Figure 15.2.

Image

Figure 15.2 De-duplication

Seeding

When users enable iCloud support in an application they already use, chances are they have data that needs to be migrated to iCloud. The technique that will be used to migrate non-iCloud data to iCloud in Grocery Dude is deep copy, which was implemented in Chapter 9, “Deep Copy.” Although more CPU intensive than migratePersistentStore, this approach provides the granularity of per-entity migration and allows object values to be updated as they are migrated. With the ability to update attribute values as they are migrated, the modified attribute can be set to today’s date. This ensures that the de-duplication process is more effective when default data with the same date on multiple devices is migrated. Listing 15.7 shows new CoreDataImporter code required to update objects with a modified attribute of today’s date as they’re copied.

Listing 15.7 CoreDataImporter.m: copyUniqueObject:toContext


// Update modified date as appropriate
if ([[[copiedObject entity] attributesByName] objectForKey:@"modified"]) {
[copiedObject setValue:[NSDate date] forKey:@"modified"];
}


Update Grocery Dude as follows to ensure deep copy updates the modified date:

1. Add the code from Listing 15.7 to the copyUniqueObject:toContext method of CoreDataImporter.m on the line before return copiedObject;.

The seeding process requires a context that is not a child of parentContext, so the existing sourceContext cannot be used for this purpose. Instead, a new seedContext and seedCoordinator will be added to CoreDataHelper. In addition, a new seedStore will be added so the Non-iCloud store can be loaded as read-only, ready to be seeded to iCloud. Listing 15.8 shows the new properties required, along with a new UIAlertView intended to confirm that the user wants the seed to take place, and a BOOL property that prevents migration from being triggered twice.

Listing 15.8 CoreDataHelper.h


@property (nonatomic, readonly) NSManagedObjectContext *seedContext;
@property (nonatomic, readonly) NSPersistentStoreCoordinator *seedCoordinator;
@property (nonatomic, readonly) NSPersistentStore *seedStore;
@property (nonatomic, retain) UIAlertView *seedAlertView;
@property (nonatomic) BOOL seedInProgress;


Update Grocery Dude as follows to implement the new properties required for seeding:

1. Add the properties from Listing 15.8 to CoreDataHelper.h.

The seed context will be configured in the same way as the other contexts; however, it will have its own coordinator instead of a parent context. The code involved is shown in Listing 15.9.

Listing 15.9 CoreDataHelper.m: init


_seedCoordinator =
[[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:_model];
_seedContext = [[NSManagedObjectContext alloc]
initWithConcurrencyType:NSPrivateQueueConcurrencyType];
[_seedContext performBlockAndWait:^{
[_seedContext setPersistentStoreCoordinator:_seedCoordinator];
[_seedContext setMergePolicy:NSMergeByPropertyObjectTrumpMergePolicy];
[_seedContext setUndoManager:nil]; // the default on iOS
}];
_seedInProgress = NO;


Update Grocery Dude as follows to configure the seed properties:

1. Add the code from Listing 15.9 to the bottom of the init method of CoreDataHelper.m before [self listenForStoreChanges];.

The seeding process will rely on two new helper methods that will be added to the existing CORE DATA RESET section of CoreDataHelper.m. The first method, unloadStore, will be used to ensure that the given store does not exist. It does this by first removing the given store from its coordinator and then setting the store to nil.

The second method, removeFileAtURL, will be used to delete the old Non-iCloud store once seeding is finished. For brevity, there will be no check performed to ensure that seeding was successful before deleting the Non-iCloud data. In your own applications, you may wish to prompt the user prior to deleting the old store or implement code to detect seeding status prior to deletion.

The code involved with the new methods is shown in Listing 15.10.

Listing 15.10 CoreDataHelper.m: CORE DATA RESET


- (BOOL)unloadStore:(NSPersistentStore*)ps {
if (debug==1) {
NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
if (ps) {
NSPersistentStoreCoordinator *psc = ps.persistentStoreCoordinator;
NSError *error = nil;
if (![psc removePersistentStore:ps error:&error]) {
NSLog(@"ERROR removing store from the coordinator: %@",error);
return NO; // Fail
} else {
ps = nil;
return YES; // Reset complete
}
}
return YES; // No need to reset, store is nil
}
- (void)removeFileAtURL:(NSURL*)url {

NSError *error = nil;
if (![[NSFileManager defaultManager] removeItemAtURL:url error:&error]) {
NSLog(@"Failed to delete '%@' from '%@'",
[url lastPathComponent], [url URLByDeletingLastPathComponent]);
} else {
NSLog(@"Deleted '%@' from '%@'",
[url lastPathComponent], [url URLByDeletingLastPathComponent]);
}
}


Update Grocery Dude as follows to add the two new methods:

1. Add the code from Listing 15.10 to the bottom of the existing CORE DATA RESET section of CoreDataHelper.m.

The next step is to add a new method called loadNoniCloudStoreAsSeedStore. As its name suggests, this method is responsible for loading the Non-iCloud store as the source store used to seed existing data to iCloud. This method is similar to the loadStore method already in place withinCoreDataHelper.m. Listing 15.11 shows the code involved.

Listing 15.11 CoreDataHelper.m: loadNoniCloudStoreAsSeedStore


#pragma mark - ICLOUD SEEDING
- (BOOL)loadNoniCloudStoreAsSeedStore {
if (debug==1) {
NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
if (_seedInProgress) {
NSLog(@"Seed already in progress ...");
return NO;
}

if (![self unloadStore:_seedStore]) {
NSLog(@"Failed to ensure _seedStore was removed prior to migration.");
return NO;
}

if (![self unloadStore:_store]) {
NSLog(@"Failed to ensure _store was removed prior to migration.");
return NO;
}

NSDictionary *options =
@{
NSReadOnlyPersistentStoreOption:@YES
};
NSError *error = nil;
_seedStore = [_seedCoordinator addPersistentStoreWithType:NSSQLiteStoreType
configuration:nil
URL:[self storeURL]
options:options error:&error];
if (!_seedStore) {
NSLog(@"Failed to load Non-iCloud Store as Seed Store. Error: %@", error);
return NO;
}
NSLog(@"Successfully loaded Non-iCloud Store as Seed Store: %@", _seedStore);
return YES;
}


Update Grocery Dude as follows to implement the new method to load a seed store:

1. Add the code from Listing 15.11 to the bottom of CoreDataHelper.m before @end.

The next step is to implement a method responsible for the migration of the Non-iCloud seed store to iCloud. The mergeNoniCloudDataWithiCloud method begins by scheduling a timer that will periodically refresh the table views. Next, the seedContext that runs in the background loads the Non-iCloud store as the seedStore. Provided this is successful, the entities to copy are specified by name and given to an instance of CoreDataImporter. A deep copy is then triggered, and once it completes the old store is removed. Listing 15.12 shows the code involved.

Listing 15.12 CoreDataHelper.m: mergeNoniCloudDataWithiCloud


- (void)mergeNoniCloudDataWithiCloud {
if (debug==1) {
NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
_importTimer = [NSTimer scheduledTimerWithTimeInterval:5.0
target:self
selector:@selector(somethingChanged)
userInfo:nil
repeats:YES];
[_seedContext performBlock:^{

if ([self loadNoniCloudStoreAsSeedStore]) {

NSLog(@"*** STARTED DEEP COPY FROM NON-ICLOUD STORE TO ICLOUD STORE ***");
NSArray *entitiesToCopy = [NSArray arrayWithObjects:
@"LocationAtHome",@"LocationAtShop",@"Unit",@"Item", nil];
CoreDataImporter *importer = [[CoreDataImporter alloc]
initWithUniqueAttributes:[self selectedUniqueAttributes]];
[importer deepCopyEntities:entitiesToCopy fromContext:_seedContext
toContext:_importContext];

[_context performBlock:^{
// Tell the interface to refresh once import completes
[[NSNotificationCenter defaultCenter]
postNotificationName:@"SomethingChanged" object:nil];
}];
NSLog(@"*** FINISHED DEEP COPY FROM NON-ICLOUD STORE TO ICLOUD STORE ***");
NSLog(@"*** REMOVING OLD NON-ICLOUD STORE ***");
if ([self unloadStore:_seedStore]) {

[_context performBlock:^{
// Tell the interface to refresh once import completes
[[NSNotificationCenter defaultCenter]
postNotificationName:@"SomethingChanged"
object:nil];

// Remove migrated store
NSString *wal = [storeFilename stringByAppendingString:@"-wal"];
NSString *shm = [storeFilename stringByAppendingString:@"-shm"];
[self removeFileAtURL:[self storeURL]];
[self removeFileAtURL:[[self applicationStoresDirectory]
URLByAppendingPathComponent:wal]];
[self removeFileAtURL:[[self applicationStoresDirectory]
URLByAppendingPathComponent:shm]];
}];
}
}
[_context performBlock:^{
// Stop periodically refreshing the interface
[_importTimer invalidate];
}];
}];
}


After the store is migrated, it is removed along with all of its accompanying wal and shm files that exist because WAL journaling is being used.

Update Grocery Dude as follows to implement the code to seed data to iCloud:

1. Add the code from Listing 15.12 to the bottom of the ICLOUD SEEDING section of CoreDataHelper.m.

Whenever the iCloud store is loaded, a check will be performed to see if there’s local data that needs to be migrated to iCloud. If there is, the user will be asked if he or she would like to merge it with iCloud. This requires a new method, confirmMergeWithiCloud, which will be used to show the seedAlertView. Listing 15.13 shows the code involved.

Listing 15.13 CoreDataHelper.m: confirmMergeWithiCloud


- (void)confirmMergeWithiCloud {
if (debug==1) {
NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
if (![[NSFileManager defaultManager] fileExistsAtPath:[[self storeURL] path]]) {
NSLog(@"Skipped unnecessary migration of Non-iCloud store to iCloud (there's no store file).");
return;
}
_seedAlertView = [[UIAlertView alloc] initWithTitle:@"Merge with iCloud?"
message:@"This will move your existing data into iCloud. If you don't merge now, you can merge later by toggling iCloud for this application in Settings."
delegate:self
cancelButtonTitle:@"Don't Merge"
otherButtonTitles:@"Merge", nil];
[_seedAlertView show];
}


Update Grocery Dude as follows to implement the code to confirm a merge with iCloud:

1. Add the code from Listing 15.13 to the bottom of the ICLOUD SEEDING section of CoreDataHelper.m.

2. Add the following code to the bottom of the alertView:clickedButtonAtIndex method of CoreDataHelper.m:

if (alertView == self.seedAlertView) {
if (buttonIndex == alertView.firstOtherButtonIndex) {
[self mergeNoniCloudDataWithiCloud];
}
}

3. Add the following code to the loadiCloudStore method of CoreDataHelper.m on the line before return YES;:

[self confirmMergeWithiCloud];

All code required to test seeding is now in place. Test seeding as follows:

1. Click Product > Clean.

2. Run Grocery Dude on a device or the iOS Simulator.

3. Press Home (Option+image+H) and enter the Settings App.

4. Scroll down, tap Grocery Dude, and ensure Enable iCloud is switched off.

5. Return to Grocery Dude and create an item called LOCAL ITEM.

6. Return to Settings > Grocery Dude and ensure Enable iCloud is switched on.

7. Ensure an account is signed in iCloud.

8. Return to Grocery Dude and click Ok. Then Merge as shown in Figure 15.3.

Image

Figure 15.3 Merging non-iCloud data with iCloud

The local data should be migrated into iCloud and the Non-iCloud store removed once the migration completes. If you have additional devices, create some recognizable local data on them and seed it to iCloud. Upon initial import, there will be a lot of seeding activity and then subsequent de-duplication work. Once a second device seeds to iCloud, it’s normal for many “Skipped faulting an object that is already a fault” messages to appear in the console log until de-duplication completes. Search the console log for “Using local storage: 0,” which should appear within a few minutes of enabling iCloud, so long as your device has Internet connectivity and has a consistent ubiquity container.

Developing with a Clean Slate

Throughout testing, you may wish to revert iCloud to a clean slate. Usually this is so you can observe application behavior as if a user had just installed the application for the first time. Instead of deleting an application’s iCloud directory contents each time you want to clear iCloud, you can now use the NSPersistentStoreCoordinator class method removeUbiquitousContentAndPersistentStoreAtURL. This method synchronously deletes everything in iCloud specific to the given persistent store, so it can take a little while. It then propagates this deletion to all participating devices when they come online. When you call this method, you’ll need to provide an options dictionary to help locate the files associated to the given persistent store, just as you would when adding a persistent store normally. It is important to ensure that there are no active persistent store coordinators in use when this method is called. Listing 15.14 shows the code involved in a new ICLOUD RESET section.

Listing 15.14 CoreDataHelper.m: ICLOUD RESET


#pragma mark - ICLOUD RESET
- (void)destroyAlliCloudDataForThisApplication {

if (![[NSFileManager defaultManager] fileExistsAtPath:[[_iCloudStore URL] path]]) {
NSLog(@"Skipped destroying iCloud content, _iCloudStore.URL is %@",
[[_iCloudStore URL] path]);
return;
}

NSLog(@"\n\n\n\n\n **** Destroying ALL iCloud content for this application, this could take a while... **** \n\n\n\n\n\n");

[self removeAllStoresFromCoordinator:_coordinator];
[self removeAllStoresFromCoordinator:_seedCoordinator];
_coordinator = nil;
_seedCoordinator = nil;

NSDictionary *options =
@{
NSPersistentStoreUbiquitousContentNameKey:@"Grocery-Dude"
//,NSPersistentStoreUbiquitousContentURLKey:@"ChangeLogs" // Optional since iOS7
};
NSError *error;
if ([NSPersistentStoreCoordinator
removeUbiquitousContentAndPersistentStoreAtURL:[_iCloudStore URL]
options:options
error:&error]) {
NSLog(@"\n\n\n\n\n");
NSLog(@"* This application's iCloud content has been destroyed *");
NSLog(@"* On ALL devices, please delete any reference to this application in *");
NSLog(@"* Settings > iCloud > Storage & Backup > Manage Storage > Show All *");
NSLog(@"\n\n\n\n\n");
abort();
/*
The application is force closed to ensure iCloud data is wiped cleanly.
This method shouldn't be called in a production application.
*/
} else {
NSLog(@"\n\n FAILED to destroy iCloud content at URL: %@ Error:%@",
[_iCloudStore URL],error);
}
}


The destroyAlliCloudDataForThisApplication method first checks a store exists at the given path. So long as it does, all stores are removed from all coordinators and an iCloud options dictionary is created. This dictionary is given along with the iCloud URL to theremoveUbiquitousContentAndPersistentStoreAtURL method. Upon success, you’re notified to wipe the application data from all devices. At this point, consider deleting the application entirely to more accurately simulate first time use, if that is your goal.

Update Grocery Dude as follows to implement iCloud reset:

1. Add the code from Listing 15.14 to the bottom of CoreDataHelper.m before @end.

2. Add the following line of code to the loadiCloudStore method of CoreDataHelper.m on the line before return YES;. This will trigger a complete wipe of this application’s iCloud data on all devices once the iCloud store loads.

[self destroyAlliCloudDataForThisApplication];

3. Run the application and wait for the iCloud store to be loaded and destroyed. The application should terminate automatically once the abort(); is reached. Figure 15.4 shows the expected result.

Image

Figure 15.4 iCloud reset

4. Once you receive confirmation that the iCloud content has been destroyed, stop the application and comment out the line of code added to the loadiCloudStore method of CoreDataHelper.m in step 2.

5. Ensure there are no references to Grocery Dude in Settings > iCloud > Storage & Backup > Manage Storage > Show All on all devices that synchronize with this iCloud account. The removeUbiquitousContentAndPersistentStoreAtURL method should have taken care of this for you, unless the device in question hasn’t received the propagated deletion yet.

6. In Xcode, click Product > Clean.

7. Sign in to https://developer.icloud.com/ and ensure there are no files in the Grocery Dude folder.


Note

If you have a device that is refusing to synchronize after you have reset its iCloud data, try resetting all settings via Settings > General > Reset All Settings. This approach won’t delete any application data, and it can resolve the “Error attempting to read ubiquity root url” error message.


Configurations

Although inappropriate in Grocery Dude, it’s good to be aware of Core Data configurations. A configuration allows you to assign different entities to different stores. This can be useful if you need to separate data that should be stored in iCloud from data that is best stored locally. For example, static or even rapidly changing device-specific data (such as a location) would be best placed in a Non-iCloud store. So long as it is unnecessary to replicate that data to other devices, it should stay local. A configuration is created using the Model Editor, as shown in Figure 15.5.

Image

Figure 15.5 Core Data configurations

Configuration creation requires a similar approach to creating an Entity or Fetch Request template. The default configuration can’t be deleted, so if you wanted to divide your entities into iCloud and Non-iCloud configurations, you’ll need to create two new configurations. Once these are created, you can then drag each entity into an appropriate configuration.

To use a configuration, pass the configuration name when adding a persistent store to a coordinator with addPersistentStoreWithType. For example, the loadStore method currently passes nil as the configuration name, which just uses the default configuration. Instead of nil, pass the configuration name.

A key point to be aware of when using configurations is that you are separating your data into separate stores. Relationships between objects in separate stores are not supported, so you will need to work around this constraint.

Finishing Touches

Since Core Data has been integrated with iCloud, the Dropbox backup-and-restore process will have no visible effect with iCloud enabled. This is because the iCloud store is in use instead of the Non-iCloud store that Dropbox is configured to work with. In reality, the only data resident on each device is an iCloud “cache,” and not the actual data stored behind the iCloud service. This means that even if users lose their device, they won’t lose any data because the store can be reconstructed from iCloud. In addition, restoring an iCloud store would have unpredictable results, so Dropbox backup-and-restore support will be disabled when iCloud is enabled. Listing 15.15 shows the code involved in presenting an alert view, which informs the user that Dropbox is disabled when iCloud is enabled.

Listing 15.15 DropboxTVC.m: backup / restore


CoreDataHelper *cdh =
[(AppDelegate *)[[UIApplication sharedApplication] delegate] cdh];
if ([cdh iCloudEnabledByUser]) {
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Not Supported"
message:@"This functionality is disabled because iCloud is enabled"
delegate:nil
cancelButtonTitle:nil
otherButtonTitles:@"OK", nil];
[alert show];
return;
}


Update Grocery Dude as follows to disable backup-and-restore when iCloud is enabled:

1. Add the following code to the bottom of CoreDataHelper.h before @end:

- (BOOL)iCloudEnabledByUser;

2. Add the code from Listing 15.15 to the top of the backup method of DropboxTVC.m.

3. Add the code from Listing 15.15 to the top of the restore method of DropboxTVC.m.

4. Run Grocery Dude on a couple of devices and enable iCloud in Settings. Don’t forget to also sign in to iCloud again if you ever reset the simulator to defaults. You should see that, when changes are persisted on one device, they show up on all other iCloud-enabled devices with Grocery Dude installed.

Summary

Additional features have now been implemented that should keep Core Data and iCloud a little more user friendly. From de-duplication to seeding, each of these techniques goes a step closer to providing the seamless experience that users have come to expect. One final point to note is that it is possible to load the iCloud store when the device is not signed in to iCloud. This is the beauty of the Fallback Store, which is at work under the hood. Still, it doesn’t cater to existing Non-iCloud stores, so Grocery Dude has been configured with the additional “iCloud Enabled” setting, allowing you to bypass iCloud and the Fallback Store altogether. To configure CoreDataHelper to use an existing store of your own, just ensure that the FILES and PATHS sections return a URL pointing to that store.

Exercises

Why not build on what you’ve learned by experimenting with adding Core Data and iCloud to a completely new application using the helper classes built throughout this book?

1. Create a new Single View Application for iPhone in Xcode and name the project EasyiCloud.


Tip

If you adapt this procedure to other project types, do not tick Use Core Data.


2. Download and extract the Generic Core Data Classes folder from the following URL: http://timroadley.com/LearningCoreData/Generic%20Core%20Data%20Classes.zip.

3. Drag the Generic Core Data Classes folder into the EasyiCloud group in the EasyiCloud project. Ensure that “Copy items into destination group’s folder” and the EasyiCloud target are ticked before clicking Finish.

4. Add a Data Model as follows:

a. Click File > New > File... and create an iOS > Core Data > Data Model.

b. Ensure the EasyiCloud target is selected and click Create to accept Model as the filename.

5. Configure Model.xcdatamodeld as follows:

a. Add an entity called Test with three attributes: modified, device, and someValue.

b. Set the modified attribute type to Date.

c. Set the device and someValue attribute types to String.

6. Create an NSManagedObject subclass for the Test entity. Ensure the EasyiCloud target is selected before clicking Create.

7. Create an iOS > Cocoa Touch > Objective-C class that is a CoreDataTVC subclass called TestTVC and then replace the code in TestTVC.m with the code from Listing 15.16.

Listing 15.16 TestTVC.m


#import "TestTVC.h"
#import "CoreDataHelper.h"
#import "AppDelegate.h"
#import "Deduplicator.h"
#import "Test.h"

@implementation TestTVC

#pragma mark - DATA
- (void)configureFetch {

CoreDataHelper *cdh = [CoreDataHelper sharedHelper];
NSFetchRequest *request =
[NSFetchRequest fetchRequestWithEntityName:@"Test"];
request.sortDescriptors = [NSArray arrayWithObjects:
[NSSortDescriptor sortDescriptorWithKey:@"modified" ascending:NO],nil];
[request setFetchBatchSize:15];
self.frc =
[[NSFetchedResultsController alloc] initWithFetchRequest:request
managedObjectContext:cdh.context
sectionNameKeyPath:nil
cacheName:nil];
self.frc.delegate = self;
}

#pragma mark - VIEW
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];

CoreDataHelper *cdh = [CoreDataHelper sharedHelper];
[Deduplicator deDuplicateEntityWithName:@"Test"
withUniqueAttributeName:@"someValue"
withImportContext:cdh.importContext];

}
- (void)viewDidLoad {
[super viewDidLoad];
[self configureFetch];
[self performFetch];
// Respond to changes in underlying store
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(performFetch)
name:@"SomethingChanged"
object:nil];
}
- (UITableViewCell*)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {

static NSString *cellIdentifier = @"Cell";
UITableViewCell *cell =
[tableView dequeueReusableCellWithIdentifier:cellIdentifier
forIndexPath:indexPath];
Test *test = [self.frc objectAtIndexPath:indexPath];

cell.textLabel.text = [NSString stringWithFormat:@"From: %@",test.device];
cell.detailTextLabel.text = test.someValue;
return cell;
}
- (void)tableView:(UITableView *)tableView
commitEditingStyle:(UITableViewCellEditingStyle)editingStyle
forRowAtIndexPath:(NSIndexPath *)indexPath {
if (editingStyle == UITableViewCellEditingStyleDelete) {
NSManagedObject *deleteTarget = [self.frc objectAtIndexPath:indexPath];
[self.frc.managedObjectContext deleteObject:deleteTarget];
[self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:indexPath]
withRowAnimation:UITableViewRowAnimationFade];
}
CoreDataHelper *cdh = [CoreDataHelper sharedHelper];
[cdh backgroundSaveContext];
}

#pragma mark - INTERACTION
- (IBAction)add:(id)sender {

CoreDataHelper *cdh = [CoreDataHelper sharedHelper];
Test *object =
[NSEntityDescription insertNewObjectForEntityForName:@"Test"
inManagedObjectContext:cdh.context];
NSError *error = nil;
if (![cdh.context obtainPermanentIDsForObjects:[NSArray arrayWithObject:object]
error:&error]) {
NSLog(@"Couldn't obtain a permanent ID for object %@", error);
}
UIDevice *thisDevice = [UIDevice new];
object.device = thisDevice.name;
object.modified = [NSDate date];
object.someValue = [NSString stringWithFormat:@"Test: %@",
[[NSUUID UUID] UUIDString]];
[cdh backgroundSaveContext];
}
@end


8. Replace the default view with a table view, as follows:

a. Select Main.storyboard.

b. Delete the existing View Controller.

c. Drag a Table View Controller onto the storyboard and then click Editor > Embed In > Navigation Controller.

d. Drag a Bar Button Item on to the top right of the Table View Controller.

e. Set the Identifier of the new Bar Button Item to Add using Attributes Inspector (Option+image+4).

f. Select the Prototype Cell and then set its Style to Subtitle and its Identifier to Cell.

g. Select the Table View Controller and set its Custom Class to TestTVC using Identity Inspector (Option+image+3).

h. Hold down Control and drag a line from the Add button to the yellow circle at the bottom of the Table View Controller. Then select Sent Actions > add.

9. Turn on the iCloud capability using the approach discussed in Chapter 14.

10. Configure a Settings Bundle as follows:

a. Click File > New > File... and create a Resource > Settings Bundle, ensuring that the EasyiCloud target is selected before clicking Create.

b. Select /Settings.bundle/Root.plist, expand Preference Items, and delete the three items that aren’t a Toggle Switch.

c. Set the Default Value of Item 0 to NO.

d. Set the Identifier of Item 0 to iCloudEnabled.

e. Set the Title of Item 0 to Enable iCloud.

f. Add #import "CoreDataHelper.h" to the top of AppDelegate.m.

g. Add [[CoreDataHelper sharedHelper] ensureAppropriateStoreIsLoaded]; to the applicationWillEnterForeground method of AppDelegate.m. This new class method allows greater portability of CoreDataHelper. Further information on its usage is found in CoreDataHelper.h.

11. Add [[CoreDataHelper sharedHelper] iCloudAccountIsSignedIn]; to the didFinishLaunchingWithOptions method of AppDelegate.m before return YES;.

12. Add [[CoreDataHelper sharedHelper] backgroundSaveContext]; to the applicationDidEnterBackground and applicationWillTerminate methods of AppDelegate.m.

13. Update the selectedUniqueAttributes method of CoreDataHelper.m so that it is appropriate to the new data model. To do so, replace the selectedUniqueAttributes method with the one from Listing 15.17.

Listing 15.17 CoreDataHelper.m: selectedUniqueAttributes


- (NSDictionary*)selectedUniqueAttributes {
if (debug==1) {
NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
NSMutableArray *entities = [NSMutableArray new];
NSMutableArray *attributes = [NSMutableArray new];

// Select an attribute in each entity for uniqueness
[entities addObject:@"Test"];[attributes addObject:@"someValue"];
//[entities addObject:@"Item"];[attributes addObject:@"name"];
//[entities addObject:@"Unit"];[attributes addObject:@"name"];
//[entities addObject:@"LocationAtHome"];[attributes addObject:@"storedIn"];
//[entities addObject:@"LocationAtShop"];[attributes addObject:@"aisle"];
//[entities addObject:@"Item_Photo"];[attributes addObject:@"data"];

NSDictionary *dictionary = [NSDictionary dictionaryWithObjects:attributes
forKeys:entities];
return dictionary;
}


14. Update entitiesToCopy in the mergeNoniCloudDataWithiCloud method of CoreDataHelper.m so that it is appropriate to the new data model. To do so, replace the line of code with the one shown here:

NSArray *entitiesToCopy = [NSArray arrayWithObjects:@"Test", nil];

15. Run the application on two more devices and then enable iCloud in Settings > EasyiCloud. No data will be merged into iCloud if confirmMergeWithiCloud is commented out in the loadiCloudStore method of CoreDataHelper.m.

16. Tap the + button to create test objects on each device. Once “Using local storage: 0” appears in the console log, you should see data show up on other devices. The expected result is shown in Figure 15.6. If the simulator seems slow to sync, click Debug > Trigger iCloud Sync.

Image

Figure 15.6 Easy iCloud

For your convenience, the EasyiCloud project is available for download from the following URL: http://timroadley.com/LearningCoreData/EasyiCloud.zip.