ninjinkun's diary

ninjinkunの日記

iOS6から使えるアプリの状態復元UIStateRestoration

iOSアプリを起動する際、ユーザが最後に開いた画面を表示できると利便性は高まります。しかしバックグラウンドに移ったアプリは、メモリが逼迫してくると強制的に終了させられて、最初からやり直しです。この終了状態から、あたかも直前まで動いていたかのように状態を復元するUIStateRestorationがiOS6から導入されました。

追加の実装は必要ですが、自前でやるよりは楽に状態の保存と復元が可能になります。

以下の内容は最新のiOSアプリケーションプログラミングガイド(既に日本語訳出てる!)を参照しながら実装してみたものなので、詳細を知りたければそちらを参照するのがおすすめです。

はじめに

この機能で保存、復元されるものは以下の通りです。

  • ViewControllerとViewController Container
    • UINavigationViewControllerのスタックも復元
  • View

注意すべき点としては、この仕組みはデータの保存のためではなく、あくまで画面を復元することが目的です。データの保存はNSUserDefaultsに保存するなり、CoreDataを使うなりで別途用意する必要があります。

ViewControllerの保存と復元

準備

  • Restoration IDを各ViewControllerに設定する
    • 入れ子の場合は、祖先のViewController全てに設定する必要がある
      • 例えば親のUINavigationControllerに設定し忘れると子が保存されなくなる
      • 保存されなかった漏れが無いかチェック!
      • Storyboardでもコードでも設定できます

f:id:ninjinkun:20121020122053p:plain

  • 復元クラス*1を割り当てる
    • 起動時にStoryboardから読み込まれるViewControllerは復元クラスを割り当てない
    • そうでないViewControllerには復元クラスを割り当てる

復元クラスは以下のメソッドを実装することになります。復元クラスは何でもいいので、とりあえずは自身のViewControllerに書いておくのが楽そうです。

-(void)viewDidLoad {
    [super viewDidLoad];
    self.restorationIdentifier = @"SampleViewController";
    self.restorationClass = [self class]; // restorationClass設定
}

// Restorationメソッド
+ (UIViewController *) viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder {
    SampleViewController *viewController = [[self alloc] init];
    return viewController;    
}
  • AppDelegateに以下のメソッドを追加
    • 保存の開始、復元の開始を許可するメソッド
      • これがないと始まらないので必須
-(BOOL)application:(UIApplication *)application shouldSaveApplicationState:(NSCoder *)coder {
    // 状態を保存して良ければYESを返す
    return YES;
}

-(BOOL)application:(UIApplication *)application shouldRestoreApplicationState:(NSCoder *)coder {
    // アプリのバージョン違いを検出してRestoreをやめたりするならここで
    return YES;
}
    • 起動時の初期化メソッド
// 通常の起動時の初期化メソッド
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [self commonLaunchInitialization:launchOptions]; // 共通の初期化メソッド

// アクセス解析の設定などは初回起動時にあればよい
#ifdef GOOGLE_ANALYTICS
    NSDictionary *param = @{ kGANAccountIdKey : GOOGLE_ANALYTICS_KEY };
    [EasyTracker launchWithOptions:launchOptions withParameters:param withError:nil];
#endif

    return YES;
}

// 復元の際にはここが初期化メソッドになる
-(BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [self commonLaunchInitialization:launchOptions]; // 共通の初期化メソッド
    return YES;
}

// 起動時に行う処理はまとめて別メソッドにする
- (void) commonLaunchInitialization:(NSDictionary *)launchOptions {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        UINavigationController *navigationController = (UINavigationController *)self.window.rootViewController;
        RootViewController *rootViewController = (RootViewController *)[[navigationController viewControllers] objectAtIndex:0];
        rootViewController.managedObjectContext = self.managedObjectContext;
    });
}
  • 各ViewControllerに以下のメソッドを実装
    • CoreDataの場合はModelそのものをシリアライズしたりせず、オブジェクトのURLを保存する
// オブジェクトのIDを保存する
-(void)encodeRestorableStateWithCoder:(NSCoder *)coder {
    // CoreDataのURLを保存する
    [coder encodeObject:self.detailItem.objectID.URIRepresentation forKey:kItemKey];
    
    [super encodeRestorableStateWithCoder:coder];
}

// オブジェクトを復元する
-(void)decodeRestorableStateWithCoder:(NSCoder *)coder {
    NSURL *itemObjectURL = [coder decodeObjectForKey:kItemKey];
    NSManagedObjectID *itemObjectID = self.persistentStoreCoordinator managedObjectIDForURIRepresentation:itemObjectURL];
    
    if (itemObjectID) {
        self.detailItem = self.managedObjectContext objectWithID:itemObjectID];
    }

    [super decodeRestorableStateWithCoder:coder];
}

ViewやViewController自体をシリアライズするのではなく、状態に関連したオブジェクトのみをシリアライズするようになっているので容量と速度の効率も良さそうです。

保存処理

  • ホームボタンを押す
    • タスク一覧からの強制終了だと保存処理が無効になる
    • Xcodeの終了も同様、なのでCtrl+Shift+Hでホームボタンをシミュレートしよう
  • AppDelegate
    • application:shouldSaveApplicationState:
  • ViewController
    • encodeRestorableStateWithCoder:が呼ばれる
      • ViewControllerの状態に関連するオブジェクトを保存する
  • 最後に見たビューのスクリーンショットが保存される
    • 2013/1/29追記 iOS 6.1からこの挙動は廃止され、通常のスプラッシュが表示されます

