2015-09-30Androidアプリプログラミングで、ある程度経験を積んだ開発者なら、Fragmentにまつわる操作で不意に発生するIllegalStateExceptionには、いくどとなく苦しめられたことがあるでしょう。3.0で登場したコンポーネントです。足をすくわれがちです。2を組み合わせて、Androidアプリを構築する方法について説明します。合わせて、FlowとMortarがどのような仕組みで動いているのかも学びます。これから説明するMVPプログラミングは、ユニットテストをしやすくするために、オリジナルのSquareのやりかたから若干アレンジしています。理解の助けとなるよう図をたくさん使いましたが、図中の線、形状、色の意味付けはかなりいい加減で一貫性もないので、逆に混乱させてしまったら申し訳ありません。文章と合わせて適切に解釈してもらえると助かります。2そのものの使い方には焦点を当てていないので、それについては他の文献を当たってください。参考文献ライブラリのバージョン本稿の説明は、以下のバージョンのライブラリを前提とします。2.0.1サンプルアプリFlow/Mortar/Dagger2を利用したアプリのサンプルとして、Todoアプリを用意しましたので、参考にしてください。https://github.com/tai2/flowmortardagger2demoこのアプリは、Todoリスト画面、Todo追加画面、Todo編集画面という3つの画面からなります。各画面のユニットテストも実装してあります。Square風MVPのメリット・デメリットSquare風MVPでは、Androidの標準コンポーネントであるFragmentを完全に捨ててしまいます。それどころかActivityさえも基本的に使いません。標準の開発者ガイドや参考書に書いてあるのとは、まったく違うやりかたでプログラミングをしますし、参考にできる情報も多くはありません。そこまでして導入する価値のあるものなのかどうかという点が、気になっている方が多いと思いますので、まずはメリットとデメリットについて挙げます。以下は、Square風MVPで、そこそこの規模の商用アプリを一本開発してみての感触です。箇条書きで足りない部分は追加で補足します。メリット当然ながら、Fragment関連でのIllegalStateExceptionからは開放される。Changesへの特別な対応が不要になる。画面遷移をするときに、(画面を表す)Pathのコンストラクタに必要な情報を渡せる。Presenterは、Viewに直接依存しないのでテストし易い。Daggerにより、オブジェクトの初期化を自動でできる(フィールドの宣言だけで済むのでコードがスッキリする)。Daggerにより、「どこから」オブジェクトを取得するかと、「どの」オブジェクトを初期化するかを切り離すことができる。適切に状態を保存・復元する必要があります。Changesを気にする必要はありません。Fragmentでは、画面遷移をするときにFragmentオブジェクトをnewで生成して、FragmentTransactionに渡します。これは、Fragmentにあまり慣れていないプログラマーがよく陥る罠の一つです。Fragmentには、デフォルトコンストラクター以外のコンストラクターを定義してはいけません。Square風MPVで画面遷移時に用いるPathは、なにも特殊なことはないただのオブジェクトなので、気兼ねなくいろいろなコンストラクターを定義して、素直にオブジェクトを生成できます。されていますが、そのメリットをそのまま受けられます。Squareのサンプルコードでは、Presenterが具象Viewと密結合する形となっているので、若干のアレンジが必要です(より.NETのMVPに近い形にします)。これは、たとえば、開発版とリリース版で使用するオブジェクトを切り替えたり、テスト版では使用するオブジェクトをすべてモックに切り替えるといったことを、一行の変更で行えるようになるということです。デメリット学習コストが高い。ドキュメントがほとんどなく、使い方を調べるためにライブラリのソースコードを読む必要がある。2ボイラープレートが多くなる。1画面ごとに、Path,Presenter,View,Componentすべてを記述する必要がある。APIの互換性についての保証はない。ライフサイクルの種類はたしかに少ないが、十分な機能を提供していない。Activityの機能に直接アクセスできないことが不便なときがある。1は、この記事を読めば、多少ハードルが下がるのではないかと思います。SDKは、基本的にAPIの後方互換性を保つ形でバージョンアップされていきますが、FlowとMortarはサードパーティーであるSquareが、自社アプリのためのライブラリとして開発したものをOSSとして公開しているに過ぎないので、そのような保証はありません。APIの破壊的変更が行われました。このため、これまでFlowとMortarについて書かれた記事の多く3やコードは、そのままでは使えなくなってしまっています。4について、たしかにFragmentのライフサイクルよりは、MortarのPresenterのライフサイクルのほうがシンプルだとは思うのですが、実際のアプリを作るのに十分な機能を提供できていない場合があります。基本的にはポートレイト固定で、特定の画面のみ回転可能にしたいといった要求はよくあると思いますが、そのようなケースで実装に試行錯誤が必要になることがあります。スクリーンオフになったときに、動画の再生を停止するといった単純なことも、それをやるのに相応しいライフサイクルメソッドは用意されていないため、Viewのメソッドを駆使してがんばる必要があります。5は、4とも関連しますが、けっきょくのところ実際のアプリ開発では、Activityの機能にアクセスしたくなることがあります。アクションバーのタイトルを変更するためには、Activityへのアクセスが必要です。Flowでは、Activityに直接アクセスする手段は用意されていないため、一工夫が必要になります。Presenterのシングルトン化に、これらのライブラリの提供する機能が必要だからです。1を前提としていました。Injectionを実現するためのものであり、javax.inject(JSR-330)の一実装です。オブジェクトグラフの構成と検証をコンパイル時に行うということです。そのため、生成されるコードは簡潔で、素早いものになります。1は、オブジェクトグラフの構成(リンク)をランタイムに行います。オブジェクトグラフを動的に拡張することが可能です。コンパイル時のオブジェクトグラフ検証を完全には行いません。移行する予定もなかったようです。1は、今年の5月で更新が止まっています。1とのほうが相性はいいのかもしれません。1と併用するパターンのほうが情報が多く使い方が確立されていると思います。確立された方法はないという状況です。2と併用する場合には、導入までの努力が多く必要になると思います。1のほうが向いているかもしれません。1のほうがいい選択なのかもしれません。Model-View-PresenterModel-View-Presenterパターンは、Model-View-Controllerを改変したGUI用のアーキテクチャです。MVPの世界では、すべてをドメインモデルとユーザーインターフェイスという2つに綺麗に分割し、Controllerのようなどちらにもまたがる半端者はいなくなる、というのが筆者の解釈です。PresenterとViewはインターフェイスによって分離されるため、UI(Presenter)の自動テストが容易になります。Viewの表現自体はテストできませんが、ユーザーアクション=Presenterのメソッドとなるため、アプリ動作のシナリオを、通常のユニットテストの枠組みで検証できるようになります。コンポーネントの責務この節では、利用する各コンポーネント6の責務について簡単に説明します。それを利用するアプリがすべきこととしては、なにが残るのかについても触れます。Flowの責務Flowは、View単位での画面遷移の仕組みを提供します。これを使うことによって、カスタムビューをベースとしたアプリの構築ができるようになります。Flowは、独自にバックスタック(History)を管理し、バックスタックに対する操作を定義します。Activityのライフサイクルメソッドの処理を肩代りする、FlowDelegateというクラスも提供します。Mortarの責務Mortarは、階層化されたスコープ(MortarScope)を提供します。これにより、Contextを階層化したり、階層毎に異なるサービス7を定義することが可能となります。スコープに応じて適切にリソースを破棄します。Mortarは、Flowの管理するバックスタックをBundleに保存・復元します。2は、アプリの定義したオブジェクトグラフ8から、Componentクラスを生成します。Componentクラスは、オブジェクトを生成してフィールドに注入します。シングルトンとして指定されたオブジェクトを保持します。この場合のシングルトンとは、static変数ということではなく、何度injectしても同じインスタンスが再利用される、という意味です。したがって、Componentインスタンスが変われば、シングルトンとして指定されているインスタンスでも同一とは限りません。Componentは、MortarのPresenterインスタンスをシングルトンとして保持します。アプリの責務アプリは、Activityのライフサイクルメソッドを、FlowDelegateやBundleServiceRunnerに委譲します。バックスタックの状態をParcelableにシリアライズするための方法も定義します。追加のサービスをApplicationやActivityに埋め込むのもアプリの責務です。これらはFlowやMortarのサンプルコードに必要なクラスやコード片が用意されているので、基本的にはそれを使えば済みます。Componentを実装し、それらの間の遷移やビジネスロジックを実装します。Component(必要に応じてModule)カスタムView(とそのレイアウトファイル)の4つから構成されます。Squareスタックにおける基本的な構成Pathは、画面を特定するアドレスのようなもので、画面遷移時に使われます。Componentは、各画面のオブジェクトグラフを定義します。カスタムViewは、画面の視覚的な表現です。Componentによってinjectされます。サンプルTodoアプリのTodo追加画面の実装は、以下のようになっています。...}Activityは、アプリケーションでひとつしか存在しません。Square風MVPアプリにおいて、Activityは、Applicationと同様にシングルトン的な存在です。ChangesでActivityは破棄されるので、常に同一のインスタンスであることを期待してはいけません。各画面ごとのViewを格納するためのコンテナビュー(PahtContainerView)が、Activityに対してひとつあります。ように異なるレイアウトを出し分けする場合には、コンテナビューもその分用意します。サービスの追加Androidにおいて、Contextは、システムにアクセスするための重要な手段です。ApplicationやActivity自身もContextの一種であり、通常、いつでもどこからでもアクセスすることが可能です。オーバーライドして、独自のサービスをContextに追加することで、システムを拡張するという方法を多用します。Activityのライフサイクルメソッドを肩代わりするためのFlowDelegateインスタンスを埋め込みます。サンプルアプリでは、ApplicationのgetSystemServiceは以下のようになっています。使用して、独自のContext定義し、機能を拡張するという手法も使います。ContextWrapperは、Contextインスタンスを包んで追加の機能やフィールドを持たせるためのプロキシークラスです。SDK内でも多用されていることがわかります。ただしく認識するのが、コードを理解する鍵になってきます。それによって利用できるサービスが異なるからです。追加されるサービス一覧Flow/Mortarアプリで独自に定義される(非標準の)サービスとしては、以下のようなものがあります。純粋に内部的なもので、アプリから直接は利用しないものも含まれます。Flowインスタンスを提供する。Contextに付随するPathを提供する。Path固有のContextを提供する。Contextに付随するMortarScopeを提供する。Contextに付随するBundleServiceRunnerを提供する。Componentを提供する。MortarScopeスコープ(MortarScope)は、Mortarの提供する主たる機能のひとつで、サービスの辞書を保持するオブジェクトです。スコープは、それ自身ツリー構造を成します。実際のアプリでは、次の図のように、Rootスコープ(Applicationスコープ)、Activityスコープ、Pathスコープという3階層までになります。Pathスコープが2ノードになるのは、マスター・ディテールなど複数画面構成の場合のみで、1画面構成のアプリの場合は、常に1ノードです。MortarScopeのツリー構造スコープインスタンスに対して、サービスの検索を要求すると、まず自分の持つ辞書に該当するサービスがあるかを検索し、なければ親に遡って検索していきます。スコープに登録されるサービスがScopedインターフェイスを実装している場合には、スコープへの登録時にonEnterScope()が、スコープの破棄時に、onEnterScope()が呼ばれます。onExitScope();}これらのメソッドで、前処理と後処理をすることで、スコープの寿命と同期したリソースの初期化と回収が実現できます。サービスとしてスコープにに登録することも可能です。Changesを生き残ります。Rootスコープは、Applicationによって保持されるインスタンスだからです。PresenterのライフサイクルPresenterには、次の4つのライフサイクルメソッドが用意されています。PresenterがScopeに登録されたとき(画面遷移時)に一度だけ実行される。Presenterのロード時に呼び出される。Presenterの中断時に呼び出される。PresenterがScopeから登録解除されたとき(画面遷移時)に一度だけ実行される。Changeのたびに呼びだされることはありません。ChangeのたびにViewインスナンスは再構築されるので、Viewに依存した初期化などはここで実行することはできません。onLoadのタイミングでは、実際のViewが生成されInflateも完了しているので、初期化処理はこの中でやるのが適切です。onLoadは、ActivityのonCreateかViewの表示時(takeView)のタイミングで呼ばれます。onSaveは、ActivityのonSaveInstanceStateがトリガーになります。onEnterScopeとonExitScopeのタイミングは、アプリの実装に依存しますが、通常は画面遷移時です。Presenterには、ActivityのonStaret,onResume,onSuspend,onStopに相当するようなライフサイクルメソッドが存在しないため、実際のアプリ実装では若干機能が足りないことがあります。スクリーンオフになったときに、動画やオーディオの再生を停止したいといったケースには、上記のメソッドだけでは対応できません。いったケースでは、ViewのonDetachedFromWindowや、onWindowFocusChangedといったメソッドを駆使して対応します。PathとPathContextFlowでは、Viewが画面遷移の単位になります。Viewと一対一で対応付けて、そのViewを識別するために用いられるのがPathです。Flowでの画面遷移は、次のようなコードで実行されます。TodoEditPath(todoItems.get(position).getId()));}サンプルアプリでは、次のコードのように、@Layoutというアノテーションを使って、Pathとレイアウトファイルを関連付けています。...これ自体はFlowに用意されている仕組みではありません。Pathとレイアウトファイルを関連付けて、レイアウトファイルをinflateするといったあたりは、アプリ側コードになります(SimplePathContainer)。定型処理なので、サンプルにあるものをそのまま使えば事足ります。PathContextは、Pathインスタンスと対になるContextで、Path固有のサービスを提供します。画面遷移の際には、直前のPathから親Contextを引き継いで、新しいPathContextを作成します。新しいContextを指定したLayoutInflaterによって、レイアウトファイルをinflateすることで、そのContextに紐付くViewインスタンスを生成します。同時に、前の画面のPathContextは破棄します。ちなみに、PathContextの親Contextは、Activityです。PathContextは、遷移時に生成・破棄されるこうして、PathContextのサービスを利用可能なViewのセットアップ、および、そのクリーンアップがされます。内部にHistoryインスタンスと、Dispatcherインスタンスをひとつづつ保持しています。このクラスを通してバックスタック(History)を操作することで、画面遷移を行います。Flowインスタンスは、FlowDelegateインスタンスの中に保持されます。FlowDelegateインスタンス自体は、Activityに持たせますので、実質的に、Flowインスタンスはシングルトンのようなものです。ActivityはライフサイクルメソッドをFlowDelegateに委譲するまた、FlowDelegateインスタンスは、上図のようにActivityのライフサイクルメソッドの委譲先となります。これにより、適切にActivityのライフサイクルがハンドリングされ、アプリ側ではActivityのことを気にせずにすみます。Historyのエントリーには、View階層の状態と、対応するPathオブジェクト自身が含まれます。Changesなど状態保存が必要なときには、Historyまるごと、Bundleの中にシリアライズされます。Pathは、アプリ定義のシリアライザー(StateParceler)で、シリザライズ・デシリアライズされるのですが、サンプル実装では、GsonParcelarというGsonを使った実装になっています。したがって、PathはGsonでシリアライズ可能なオブジェクトでなければなりません。PathContainerViewViewは、PathContainerViewの子ViewとしてViewツリーに追加されます。それらは、PathContainerに管理されます。PathContainerViewは、Activityによって表示されます。PathContainerViewは、マスター・ディテールなどの場合に複数になることがあります。PathContainerは子Viewを管理するFlowDelegateは、コンストラクターでDispatcherインスタンスを引数に取ります。画面遷移が起きたときには、Dispatcher#dispatchが呼ばれて、そこから最終的には、PathContainer#performTraversalが呼ばれます(呼ばれるようにアプリコードを構成します)。このperformTraversalの中で、Viewの入れ替えや、アニメーションの実行、PathContextの生成・破棄といった処理を行います。これらの処理は、Flowの外側の部分なのでアプリの責務の範囲ですが、Flowのサンプル実装で提供されているSimplePathContainerをそのまま使えば、十分です。状態の保存と復元メカニズムHistory(バックスタック)や、View固有の状態(onSaveで保存されるもの)などは、すべてBundleに保存されます。Bundleへの状態保存とBundleからの復元は、BundleServiceRunnerが行います。このインスタンスは、Activityのスコープにサービスとして登録されるので、アプリから見れば、実質的にシングルトンです。このオブジェクトは、保存の大本になるルートBundleを保持します。BundleServiceRunnerは、ActivityのonCreate/onSaveInstanceStateをトリガーとして、保存と復元を行います。Bundleはツリー構造を成すルートBundleの下には、スコープのパスをキーとして、スコープごとのBundleが格納されます。スコープごとのBundleの下には、Bundlerインスタンス毎にBundleがぶら下がります。Bundlerは、Scope毎にリソースを管理するためのインターフェイスです。onExitScope();}PresenterもBundlerを内部に保持しており、同名のライフサイクルメソッドも、これに準じたタイミングで実行されます。Presenter用のBundlerでは、クラス名が保存用のキーになります。Changesを生き残るためにシングルトンである必要があります。2のScopedインスタンスの機能を使います。2モジュールが定義されています。provideRealmConfiguration();}RealmConfigurationには@Singletonアノテーションがついているため、同一のComponentインスタンスを使用する限り、何度injectしても、同一のインスタンスが使い回されます。2では、オブジェクトグラフに含まれるインスタンスは、Componentインスタンスを通して取得できます。Component自体を他のComponentに依存させることで、オブジェクトグラフを階層化することができます。階層化されたオブジェクトグラフにおいて、他のComponentに依存するComponentは、依存されるComponentとは異なるスコープを持つ必要があります(依存するComponentのほうが寿命が短かくなります)。そのことを表現するために、必要に応じて、@Scopeアノテーションを定義する必要があります。異なるComponent階層に対して、同じ@Scopeアノテーション(例えば@Singleton)をつけようとしても、ビルドエラーになってしまいます。ここで言うスコープは、Mortarのスコープとは異なるもので、直接の関係はないので注意してください。筆者の提案するMVP構成では、Rootスコープと、Pathスコープという2種類のスコープを定義します。Rootスコープ用には、標準の@Singletonをそのまま使い、Pathスコープ用には、画面毎のスコープという意味で、@PerScreenスコープを新設します。Rootスコープには、アプリケーション全体を通じて有効なオブジェクトを置き、Pathスコープには、画面毎に必要に応じたオブジェクトを置きます。PathスコープのComponentインスタンスは、次節の仕組みにより、画面を去るときにいっしょに破棄されます。PathScoperサンプルアプリでは、他の人のやりかたを参考にして、@WithComponentというアノテーションを導入しました。これをPathに付与することで、PathとComponentを対応付けます。...そして、Pathへの遷移時に、リフレクションでComponentクラスを検索して、Componentインスタンスを生成し、PathレベルのMortarScopeに紐付けます(MortarContextFactory)。これをやるためのヘルパーとして、PathScoperというクラスを作成しました。Androidのリフレクションは重いということなので、一度検索したComponentとModuleコンストラクタはキャッシュするようになっています。Componentインスタンスは、PathレベルのMortarScopeの管理下にあるため、画面遷移時にきちんと破棄されます。Rootスコープ(Applicationレベル)のオブジェクトグラフは破棄されずに残ります。ハードコーディングになっています。構成がこれ以上変更になることはないと思うので、これでも十分な気はしますが、さらなる柔軟性を追求する余地が残っています。縦横での切り替えFlowでは、ポートレイトとランドスケープで実装を切り替えることも容易です。通常のリソース切り替えのメカニズムを使って、ポートレイト用とランドスケープ用のレイアウトファイルを切り替えることで、実装ごと切り替えることができます。いくつかバリエーションが考えられます。縦横の切り替えと同じ要領で、ルートのレイアウトファイルをスマートフォンとタブレットで分けることで、実装を変えるという方法があります。Flowのサンプルアプリが、その方法でレスポンシブ対応をしているので、そちらを見ると良いと思います。直近の変更でサンプルコードが削除されてしまったので、0.12以前をチェックアウトしてください。ユニットテストMVPでは、ユニットテストがしやすくなるとよく言われますが、それは、主に、Presenterと具象Viewが、インターフェイスによって分離されているためです。ところが、SquareのMVP実装では、画面の実装に利用するViewPresenterは次のようになっています。}}ここで、Viewは、android.view.Viewです。これでは、Presenterが具象Viewと直接結びついてしまうため、モックを使ってテストを実装するのがやりにくくなります。基底クラスのPresenterはジェネリックになっており、付随するオブジェクトの型を変更できるので、以下のようにします。}}より具体的なViewのインターフェイスは、画面ごとにContextHolderを拡張して定義します。これで、PresenterとViewの結びつきを間接化できます。モックViewの定義も容易になり、以下のような感じでテストコードが書けます。});}もちろん、AndroidにはEspressoがあるので、このようなアプローチを用いる必要はないかもしれません。実際、Espressoでは具象Viewをそのままテストできるので、カバーできている範囲はこのアプローチよりも広いです。今回はMVPがテーマですので、よりMVPのメリットを活かせるユニットテストのアプローチを模索してみました。両者を比較して、どのようなメリットとデメリットがあるのかは興味深い話題ですが、この記事の範囲外です。2の自動生成するクラスをリフレクションで検索するので、それに関連したクラスをProguardから除外します。2を使用したMVPアーキテクチャによるAndroid実装のメリット・デメリットを分析し、その後、ライブラリの詳しい使い方と内部のメカニズムを見ました。メリットは、Fragmentを使用しないことなどによる安定化や、MVCとは違ったアプローチの自動テストが可能になることでした。デメリットは、標準的な方法を外れることによる、高い学習コストや、機能の不足、またボイラープレートの増加などです。2とアプリ自身、それぞれの受け持つ責務の振り分けについて学び、各画面の基本的な構成が、View,Path,Presenter,Componentの4つから成ることを学びました。Flow,History,PathContext,Presenter,Bundler,Componentといった要素が連携してアプリを構成することを学びました。SquareのオリジナルMVPに変更を加えて、Presenterを直接テストするための方法を学びました。2を組み合わせたアプローチは、まだ決定的なやりかたが確立されておらず、Flow/Mortarの進化と共に変わっていくと思います。Flow/Mortarを利用することで、かえってボイラープレートが増加し、煩雑になってしまっている部分があるのが、大きな欠点です。2自身のアプローチと同様、アノテーションとコード生成あるいはリフレクションを活用することで、改善の余地があります。いった試みが出てきているので、これらを活用することで、記述量を減らせるかもしれません。Ryanによる紹介記事。SquareがFragmentをやめてMVPに移行した理由の説明。Flow/Mortarの解説。Fragmentとカスタムビューベースの設計の比較。