State Restoration - iOS Programming: The Big Nerd Ranch Guide (2014)

iOS Programming: The Big Nerd Ranch Guide (2014)

24. State Restoration

As we discussed in Chapter 18, applications have limited life spans. If iOS ever needs more memory and your application is in the background, Apple might kill it to return memory to the system. This should be transparent to your users; they should always return to the last spot they were within the application.

To achieve this transparency, you must adopt state restoration within your application. State restoration works a lot like another technology you have used to persist data – archiving. When the application is suspended, a snapshot of the view controller hierarchy is saved. If the application was killed before the user opens it again, its state will be restored upon launch. (If the application has not been killed, then everything is still in memory and you have no need to restore any state.)

In this chapter, you will add state restoration to the Homepwner application.

Let’s start by demonstrating the need for state restoration. Open the Homepwner project. Create a new item and drill down to its detail screen (the BNRDetailViewController). Now you need to simulate the process that triggers state restoration. In the iOS Simulator, press the Home button (or use the keyboard shortcut Command-Shift-H). This will put the application into the background. Now, to kill the application as if the system killed it, go back to Xcode and click the stop button (Command-.). Then relaunch the application from Xcode.

When the application launches, you will briefly see the BNRDetailViewController, but that will quickly be replaced with the BNRItemsViewController. Why is this? When applications go into the background, iOS takes a snapshot image of the user interface. Then when the application is relaunched, this snapshot is used as the launch image until the application is loaded into memory.

If an application does not implement state restoration, the user will briefly see the previous application state, but then the screen will change to its fresh-launch state. With Homepwner, you see a flicker of the detail view controller and then the items view controller. This is a disorienting experience.

How State Restoration Works

A running app can be thought of as a tree of view controllers and views, with the root view controller as the root of the tree. For example, the interface of your HypnoNerd app can be thought of as a tree like this:

Figure 24.1 An app is a tree

An app is a tree

If you opt-in to state restoration, before your app is terminated, the system walks this tree asking each node, “What is your name?”, “What is your class?”, and “Do you have any data you want me to hold on to?” While the app is dead, this description of the tree is stored on the file system.

A node’s name, actually called the restoration identifier, is typically the object’s class name. The restoration class is typically the class of the object. The data holds the state of the object. For example, the data for a tab view controller includes which tab is currently selected.

When the app is restarted, the system tries to recreate the tree of view controllers and views from that saved description. For each node:

· The system asks the restoration class to create a new view controller for that node.

· The new node is given an array of restoration identifiers: The identifier for the node being created and the identifiers for all of its ancestors. The first identifier in the array is the identifier for the root node. The last is the identifier for the node being recreated.

· The new node is given its state data. This data comes in the form of an NSCoder, which you used in Chapter 18.

In this chapter, you are going to extend the view controllers in Homepwner to properly give their node information when the app is terminating and to use that node information when they are resurrected.

Opting In to State Restoration

State restoration is disabled by default in applications. To enable state restoration, you must opt in within the application delegate.

Open BNRAppDelegate.m and implement the two delegate methods to enable state saving and restoration.

@implementation BNRAppDelegate

- (BOOL)application:(UIApplication *)application

shouldSaveApplicationState:(NSCoder *)coder

{

return YES;

}

- (BOOL)application:(UIApplication *)application

shouldRestoreApplicationState:(NSCoder *)coder

{

return YES;

}

When the application goes into the background, its state will attempt to be saved, and if the app is launching fresh, its save will attempt to be restored. To understand what gets stored, we need to discuss restoration identifiers.

Restoration Identifiers and Classes

When the application’s state is being saved, the window’s rootViewController is asked for its restorationIdentifier. If it has a restorationIdentifier, it is asked to save (or encode) its state. Then it is responsible for processing its child view controllers in the same way, and they, in turn, pass the torch to their child view controllers. If any view controller in the hierarchy does not have a restorationIdentifier, however, it (and its child view controllers, whether or not they have restorationIdentifiers) will be excluded from the saved state.

Figure 24.2 Restoration identifiers

Restoration identifiers

For example, with the application shown in Figure 24.2, the state of the two gray view controllers – and any child view controllers they might have – would not be saved.

