こんにちは。技術本部 Mobile Application Groupの山本です。EightのAndroid版の開発を行なっています。
Eightのアーキテクチャは何度か部分的な見直しを行なっているのですが、現在の主流からは古くなってしまった部分も存在します。そのため全面的に見直しを行うことにしました。この試みは週1回のアーキテクチャ検討会として1年以上継続しています。
Rxをコルーチンに置き換えるといった、利点が明確なものについてはそこまで大きく議論になる部分はなかったのですが、状態管理については抽象度が高く、絶対的な正解というものが存在しないため、かなり議論がありました。
特に弊社の2つの代表的なアプリであるSansanとEightでは状態管理方式の違いがあり、お互いの利点について認識を合わせることが難しい部分がありました。
これに対してDomainStateという別の概念を導入することで認識のすり合わせを行いました。これについてお話しします。
変更前のEightアプリの状態管理
変更前のEightアプリの状態管理は以下のようになっていました。
以下の議論では話をわかりやすくするためRepositoryという言葉は使用せず、LocalStorageとNetworkと記載します。実際にはLocalStorageやNetworkはモジュールとしてはReposityからアクセスされる場合があります。
Android Architecture ComponentsのViewModelが発表される前に作成されたのでViewModelはありません。Fluxとは異なりますが、Fluxを参考に単一方向のデータフローを意識して作られました。
サーバーからデータを取得して、その結果をViewに表示するまでのデータの流れは以下のようになります。
- Viewはデータ取得処理を行うためにUseCaseを呼び出します。
- UseCaseはNetworkでサーバーからデータ取得を行なった後、その結果データをLocalStorageに保存します。
- LocalStorageは自身の変更を購読しているStoreに対して変更を通知します。
- Storeは自身の変更を購読しているViewに対して変更を通知します。
StoreからはView表示のための状態データを取得できます。普通のViewModelで考えると、ViewModelのメソッドがUseCase、状態の通知をStoreで行うイメージに近いです。
Sansanアプリの状態管理
Eight Android版の開発メンバにはSansan Android版の開発から異動してきたメンバがいます。そのためSansanアプリの状態管理についても説明してもらい、選択肢の一つとして検討しました。
Sansanアプリの状態管理はおおむね以下のようになっています。
サーバーからデータを取得して、その結果をViewに表示するまでのデータの流れは以下のようになります。
- Viewはデータ取得処理を行うためにActionCreatorを呼び出します。
- ActionCreatorはNetworkでサーバーからデータ取得を行ないます。
- (オプション) ActionCreatorはオフライン対応が必要なデータの場合はLocalStorageに保存します。
- ActionCreatorは結果データをActionとして作成し、Dispatcherに渡します。
- DispatcherはActionをStoreに渡し、StoreはActionに応じて自身の状態を変更します。
- Storeは自身の変更を購読しているViewに対して変更を通知します。
Sansanアプリの状態管理はFluxを採用しています。詳細については以下を参照してください。
buildersbox.corp-sansan.com
Storeとは何か
EightとSansanの状態管理を比べると、単一方向のデータフローという点では似ています。一方でStoreとLocalStorageの位置付けは異なる部分があります。
Eight
- ネットワークから取得したデータは一旦LocalStorageに保存される
- StoreはLocalStorageの変更を購読し、Viewの形式に変換する
- Storeはオンメモリに状態を保持しなくても良い。LocalStorage経由でデータを直接取得し、随時変換が可能
Sansan
- ネットワークから取得したデータはDispatcher経由で直接Storeに送られる
- Storeはオンメモリにデータを保持しなくてはならない
- LocalStorageは基本的にはネットワークから取得したデータを保存しない
そのためSansanから来たエンジニアからはEightのStoreには違和感があったようです。互いの理解のために議論をしたのですが、以下の理由から難しい部分がありました。
- 両方のプロダクトを詳しく知っている人がいない
- 全く異なるなら違うものと割り切れるが、両方がFluxを参考にした背景から似ている部分がある。そのため自分が知っているものを基準に理解しようとしてしまう。
- Web FrontendにおけるReduxのように特定のオープンな実装ではなく、両方が独自に開発した実装のため、共通の認識が持ちにくい
DomainStateによる抽象化
このギャップを埋めるため、これまで双方のフレームワークで使用していた言葉を使わずに、別の言葉で抽象化することで説明を試みました。
まずViewStateと NetworkDomainStateという2つの状態を定義します。この言葉が他の意味で使われる文脈もあると思いますが、それは一旦無視して、この説明における定義を新たに作成します。
以下でスコープというのは、アクセス可能な範囲と生存期間の両方を指します。
データの提供のPush型とは変更部分だけをFlowのようなObserverパターンで取得できるということです。Pull型は取得を明示的に行なって全データを取得することしかできません。
ViewState
- Viewの表示するデータと一致するデータを保持する
- スコープはViewと同じ
- 通常はオンメモリのデータ
- データの提供はPush型でもできる
NetworkDomainState
- 全ユーザーの情報を含むシステム全体のドメインデータを保持する
- スコープはアプリの生存とは無関係
- サーバー上に保存されている
- データの提供は基本Pull型のみ
アプリのデータ表示というのは、基本的にはNetworkDomainStateをViewStateに変換する作業です。図で表すと以下のようになります。
このモデルはシンプルですが、以下のような問題があります。
- オフライン対応ができない
- 正確な表示のためには画面を表示するたびに全データの再取得が必要
Webであれば上記の制限はシステム上避けられないので仕方ないのですが、アプリの場合はもう少し最適化できそうです。そのためLocalDomainStateという状態を導入します。
LocalDomainState
- ユーザーに限定されたドメインデータ
- スコープはアプリ全体
- アプリ内のオンメモリまたは永続ストレージに保存されている
- データの提供はPush型でもできる
これを以下のようにViewStateとNetworkDomainStateの間に入れます。図で表すと以下のようになります。
これにより上記のNetworkDomainStateから直接取ってくる場合の問題を解決できます。
- オフライン対応についてはLocalDomainStateを永続ストレージに保存すれば参照できる。
- データの再取得については、LocalDomainStateはPushでViewStateにデータを送ることができるので、更新時は全てを取得しなくても、必要な変更部分だけの差分を受け取ることができる。
しかしNetworkDomainStateとLocalDomainStateの同期については、Pullで行う必要があります。そのため状態管理としてはLocalDomainStateのない場合より複雑になることになります。
ポイントとしてはLocalDomainStateはNetworkDomainStateの単なるキャッシュではないということです。
キャッシュというのは、ネットワークに接続できない、もしくはネットワークのデータが前回のアクセスから変更されていないことがわかる場合に、ネットワークの代わりにキャッシュからデータを取得します。この場合もPullでデータを取得することには変わりありません。
EightとSansanの状態管理を再度比較する
EightとSansanの状態管理を上記のDomainStateモデルを用いて再度比較してみます。
EightはStoreがViewState、LocalStorageがLocalDomainStateとして機能しています。一方でSansanはStoreがViewStateである点は同じですが、LocalStorageはキャッシュであり、LocalDomainStateではありません。
これについてはアプリの立ち位置の違いからくる最適化の結果ではないかと考えています。
Eightは個人向けという点もあり、PCよりアプリがメインで使用されています。そのためローカルストレージの利点を最大限に利用した構成です。
一方でSansanは法人向けということもあり、どちらかというとPC版がメインです。そのためWeb Frontendに近いLocalDomainStateを使わない実装の方が適しているのかと思います。
またセキュリティの観点からも、Sansanはローカルに保存するデータを細かく制御したい機能要件があり、ローカルストレージを使用しなくても成立する構成なのかと思います。
変更後のEightの状態管理
上記の議論をもとにEightの状態管理を今後どのようにするか検討しました。結論としては状態管理自体は大きな変更は行いませんでした。
変更としてViewModel層を新たに導入することにしました。ViewとStore、UseCaseの間にViewModelが入る構造になります。
大きな変更を行わなかった理由は以下になります。
- 現状の状態管理自体に大きな問題点がある認識はない
- 今回の議論でEightの状態管理に対する違和感が解消された
- Sansanの状態管理は優れた面もあるが、積極的に現在のEightの状態管理から変更するほどの利点が感じられない
- 現在のEightの状態管理はGoogleの推奨アーキテクチャと構造的には似ており、特殊な構成というわけではない
ViewModel層を導入したのは、Android標準のViewModelを使用することにより、スコープの問題を解決しやすくするためです。
一方でViewModelの導入によって、ViewStateを持つ層がViewStateとStoreの2つできてしまうことになります。これについては以下のように切り分けています。
- 従来のStoreの役割は維持し、ViewModelでは基本的にはStoreを組み合わせて状態を作る。
- 編集中の画面やローディング状態など、DomainStateを変更しない画面の一時的な状態については、Store経由でなくViewModel内で状態変更を完結させて良い。
これにより DomainState → ViewStateの変換をモジュール化することでViewModelの肥大化を防ぐことができます。
また、LocalStorageやNetworkへのアクセスは実際はRepository層になるのですが、RepositoryへのアクセスをViewModelで直接行わず、常にUseCaseとStore経由にできることで依存が明確になります。
まとめ
今回状態管理は大きな変更は行いませんでしたが、ViewModelの導入によりスコープ管理と状態管理のシンプル化ができました。
状態管理というのは概念自体が曖昧で実装も多様です。それぞれ特徴はありますが絶対的な正解というのは存在しません。またメリットやデメリットは大きな規模のプロジェクトを長期間運用しないと見えにくいです。そのため比較がかなり難しいと言えます。
今回は状態管理のフレームワークを比較検討するにあたり、フレームワークをもう一段抽象化することで理解を深めました。特に抽象的なものを比較する際にこのアプローチは一つのやりかたではないかと思います。