復元処理

  • 最後に見たビューのスクリーンショットが表示される
    • 2013/1/29追記 iOS 6.1からこの挙動は廃止され、通常のスプラッシュが表示されます
  • AppDelegate
    • application:willFinishLaunchingWithOptions:
      • ここでapplication:didFinishLaunchingWithOptions:相当のことをする
    • application:shouldRestoreApplicationState:
    • application:viewControllerWithRestorationIdentifierPath:coder:
      • StoryboardもしくはRestoration Classに任せるなら実装しなくて良い
  • ViewConttoller
    • application:viewControllerWithRestorationIdentifierPath:coder:
      • Storyboardに任せるなら実装しなくて良い
    • decodeRestorableStateWithCoder:が呼ばれる
    • 状態に関連するオブジェクトを復元する
      • viewDidLoadの後に呼ばれるので注意!

Viewの保存と復元

View単位での保存、復元もできます。組み込みのクラスとしては以下のViewが対応しています。自作のViewに状態の保存/復元機構を組み込むこともできます。この辺はまだあまり検証できていません。

  • UICollectionView
    • 未検証
  • UIImageView
    • 未検証
  • UIScrollView
    • スクロール位置を覚えていていくれる
  • UITableView
    • 同上
    • 編集状態も覚えていてくれる
  • UITextField
    • 未検証
  • UITextView
    • 未検証
  • UIWebView
    • 未検証

ViewもViewControllerと同じようにencodeRestorableStateWithCoder:/decodeRestorableStateWithCoder:で状態オブジェクトを保存することで、状態の復元が可能になります。

UIDataSourceModelAssociationプロトコル

また、UITableView等を利用している場合に、編集状態などを保存する際にはDataSourceとの整合性を取る必要があります。これを実現するために必要なのがUIDataSourceModelAssociationプロトコルです。以下の様にidentifierとIndexPathの整合性を指定することで、TableViewの編集状態などの復元が可能になります。

- (NSString *) modelIdentifierForElementAtIndexPath:(NSIndexPath *)indexPath inView:(UIView *)view {
    NSManagedObject *object = [self.fetchedResultsController objectAtIndexPath:indexPath];
    return [[object.objectID URIRepresentation] absoluteString];
}

- (NSIndexPath *) indexPathForElementWithModelIdentifier:(NSString *)identifier inView:(UIView *)view {
    NSURL *objectURL = [NSURL URLWithString:identifier];
    NSManagedObjectID *objectID = [self.persistentStoreCoordinator managedObjectIDForURIRepresentation:objectURL];
    if (objectID) {
        NSManagedObject *object = [self.managedObjectContext objectWithID:objectID];
        return [self.fetchedResultsController indexPathForObject:object];
    }
    return nil;
}

indexPathに対応するidentifier、次回もそのidentifierからindexPathが決まるようにしなければならないため、CoreData以外を使う場合はModelにURLなりIDなりをふっておく必要が出てきます。

サンプルコード

実装してみたサンプルコードは以下にあります。

https://github.com/ninjinkun/RestoreSample

おわりに

以上のことを行えば、アプリの状態復元が可能になります。復元機能はiOS 6からのサポートですが、コードを追加してもiOS 5との互換性は保たれます(状態復元しない普通の挙動で動きます)。

また、以下に実装してみて感じた点をメモしておきます。

  • 起動時に保存されたスクリーンショットが表示されるのが良い
    • 無意味なスプラッシュが表示されなくなる
    • 自前実装にはないメリット
    • 2013/1/29追記 iOS 6.1からこの挙動は廃止され、通常のスプラッシュが表示されます
  • シンプルなStoryboardアプリになら実装コストも大してかからない
    • ViewControllerが多くなってくると大変そう
  • 親のrestorationIdentifier設定忘れがち
    • 忘れると子が復元されなくなるので注意
  • 何回もクラッシュするとどうなるのか
    • 3回目にRestoreせずに起動する

また、iOSアプリはどうしてもステートフルな設計になってしまいますが、状態復元を実装すると、状態とView及びViewControllerを切り離して設計することになるので、結果的に見通しが良くなるという効果もありそうです。

10/20 14:45追記

id:kanizaさんによるとViewのRestoreは以下の様な動きをするようです

  • UITextView
    • テキストの内容自体は覚えていない
    • 編集中のテキスト選択状態などは覚えておいてくれる
    • つまりデータは自分で復旧する必要がある、編集状態だけはお任せできる
  • UIWebView
    • ヒストリだけ覚えておいてくれる
    • 今見ているURLなどは覚えてくれない…

データは自分で保存、復旧しろという姿勢が見て取れますね。それぞれのViewのドキュメントに、何を保存、復旧してくれるかは記載されているとのことでした。

10/20 15:20追記

id:KishikawaKatsumiさんから以下の指摘を頂きました。もうちょっとこの辺は調べる必要がありそうです。






10/29 20:35追記

application:willFinishLaunchingWithOptions:の後にapplication:willFinishLaunchingWithOptions:が呼ばれるので初期化は前者でやればいいように見えます。しかしapplication:willFinishLaunchingWithOptions:iOS6からしか呼ばれないので、共通の初期化処理はdispatch_oneでくくって両方のメソッドに書いておくのが良いようです。

2013/1/29追記

iOS 6.1から復帰時に直前のスクリーンショットが表示される機能は廃止され、通常のスプラッシュが表示されます。復帰画面との不整合が多かったということかなと思います。

*1:UIViewControllerRestorationを実装したクラス