読者です 読者をやめる 読者になる 読者になる

cockscomblog?

cockscomb on hatena blog

マツコの知らない State Restoration の世界

慣れない街へ出かけるとき、電車の中で目的地までの道のりを「地図アプリ」で検索しておいて、駅を出てから地図を見て移動したことはありますか。あるいは「ネットショッピングアプリ」で欲しいものが見つかって、衝動買いするにはちょっと高価だったときに、レビューを読んだりネット上での評判を確かめたりしたことはありませんか。もしそんなとき、地図アプリやネットショッピングアプリを離れて他のことをして、また戻ってきたときに、そのアプリは元の画面のままですか。それともアプリの最初の画面に戻ってしまっていますか。

iOSState Restoration という機能は、アプリを元の状態に復元するための仕組みを提供します。メモリが不足してバックグラウンドのアプリが終了した後、次に起動したときに、アプリを元の状態に戻すことができます。地図で見つけた道順や、検討中の商品、または入力中のメッセージや、さっき見ていた web ページなど、そういったものをそのまま残しておくことができます。もしこのような復元が行われなければ、ユーザーはそれまでのコンテキストを失い、せっかく見つけたものを見失ったり、大切な言葉を忘れてしまったり、とてもがっかりしてしまうに違いありません。

あなたのアプリのユーザーが戻ってきた時にがっかりしないよう、state restoration の力を借りてみましょう。

First Step

ここからは state restoration を実現するための実装方法を紹介します。

老婆心からはじめに注意しておきたいと思いますが、state restoration は UIKit の中では難しい部類の機能です。そして既存のアプリの設計では対応できないこともあります。どうかがっかりしないで、粘り強く取り組んでほしいと思います。

UIApplicationDelegate

optional func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool
optional func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool

UIApplicationDelegate で上記のふたつのメソッドを実装し、true を返します。

状況に応じて false を返すことで、保存されたり復元されたりするのを抑制できます。典型的には、保存された時と復元される時でアプリケーションのバージョンが異なり、うまく復元することができないような場合には、復元を諦めて false を返すことになります。そのような目的のために利用できる UIApplicationStateRestorationBundleVersionKey 定数があり、coder.decodeObjectForKey(UIApplicationStateRestorationBundleVersionKey) as? String の結果を Info.plistCFBundleVersion と比較することで、復元するか判断できます。

optional func application(_ application: UIApplication, willEncodeRestorableStateWithCoder coder: NSCoder)
optional func application(_ application: UIApplication, didDecodeRestorableStateWithCoder coder: NSCoder)

また上記のメソッドで、アプリケーション全体にわたる状態を保存・復元できます。

UIViewController, UIView

UIViewControllerUIView は具体的な保存と復元を担います。保存と復元の対象にするためには、インスタンスvar restorationIdentifier: String? プロパティに値を入れる必要があります。コード上で設定することも Storyboard 上で設定することもできます。

f:id:cockscomb:20160330163542p:plain:w260:h210

Storyboard から restoration identifier を設定する

このとき view controller のヒエラルキーの中で、UIWindow の root view controller から復元したい view controller インスタンスに至るまでの全ての上位の要素にも restorationIdentifier が設定されていなければなりません。途中で restorationIdentifiernil の要素があると、それ以降は復元の対象になりません。view の復元には、その view が保持されている view controller が復元できる必要があります。

また container view controller から見たとき、child view controller の restorationIdentifier はそれぞれユニークである必要があります。UINavigationController では child view controller に、位置を元にした識別子を付加していることがドキュメントで示されています。

UIViewController

restorationIdentifier が設定された view controller は、以下のふたつのメソッドで状態の保存と復元を行います。

func encodeRestorableStateWithCoder(_ coder: NSCoder)
func decodeRestorableStateWithCoder(_ coder: NSCoder)

つまり状態の保存や復元の対象とできるのは NSCoding プロトコルに適合したオブジェクトやプリミティブな値ということになります。

またアプリケーション全体の復元が終わった後に以下のメソッドが呼ばれます。

func applicationFinishedRestoringState()

View controller が Storyboard から生成される場合はこれで十分です。もし view controller をコードだけで生成しているような場合には、これに加えてもうひとつ準備が必要です。UIViewController.restorationClassUIViewControllerRestoration を実装したクラスを設定するか(典型的には view controller のクラス自身で実装する)、 UIApplicationDelegate.application(_:viewControllerWithRestorationIdentifierPath:coder:) を実装するかです。どちらにしても restorationIdentifier の値の配列と NSCoderインスタンスから view controller のインスタンスを生成して返すことになります。UIViewControllerRestoration の典型的な実装を以下に示しておきます。

