Androidアプリのリアーキテクチャ
この記事は、NAVITIME JAPAN Advent Calendar 2020の 6日目の記事です。
こんにちは、うつぼです。ナビタイムジャパンで「NAVITIME」アプリのAndroid開発を担当しています。
現在私のチームでは、アプリのリアーキテクチャを検討しており、どのような設計をするか・どのようなライブラリを利用するかなど具体的な実装も含めて研究開発を行なっています。そこで今回は、大規模アプリのリアーキテクチャについて、現在検討していることを書かせていただきます。
なぜリアーキテクチャを検討するのか
まず始めに、現在のアーキテクチャは次のようになっています。
・モノリシックな設計
パッケージは分けられていますが、モジュールがひとつなので、Kotlinの場合に可視性が適切にできないことがあります。
・Java/Kotlinの混在
新規開発はKotlinになっていますが、まだまだJavaのコードも多く、Kotlinをメインで学ぶ新卒へのハードルが高かったり、Javaであることでボイラープレートが増えたり可読性が下がったりしている部分もあります。
・似た目的のライブラリが複数入っている
複数の設計が混在していることもあり、同じ用途の別のライブラリが導入されてることもあって、コードを読んで使うときに「どれを使うべきなのか」と迷う事象が発生しています。
次に、検討背景には大きく2つの理由があります。
1. 複数の設計思想が混在している
開発が行われていくうち、初期開発時の設計にそぐわない場面が増えてきて、別の設計に切り替えることが多くなりました。
定期的にリファクタリングなどのコード改善は行なっていますが、コア部分や完成された機能では、初期設計のままのコードも存在します。特に、責務が多いActivityやFragmentといった、多くのロジックを持ち、肥大化しているクラスです。
チームには新卒含め新しいメンバーが入ったり、メンバーが入れ替わることもあるため、調査や機能設計でコードを見る際に理解し辛かったり、参考にすべきコードが分かり辛かったりしています。また、機能やレイヤーでパッケージ分けはされているものの、モジュール分割がされていないため、やろうと思えば設計を無視して別レイヤー/機能のクラスを利用することも出来てしまうようになっています。(ルールとして運用していても、出来ないようになっているのと出来てしまうのでは分かりづらいと思っています)
2. 開発言語や環境が進化し、成熟したアーキテクチャが登場している
現行アプリの設計検討時には、まだAndroidArchitectureComponents(AAC)も登場しておらず、スタンダードなアーキテクチャが登場していませんでした。
現在では言語もKotlinが推奨され、公式ライブラリで設計をサポートする機能も登場しています。特に、画面遷移のライブラリであるNavigation・DIフレームワークのDagger-Hiltなどは便利ですが、現行アプリに一部的に導入するより、全体として設計を変えたほうが効果的に利用できると考えました。
このような背景から、フルKotlinで開発効率を上げるべく、リアーキテクチャを検討し始めました。
リアーキテクチャ
マルチモジュール
すでに世の中の新しいアプリでは多くで取り入れられていますが、フルKotlinのマルチモジュールを採用します。(以下「モジュール」は断りがない場合マルチモジュールでのモジュールを指します)
構成図はこのような形です。(依存線や一部モジュールは簡略化しています)
大きな構成としては4つに分かれており、それぞれの狙いは以下です。
appモジュール
Applicationモジュールです。表現上線は表示していませんが、DIの関係上appモジュールは全モジュールを参照しています。NavigationコンポーネントのNavigation Graphはここに配置しています。
機能モジュール群
機能ごとにモジュールを分割します。狙いとしては、コード管理の面と、ビルド速度向上があります。マルチモジュールでは差分ビルドの場合、変更した部分に依存するモジュールのみがビルドされます。
開発初期を除き、複数機能に同時に手を加えるケースは少ないため、このような戦略を取りました。
共通ウィジェットモジュール
全ての機能モジュールから依存されるモジュールです。例えば以下のようなものが入ります。
・Fragment共通の拡張関数
・DomainModelとUIを紐付ける処理
・機能共通で利用するstringsの定義
また、デザインシステムとしてAtomicDesignの導入を考えており、機能共通で使う原子・分子・有機体もここに入ります。
レイヤーのモジュール群
UI/データアクセスはもちろん、さらに細かくレイヤーを分割しています。
UI/データアクセスという面では、UIの変更とロジックの変更の影響を分離することができ、それぞれを変更しやすくなります。分割することで書くコードの量は増加します。しかし、UI改善やABテストなど高頻度に手を入れる部分が独立して存在することで改善サイクルを高速化することができると判断しました。
細かく分割するという部分での狙いは、参照方向の強制を可能にし、意図しない参照を行わせないことにあります。例えば、
・UseCaseやDBからUI操作を出来ないように制限をかける
・UI層からはDomainやInfraを直接操作出来ないように制限をかける
といったことを行います。モノリシックなモジュールでも、パッケージを分けることでまとまりを作ることは出来ますが可視性を強制することは難しいです。
・JavaだとパッケージprivateがあるがKotlinだと無い
・あったとしてもパッケージprivateでは、「どこからだと見られる」という制約を付けられない
強制しなくとも、ルールや説明・レビューでカバーすることもできますが、コードの仕組みで担保されている方が運用しやすいと思います。
また、Kotlinのinternal修飾子を利用することで、モジュール間ではInterfaceのみを公開し、実装はモジュール内に留めるといったことも可能になります。Interfaceのみを公開することで、DIと合わせてテスタビリティを向上させることが出来ます。
Navigationコンポーネント
画面遷移には、AACのNavigationコンポーネントを利用することにしました。主な理由は以下です。
FragmentManagerは複雑
FragmentManagerは種類や状態が多いため、時に複雑で使いづらいです。Navigationが発表された2018年のGoogleI/Oでも「No more FragmentTransaction」と言われていました。
Navigation Graphを用いて画面遷移を管理できる
引用元:Navigationコンポーネントスタートガイド
Navigation Graphというものを使って画面遷移を定義できます。実態は単純なxmlですが、AndroidStudioではGUIで表示・操作可能になっています。
画面数が多いと表示が重くなりますが、適切にファイル分割することで回避することも可能です。GUIの操作も十分使いやすいですが、今のメンバーは、実際のコードを触りたいという理由でxmlで読み書きしている人が多いです笑
SafeArgs
Navigationコンポーネント上で、型安全にDestination(遷移先画面)へ引数を渡せる仕組み(Gradleプラグイン)です。
SafeArgsを使うと、
・遷移元には、遷移先に必要な引数を渡せるメソッドが生えたDirectionsクラスが自動生成され
・遷移先では、by navArgs()という用意された拡張関数を利用することで引数を型安全で受け取れる
ようになり、遷移元と遷移先両方で整合性を保ってクラス引数を受けた渡すことが出来ます。
今までは、FragmentにnewInstanceメソッドを作成しBundleを通じて引数を渡すのが一般的でしたが、カスタムクラスでは取得時に型が保証されない仕様で、キャストする時に気を使ったり、キーを意識しなければならなかったりと何かと不便でした。Navigationコンポーネントと合わせて、より安全なコードを書くことが出来、快適に感じています。
簡易的ですが、コードで表すと次のようになります。
newInstanceでBundleを使うパターン
// newInstanceの定義
companion object {
private const val KEY_ARG_CONDITION = "key_arg_condition"
fun newInstance(condition: SearchCondition): SearchFragment =
SearchFragment().apply {
arguments = bundleOf(KEY_ARG_CONDITION to condition)
}
}
// 引数を渡す側(渡す側は型安全)
val nextFragment = SearchFragment.newInstance(condition)
// 引数利用時(型安全でない(Serializable/Parcelableからのキャストが必要))
val condition = requireArguments()[KEY_ARG_CONDITION] as SearchCondition
NavigationコンポーネントでSafeArgsを使うパターン
// Navigation GraphのSafeArgs定義
// 引数を受け取る側(遷移先)
<fragment
android:id="@id/search_fragment"
android:name="com.sample.SearchFragment"
tools:layout="@layout/fragment_search">
<argument
android:name="condition"
app:argType="com.sample.SearchCondition" />
</fragment>
// 引数を渡す側(遷移元)
<fragment
android:id="@id/top_fragment"
android:name="com.sample.TopFragment"
tools:layout="@layout/fragment_top">
<action
android:id="@id/to_search"
app:destination="@id/search_fragment" />
</fragment>
// クラスファイルのコード
// 引数を渡す側(遷移元)
findNavController().navigate(TopFragmentDirections.toSearch(condition))
// 引数を受け取る側(遷移先)
private val navArgs: SearchFragmentArgs by navArgs()
コールバックの実装もシンプルにできる
Navigation 2.3.0から、NavBackStackEntryにSavedStateHandleへのアクセスが提供されました。これを用いることで、遷移先Fragmentから遷移元Fragmentへデータを渡すことが容易になりました。
簡単に説明すると、
・遷移元FragmentはcurrentBackStackEntryのsavedStateHandleからLiveDataを取得しobserve
・遷移先(結果を返す)FragmentはpreviousBackStackEntryのsavedStateHandleに結果をセットする
ことで受け渡しが出来ます。
従来だと遷移元FragmentでListenerを実装して遷移先FragmentのonAttachでtargetFragmentを取得してcastするといった方法がありましたが、より簡潔に書くことが出来る印象です。
マルチモジュールとNavigationコンポーネント・SafeArgs
マルチモジュールとNavigationコンポーネントのSafeArgsを併用するにあたり、Navigation Graphの配置場所によって、そのまま利用する場合は何かを妥協する必要がありました。
3つの妥協例
1. Navigation Graphをappモジュールに配置
DirectionsはNavigation Graph(以下グラフ)と同じモジュールに生成されるため、appモジュールにグラフを定義する場合、機能モジュールからDirectionsを参照できない。
そのため、Router等のInterfaceを共通モジュールに定義する必要があり、SafeArgsを使ってDirectionsが自動生成されるのに、ボイラープレートを量産してしまう。
2. Navigation Graphを共通ウィジェットモジュールに配置
共通ウィジェットモジュールにグラフを定義した場合、機能からDirectionsクラスを参照することはできるようになるが、グラフから機能モジュールに定義されているFragmentなどが参照できない。
ビルドはできるものの、リファクタリング時等に考慮漏れでグラフを更新し忘れると実行時エラーとなってしまう。
また、グラフ作成時にFragment等の補完が利用できない。
3. 各機能モジュールにグラフを定義しincludeする
各機能モジュールにグラフを定義しincludeする場合、親グラフからネストされたグラフへはstartDestinationとなる画面にしか遷移できず、他画面から遷移される画面ごとにグラフを作る必要がある。
モジュール分割したい画面の単位とグラフを分割したい画面の単位が異なる場合に煩雑になってしまう。(今回の場合、遷移先画面のバリエーションが多く、グラフが大量に出来てしまう)
考えた解決方法
この問題を解決するため、公式のSafeArgsPluginを参考に、appモジュールにNavigation Graphを定義しつつDirectionsの生成先を共通モジュールにする「MultiModuleSafeArgsPlugin」を作成しました。これにより、
・Navigation Graphは、全画面の依存関係を解決できるappモジュールに定義するため、参照エラーにならず補完も効く
・Directionsは共通モジュールに生成されるため、機能モジュール間の移動はDirectionsを利用できる
という形で、問題点を解決できました。
Android公式のDIライブラリ「Dagger-Hilt」
現行アプリはDagger2(AndroidSupport未使用)を利用しています。Android用のDIライブラリとしてHiltが登場して、使ってみて以下の点で良さそうだったので、リアークテクチャ検討で導入することにしました。
ボイラープレートの削減
DaggerAppComponentを書かなくて良くなりました。
Activity・FragmentへのInjectが容易
今までだとApplicationを取得してキャストした後、AppComponentを取ってきてInjectという流れでしたが、@AndroidEntryPointをつけるだけで@Injectを書いたものをInjectしてくれます。
ViewModelへのコンストラクタInjectionが容易
@ViewModelInject constructorとするだけで、DaggerModuleの定義があればコンストラクタインジェクションしてくれます。
Scopeの管理が楽
現状5つのスコープが用意されており、アノテーションとして定義するだけで、生存期間を設定できます。
・ActivityRetainedScoped
・ActivityScoped
・FragmentScoped
・ServiceScoped
・ViewScoped
注意点
Dagger-Hiltは便利な半面、深く理解しなくても使えてしまう部分もあります。うまく行っている分には良いかもしれませんが、問題が起きることもあります。
・エラーや不具合が起きた時に、DIやDagger2を知らないと、理由が分からず修正が難しい
・コンパルが通って使えてしまうが実はパフォーマンスが悪い書き方をしている可能性がある
そのため、メンバーの理解度に応じて「DIとは」「Daggerとは」という部分は共有していく必要はありそうだと感じています。
おわりに
検討という形で色々設計・実装を試している段階ではありますが、アーキテクチャだけでなくライブラリや言語の進化もあり、今までの課題を解決し、より良いプロダクトが開発できる気がしています。アーキテクチャに対してご意見などありましたら、フィードバックいただけると幸いです。