Typically, the restoration identifier for a class is the same name as the class itself. In BNRAppDelegate.m, give the navigation controller a restoration identifier in application:didFinishLaunchingWithOptions:.

- (BOOL)application:(UIApplication *)application

didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{

self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]];

// Override point for customization after application launch

BNRItemsViewController *itemsViewController

= [[BNRItemsViewController alloc] init];

// Create an instance of a UINavigationController

// Its stack contains only itemsViewController

UINavigationController *navController = [[UINavigationController alloc]

initWithRootViewController:itemsViewController];

// Give the navigation controller a restoration identifier that is

// the same name as the class

navController.restorationIdentifier = NSStringFromClass([navController class]);

// Place navigation controller's view in the window hierarchy

self.window.rootViewController = navController;

self.window.backgroundColor = [UIColor whiteColor];

[self.window makeKeyAndVisible];

return YES;

}

Now that the navigation controller has a restoration identifier, it will attempt to save the state of its viewControllers if they have restoration identifiers themselves.

For your two UIViewController subclasses (BNRItemsViewController and BNRDetailViewController), you will assign the restoration identifier within that class’s designated initializer. Additionally, you will set the view controller’s restoration class. When the view controller’s state is being restored, it will ask this restoration class for an instance of the view controller to restore.

Open BNRItemsViewController.m and update init to assign the restoration identifier and class.

- (instancetype)init