class ViewController: UIViewController, UIViewControllerRestoration {

    override func viewDidLoad() {
        super.viewDidLoad()

        restorationIdentifier = "ViewController"
        restorationClass = ViewController.self
    }

    static func viewControllerWithRestorationIdentifierPath(identifierComponents: [AnyObject], coder: NSCoder) -> UIViewController? {
        return ViewController()
    }

}

UIView

View controller と同様に UIViewrestoratoinIdentifier を持つ場合に以下のメソッドが呼ばれます。

func encodeRestorableStateWithCoder(_ coder: NSCoder)
func decodeRestorableStateWithCoder(_ coder: NSCoder)

独自の view を作った際に、多くの場合は view controller から値を再設定される方が好ましいと言えますが、例えばユーザーの入力値を保持しておくといった機能を持っているなら、これらのメソッドで復元可能にするのがよいでしょう。

UIKit が提供する view のうち、UICollectionViewUIImageViewUIScrollViewUITableViewUITextFieldUITextViewUIWebView はデフォルトで復元機能を持っていることがドキュメントで示されています。さらに UICollectionViewUITableView のそれぞれの data source が UIDataSourceModelAssociation プロトコルを実装している場合に、要素の選択などといった状態を復元することができます。

復元のライフサイクル

よく注意しなければならないのは、復元されるタイミングです。多くの場合、新たに登場するライフサイクルの流れが、state restoration を実装する上での大きな障害に感じられるでしょう。

アプリケーション全体の中では、UIApplicationDelegateapplication(_:willFinishLaunchingWithOptions:) の後、application(_:didFinishLaunchingWithOptions:) の前に復元が行われます。application(_:didFinishLaunchingWithOptions:) で行う何らかの処理に依存するべきではありません。

View controller の復元では、当然ですが viewDidLoad() より後に復元が行われます。もし viewDidLoad() で view に値を設定していた場合、復元がうまくいかないことになります。

Container View Controller

Container view controller は、child view controller についても処理しなければなりません。UIKit が提供する container view controller は適切にハンドリングしてくれます。もし自分で container view controller を実装している場合は、child view controller を適切に管理する必要があります。

保存する際には、encodeRestorableStateWithCoder(_:) で child view controller を NSCoderencodeObject(_:forKey:) で保存します。UIViewControllerNSCoding に準拠していることに気がつくことでしょう。

復元は少しわかりにくいですが、Storyboard を利用しているときは、そのまま Storyboard から viewDidLoad() の中などで instantiateViewControllerWithIdentifier(_:) で生成します。Storyboard からではない場合は decodeRestorableStateWithCoder(_:) で取り出すのがよいでしょう。

また必要に応じて restorationIdentifier を調整しなければならないでしょう。

設計

State restoration をあなたのアプリで実装しようとすると、おそらく設計上の多くの問題にぶつかることになるでしょう。典型的な問題についてヒントを示そうと思います。

ディスクキャッシュ・データベース

多くのオブジェクトは、state restoration とともに再生成されればよいでしょう。しかしユーザーの入力や web から取ってきたデータは、どこかに保存しておく必要があります。

State restoration では、復元するオブジェクトが NSCoding に準拠していることを求めます。しかし現実のあなたのクラスの多くは NSCoding に準拠していないことでしょう。もしひとつでも NSCoding に準拠できない stored property を持っていたら、そのクラスは NSCoding に準拠できないかもしれません。そもそも Swift の struct は NSCoding に準拠する方法がありません。

ひとつのやり方はディスク上に保存してしまうことです。そのデータが高々ひとつの画面で表示されるようなもので、他のデータと関連性を持たないような場合、何らかの方法で単純に保存するとうまくいくかもしれません。

もしデータの構造が複雑な場合には、データベースシステムを導入すると簡単です。Core Dataや Realm、あるいは SQLite でもいいでしょう。もちろん他のものでも構いません。データはデータベースに格納してしまって、state restoration ではそのデータのプライマリーキーだけを保存・復元することにします。プライマリーキーは Core Data なら NSManagedObjectID.URIRepresentation() が利用でき、Realm であれば Object.primaryKey() をオーバーライドして指定します。その他のデータベースシステムでも類似の概念を得られるはずです。

データをディスク上に置くことで、state restoration のために保存されるデータの容量を減らすこともできます。

インスタンス管理・UIStateRestoring

State restoration が起きるとき、通常とは異なるフローで view controller が生成されます。例えばひとつ前の view controller から prepareForSegue(_:sender:) で何らかのオブジェクトが渡ってきているとき、state restoration 後にはこれは得られません。データとして state restoration で保存・復元することもできますが、view controller でそれを行うと、本来は同一のインスタンスへの参照だったものが、異なる複数インスタンスができてしまうことになります。

これを避けるために、インスタンス管理を行う方法があります。インスタンス管理の典型例は、シングルトンです。つまりアプリケーション中にインスタンスが単一であることを保証し、例えば static な stored property などでそのインスタンスへの参照を与えるやり方です。しかしシングルトンを乱用するべきとは思えないので、これを少し拡張して DI コンテナに相当する概念を導入することができます。

もう一つの方法として UIStateRestoring プロトコルに準拠させ、UIApplication.registerObjectForStateRestoration(_:restorationIdentifier:) メソッドで登録してしまうやり方があります。この方法では、view controller と独立したオブジェクトを復元できます。このことで view controller を跨ぐようなオブジェクトも復元可能です。

その他

スナップショット

State restoration を実装すると、状態が保存されるのと同時に画面のスナップショットが作成されます。次回の復元時にはこのスナップショットが先に表示されるので、ユーザーはシームレスに復元されたように感じます。しかし何らかの事情でこのスナップショットが不適切であると考えられる場合には、状態の保存中に UIApplicationignoreSnapshotOnNextApplicationLaunch() メソッドを呼び出すことで、スナップショットが表示されるのを抑制できます。

非同期の復元

復元は基本的にメインスレッド上で行われますが、場合によっては他のスレッドを活用して並列に処理したいかもしれません。そういった場合は UIApplicationextendStateRestoration() メソッドcompleteStateRestoration() メソッドが利用できます。前者を呼び出してから、例えば Grand Central Dispatch などを利用して非同期に復元処理を行い、後者のメソッドを呼び出します。このふたつのメソッドは必ずペアで呼び出さなければならないので注意してください。

デバッグ

State restoration のデバッグはやや困難です。メモリが十分あるとき、アプリはバックグラウンドで休眠状態に置かれるため、state restoration が発生しません。かといってマルチタスクスイッチャーから上スワイプなどで削除すると、やはり state restoration が発生しません。いちばん簡単なのは、ホームボタンでホーム画面に戻した後、Xcode などを使ってプロセスを kill することです。ホーム画面に戻る瞬間に状態が保存されているので、次回の起動時に復元が行われます。

さらに詳細にデバッグする必要があるときは、Apple の提供するツールを利用できます。Downloads for Apple Developers から restorationArchiveTool for iOS というのを探してダウンロードすると、保存された状態をコマンドラインで覗き見ることができるツールが得られます。また他にも同梱されている .mobileconfig ファイルを利用することで、デバッグログを出力するようにしたり、あるはマルチタスクスイッチャーでアプリを終了したり復元中にクラッシュしたりしても、次の起動時に復元しようとするように設定できます。

どの画面を復元するか

State restoration は一般に複雑で、全ての画面で実装するのは高コストです。しかし必ずしも全ての画面を復元しなくても問題ありません。ユーザーにとってメリットのある部分だけ復元できるようにしておけばよいのです。restorationIdentifier を設定しなければその view controller は復元されないので、ごく一時的にしか表示されないような画面では復元を諦めることができます。ただしその view controller より下の階層の view controller も全て復元できなくなるので注意してください。

まとめ

State restoration は iOS 6 や iOS 7 で追加された機能です。うまく実装することで、ユーザーはがっかりせずに済み、あなたのアプリを何度も使ってくれることでしょう。しかし実装するのが比較的難しいからか、適切に state restoration されるアプリは少ないです。しかしこれを読んだあなたは state restoration について幅広い知識を得たはずです。どうか安心して仕事に取りかかってください。

もしユーザーがあなたのアプリを1分以上使うなら、state restoration に対応する価値があります。反面で、例えばそのアプリが20秒程度で利用するようなものであれば、state restoration はそれほど役立たないかもしれません。State restoration が難しい場合、例えば検索フォームに履歴が残っているとか、そういった機能でいくらか代替できるかもしれません。いずれにせよ、ユーザーがアプリに戻ってきたときに、なるべく混乱が起きないようにするのが望ましいでしょう。

余談ですが Android においては、画面の遷移に必要な情報が serialize (parcelable) なオブジェクトでやりとりされることや、画面の回転でも画面が再生成されるために、state restoration のような仕組みがフレームワークと密に融合しています。このため多くの場合で、特別なことをしなくても同じような結果になります。

またさらに余談ですが OS X にも iOS と同様の仕組みがあり、アプリを起動し直したときでも前と同じ状態に戻ります。

参考