{

// Call the superclass's designated initializer

self = [super initWithStyle:UITableViewStylePlain];

if (self) {

UINavigationItem *navItem = self.navigationItem;

navItem.title = @"Homepwner";

self.restorationIdentifier = NSStringFromClass([self class]);

self.restorationClass = [self class];

Open BNRDetailViewController.m and give the view controller a restoration identifier and class in initForNewItem:.

- (instancetype)initForNewItem:(BOOL)isNew

{

self = [super initWithNibName:nil bundle:nil];

if (self) {

self.restorationIdentifier = NSStringFromClass([self class]);

self.restorationClass = [self class];

if (isNew) {

Finally, there is one more view controller that is created in Homepwner: the UINavigationController that is presented modally when a new item is created.

Reopen BNRItemsViewController.m and update addNewItem: to give the UINavigationController a restoration identifier.

- (IBAction)addNewItem:(id)sender

{

// Create a new BNRItem and add it to the store

BNRItem *newItem = [[BNRItemStore sharedStore] createItem];

BNRDetailViewController *detailViewController =

[[BNRDetailViewController alloc] initForNewItem:YES];

detailViewController.item = newItem;

detailViewController.dismissBlock = ^{

[self.tableView reloadData];

};

UINavigationController *navController = [[UINavigationController alloc]

initWithRootViewController:detailViewController];

navController.restorationIdentifier = NSStringFromClass([navController class]);

(The two instances of UINavigationController that Homepwner uses do not have restoration classes. Because of this, the application delegate will be asked to create new instances of these view controllers, as you will see shortly.)

Now that all of the view controllers in Homepwner have a restoration identifier, their state will be saved when the user leaves the application.

State Restoration Life Cycle

Now that you are working with state restoration, the application life cycle is going to be a bit different, as you can see in Figure 24.3. Currently, all of your window and rootViewController code is in application:didFinishLaunchingWithOptions:, but with state restoration, it will spread out a bit.

Figure 24.3 Restoration life cycle

Restoration life cycle

The method application:willFinishLaunchingWithOptions: gets called before state restoration has begun. You should use this method to set up the window and do anything that should happen before state restoration.

In BNRAppDelegate.m, override this method to initialize and set up the window.

- (BOOL)application:(UIApplication *)application

willFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{

self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];

self.window.backgroundColor = [UIColor whiteColor];

return YES;

}

Next, update application:didFinishLaunchingWithOptions: to set up the view controller hierarchy in case state restoration did not occur (for example, on the first launch of the application). Also, remove the code that is now in application:willFinishLaunchingWithOptions:.

- (BOOL)application:(UIApplication *)application

didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{

self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]];

// Override point for customization after application launch.

// If state restoration did not occur,

// set up the view controller hierarchy

if (!self.window.rootViewController) {

BNRItemsViewController *itemsViewController

= [[BNRItemsViewController alloc] init];

// Create an instance of a UINavigationController

// Its stack contains only itemsViewController

UINavigationController *navController = [[UINavigationController alloc]

initWithRootViewController:itemsViewController];

// Give the navigation controller a restoration identifier that is

// the same name as the class

navController.restorationIdentifier = NSStringFromClass([navController class]);

// Place navigation controller's view in the window hierarchy

self.window.rootViewController = navController;

}

self.window.backgroundColor = [UIColor whiteColor];

[self.window makeKeyAndVisible];

return YES;

}

Restoring View Controllers

Since the two view controllers have a restoration class, the restoration class will be asked to create new instances of their respective view controller. In BNRItemsViewController.h, have the view controller conform to the UIViewControllerRestoration protocol.

@interface BNRItemsViewController : UITableViewController <UIViewControllerRestoration>

@end

Then, in BNRItemsViewController.m, implement the one required method for this protocol, which will return a new view controller instance.

+ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)path

coder:(NSCoder *)coder

{

return [[self alloc] init];

}

Now do the same for BNRDetailViewController. Open BNRDetailViewController.h and have it conform to the UIViewControllerRestoration protocol.

@interface BNRDetailViewController : UIViewController <UIViewControllerRestoration>

Implementing the protocol’s required method for BNRDetailViewController is a bit trickier because you need to know whether to pass YES or NO to initForNewItem:. Thankfully, the restoration identifier path argument can help.

Figure 24.4 Restoration path

Restoration path

The restoration identifier path is an array of restoration identifiers that represents this view controller and its ancestors at the time the view controller’s state was saved. Figure 24.4 shows the restoration paths for the different code paths of the Homepwner application. One path that might look odd is the restoration path of the UINavigationController that is presented modally. When the BNRItemsViewController presents the navigation controller modally, it actually gets presented from the parent of BNRItemsViewController, which is the root UINavigationController. Therefore,"BNRItemsViewController" is not in the path of the modally presented view controller’s restoration identifier path.

Armed with this knowledge, you know that the count of the restoration identifier path array will be 2 if you are viewing an existing BNRItem and 3 if you are creating a new BNRItem.

Open BNRDetailViewController.m and implement the one required method of the UIViewControllerRestoration protocol.

+ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)path

coder:(NSCoder *)coder

{

BOOL isNew = NO;

if ([path count] == 3) {

isNew = YES;

}

return [[self alloc] initForNewItem:isNew];

}

Now the BNRDetailViewController will have the correct bar button items when its state is restored.

You have taken care of the BNRItemsViewController and BNRDetailViewController, but there are still two more view controllers that will need to be restored – the two navigation controllers that the application uses. You have given both of these view controllers restoration identifiers, but you will not give them a restoration class.

Instead, if a view controller that is getting restored does not have a restoration class, the application delegate is asked to provide the view controller. Open BNRAppDelegate.m and implement this method.

- (UIViewController *)application:(UIApplication *)application

viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents

coder:(NSCoder *)coder

{

// Create a new navigation controller

UIViewController *vc = [[UINavigationController alloc] init];

// The last object in the path array is the restoration

// identifier for this view controller

vc.restorationIdentifier = [identifierComponents lastObject];

// If there is only 1 identifier component, then

// this is the root view controller

if ([identifierComponents count] == 1) {

self.window.rootViewController = vc;

}

return vc;

}

Build and run the application. Create an item and drill down to see its details. Trigger state restoration as you did at the beginning of this chapter. After relaunching the application, you will be returned to the BNRDetailViewController – but the item details are blank. Although the view controller hierarchy is currently being saved, no model information is saved, so the BNRDetailViewController has no idea which BNRItem it should be displaying. To fix this, you will need to manually encode a reference to the BNRItem being displayed.

Encoding Relevant Data

To persist other information, a UIViewController is given a chance to encode any relevant data in a process very similar to archiving. In fact, both encoding processes use an NSCoder object to do the work. You will use this to save out any necessary information in the view controller subclasses.

In BNRDetailViewController.m, encode the itemKey for the currently displayed BNRItem.

- (void)encodeRestorableStateWithCoder:(NSCoder *)coder

{

[coder encodeObject:self.item.itemKey

forKey:@"item.itemKey"];

[super encodeRestorableStateWithCoder:coder];

}

Then implement the decoding method to search through the BNRItemStore for the appropriate BNRItem.

- (void)decodeRestorableStateWithCoder:(NSCoder *)coder

{

NSString *itemKey =

[coder decodeObjectForKey:@"item.itemKey"];

for (BNRItem *item in [[BNRItemStore sharedStore] allItems]) {

if ([itemKey isEqualToString:item.itemKey]) {

self.item = item;

break;

}

}

[super decodeRestorableStateWithCoder:coder];

}

Build and run the application and drill down into a BNRItem. Then perform the state restoration steps again. This time, the values on the BNRDetailViewController will correctly reflect the BNRItem being displayed.

There is still one problem: the text fields and labels are being populated with the values of the BNRItem. If the user has typed in some other value, those values will be lost upon state restoration.

You will fix this by saving the current text field values into the BNRItem. Since view controller encoding occurs after the application has entered the background, you will also need to save the store again.

In BNRDetailViewController.m update the encode method.

- (void)encodeRestorableStateWithCoder:(NSCoder *)coder

{

[coder encodeObject:self.item.itemKey

forKey:@"item.itemKey"];

// Save changes into item

self.item.itemName = self.nameField.text;

self.item.serialNumber = self.serialNumberField.text;

self.item.valueInDollars = [self.valueField.text intValue];

// Have store save changes to disk

[[BNRItemStore sharedStore] saveChanges];

[super encodeRestorableStateWithCoder:coder];

}

The BNRDetailViewController is now saving and restoring its state beautifully. Without a lot of work on your part, your users get a better experience. Now let’s turn our attention to the BNRItemsViewController.

Saving View States

There are a number of problems with the BNRItemsViewController:

· The UITableView is not being restored to its existing scroll position, nor is the currently selected row (if the user has drilled down into an item) being restored.

· The view controller does not save whether or not it is in editing mode, and so it is always restored with the default value (which is not in editing mode).

You will fix the second point soon very similarly to how you encoded information for the BNRDetailViewController, but before you do let’s take a look at the first point.

In addition to the view controllers having a restoration identifier, certain UIView subclasses can have a restoration identifier to save certain information about the view. Specifically, these subclasses can save some of their information: UICollectionView, UIImageView, UIScrollView, UITableView,UITextField, UITextView, and UIWebView.

The documentation for each of those classes states what information is preserved. For UITableView, the useful piece of information saved is the content offset of the table view (the scroll position).

In BNRItemsViewController.m, give the UITableView a restoration identifier.

- (void)viewDidLoad

{

[super viewDidLoad];

// Load the NIB file

UINib *nib = [UINib nibWithNibName:@"BNRItemCell" bundle:nil];

// Register this NIB which contains the cell

[self.tableView registerNib:nib

forCellReuseIdentifier:@"BNRItemCell"];

self.tableView.restorationIdentifier = @"BNRItemsViewControllerTableView";

}

Build and run the application and scroll down in the BNRItemsViewController (you will probably need to add some new items). Trigger state restoration and, upon relaunching, the table view will return to its previous content offset.

Let’s turn our attention to the editing mode of the view controller so its state persists. In BNRItemsViewController.m, implement the encode and decode methods.

- (void)encodeRestorableStateWithCoder:(NSCoder *)coder

{

[coder encodeBool:self.isEditing forKey:@"TableViewIsEditing"];

[super encodeRestorableStateWithCoder:coder];

}

- (void)decodeRestorableStateWithCoder:(NSCoder *)coder

{

self.editing = [coder decodeBoolForKey:@"TableViewIsEditing"];

[super decodeRestorableStateWithCoder:coder];

}

You have one last issue to tackle to finish state restoration in the Homepwner application. UITableView will save information about the selected UITableViewCell row, but you need to help it out a little.

In BNRItemsViewController.m, have the view controller conform to the UIDataSourceModelAssociation protocol in the class extension.

@interface BNRItemsViewController ()

<UIPopoverControllerDelegate, UIDataSourceModelAssociation>

@property (nonatomic, strong) UIPopoverController *imagePopover;

@end

The UIDataSourceModelAssociation protocol helps state restoration locate the appropriate model objects for your application. When the application state is being saved, state restoration will ask for a unique identifier for the model object associated with the selected row or rows (a BNRItem, for example). When the application is relaunched, state restoration will provide the identifier and ask for an index path for that model object. Model objects may change positions in the UITableView on relaunch, but as long as your mapping is correct, the correct rows will be selected.

In BNRItemsViewController.m, implement the method to provide state restoration with a unique identifier for the selected BNRItem. You will use the itemKey property as the unique identifier.

- (NSString *)modelIdentifierForElementAtIndexPath:(NSIndexPath *)path

inView:(UIView *)view

{

NSString *identifier = nil;

if (path && view) {

// Return an identifier of the given NSIndexPath,

// in case next time the data source changes

BNRItem *item = [[BNRItemStore sharedStore] allItems][path.row];

identifier = item.itemKey;

}

return identifier;

}

Then implement the inverse method that returns an NSIndexPath for a given identifier.

- (NSIndexPath *)indexPathForElementWithModelIdentifier:(NSString *)identifier

inView:(UIView *)view

{

NSIndexPath *indexPath = nil;

if (identifier && view) {

NSArray *items = [[BNRItemStore sharedStore] allItems];

for (BNRItem *item in items) {

if ([identifier isEqualToString:item.itemKey]) {

int row = [items indexOfObjectIdenticalTo:item];

indexPath = [NSIndexPath indexPathForRow:row inSection:0];

break;

}

}

}

return indexPath;

}

Build and run the application and trigger state restoration. Homepwner is now fully set up for state restoration and will give users a good experience.

Silver Challenge: Another Application

Implement state restoration in the HypnoNerd application. The process will be nearly identical to what you did in this chapter. Make sure to encode the UITextField text and the currently selected date on the UIDatePicker.

(Hint: to completely finish this challenge, the BNRHypnosisView should also save and restore the labels that have been added to it. You will want to give the BNRHypnosisView a restoration identifier and implement the appropriate encode and decode methods.)

For the More Curious: Controlling Snapshots

As we mentioned in this chapter, the system takes a snapshot of the application when it goes into the background. Sometimes you may want to have some control over what is displayed to your users on the next launch (which is also what your users will see when viewing your application in the multitasking display).

For example, if your application displays sensitive information (such as a banking application showing account numbers and balances), you may want to hide this information so that it is not shown to unauthorized eyes. As another example, Apple blurs the camera contents when the Cameraapp goes into the background, not for security reasons, but to make it easier for users to notice the Camera app in the multitasking display (instead of getting distracted by whatever the camera was looking at when the app went into the background).

Modifying the snapshot is easy: you update the view before the snapshot is taken to have it display what you want the snapshot to be.

We talked about the various states that the application goes through in Chapter 18 and implemented the application delegate callbacks to see the state changes. In addition to the application delegate callbacks, the application also posts notifications when its state is transitioning. Observing the appropriate notifications in your view controllers will give you an opportunity to update the user interface.

Specifically, you will want to observe the UIApplicationWillResignActiveNotification to obscure anything necessary, and UIApplicationDidBecomeActiveNotification to get things ready for the user to see again.

NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];

[nc addObserver:self

selector:@selector(applicationResigningActive:)

name:UIApplicationWillResignActiveNotification

object:nil];

[nc addObserver:self

selector:@selector(applicationBecameActive:)

name:UIApplicationDidBecomeActiveNotification

object:nil];

- (void)applicationResigningActive:(NSNotification *)note

{

// Prepare app for snapshot

}

- (void)applicationBecameActive:(NSNotification *)note

{

// Undo any changes before user returns to app

}

Finally, if your application implements state restoration but for some reason it does not make sense to use a snapshot the next time it launches, you can tell the application to ignore the snapshot:

// This method should be called during the code that preserves the state

[[UIApplication mainApplication] ignoreSnapshotOnNextApplicationLaunch];

This might make sense, for example, if your application was displaying a network connectivity error message. The user may very well have a network connection the next time the application launches, and so to reduce user confusion you would ignore the snapshot for just the next launch of the application. The application would use the launch image in its place.