Merpay Advent Calendar 2019 の4日目担当は メルペイ Android チームの @KeithYokoma です。
Android アプリ開発ではこれまで、画面を構築するためのフレームワークとして Activity
と Fragment
があり、画面遷移もそれぞれに異なる API を使って実装していました。
近年では Navigation Architecture Component が登場し、これまで自分の手で記述していた Activity
や Fragment
間の画面遷移が XML で表現できるようになりました。
あるいは、bluelinelabs/Conductor のような Fragment
を代替する UI フレームワークを使う場合、フレームワークに備わっているナビゲーションの API を使って画面遷移を実装しているはずです。
一方で、より軽量で効率の良い (主に従量課金制でネットワーク速度の遅い環境向けや、ストレージ容量の節約を目的とした) アプリケーションの配布の仕組みもできていて、これを活用するためにマルチモジュールなプロジェクトの構成にする重要度が高まっています*1。
この記事では、マルチモジュールな構成のプロジェクトにおける画面遷移の実装方針について解説します。
前提
はじめに、メルペイ Android での画面の実装について軽く触れておきます。
私たちは各画面の実装のために bluelinelabs/Conductor を利用しています。このライブラリは Fragment
の代替として Fragment
と同じような API で画面を作り、画面遷移の機能も持っているライブラリです。
次のコード例は、レイアウト XML から画面を管理する Controller
を作成しています。
class FeatureAController : Controller() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup): View = inflater.inflate(R.layout.zmp_controller_feature_a, container, false) }
Fragment
と比較すると、ほぼ同じような API を持っていることがわかります。
class FeatureAFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = inflater.inflate(R.layout.fragment_feature_a, container, false) }
この FeatureAController
から別の Controller
へ遷移するには、次の例のように実装します。
class FeatureAController : Controller() { fun openFeatureBController() { router.pushController(RouterTransaction.with(FeatureBController())) } }
これに対応する Navigation Architecture Component を利用した Fragment での画面遷移の実装は次のようになります。
class FeatureAFragment : Fragment() { fun openFeatureBController() { val navController = findNavController() navController.navigate(R.id.fragment_feature_b) } }
この記事では Conductor での実装例を用いますが、Navigation Architecture Component を利用した場合でも同じ考え方が適用できるようになっています。
マルチモジュール構成における画面遷移実装の課題
メルペイ Android では、次の図に示すようなマルチモジュール構成を採用しています。
:app
は Android アプリケーション本体で、このモジュールで apk/aab を生成します。:feature_a
、:feature_b
、:feature_c
はドメイン単位で分割したモジュールで、Controller
やそれに対応するモデルなどを持っています。たとえば、ネット決済、コード決済、iD決済はそれぞれ別のモジュールに分割しています。:common_navigation
や:common_xxx
など、:common_
で始まるモジュールはすべてのモジュールで共通に使うライブラリモジュールです。
ここで注目する点として、ドメイン単位で分割したモジュールどうし依存関係を持たないことがあげられます。つまり、:feature_a
モジュールの Controller
では :feature_b
モジュールの Controller
を直接呼び出すことができません。
package com.example.feature.a class FeatureAController : Controller() { fun openFeatureBController() { // FeatureBController は別のモジュールにある Controller で依存関係を持たないため直接呼び出せない router.pushController(RouterTransaction.with(FeatureBController())) } }
このような状況のなかで :feature_a
モジュールの Controller
から :feature_b
モジュールの Controller
へ遷移するには、すべてのモジュールのことを知っている :app
モジュールにその実装を委ねなければなりません。
画面遷移の実装を :app
モジュールに委ねる
ドメインごとのモジュールでは遷移先の Controller
が直接見えないため、画面遷移の API を抽象化したインタフェースを作り、その実装を :app
モジュールから注入するように作ります。ここでは、feature_a
モジュールの Controller
から feature_b
モジュールの Controller
へ遷移する実装例を解説します。
:feature_a
で画面遷移のためのインタフェースを定義する
呼び出し元である :feature_a
モジュールの Controller
では、それに対応する画面遷移のインタフェースを用意します。
interface FeatureANavigation { fun navigateToFeatureB(router: Router) }
そして Controller
では前述のインタフェースに定義したメソッドを呼び出すだけにします。
class FeatureAController : Controller() { private val navigation: FeatureANavigation = // ... fun openFeatureBController() { navigation.navigateToFeatureB(router) } }
FeatureANavigation
の実装をどのように注入するかは後ほど解説します。
:app
モジュールで画面遷移の実装を作る
すべてのモジュールを参照している :app
モジュールでは、先ほど定義した FeatureANavigation
の実装クラスを作ります。
class FeatureANavigator : FeatureANavigation { override fun navigateToFeatureB(router: Router) { router.pushController(RouterTransaction.with(FeatureBController())) } }
:app
モジュールから画面遷移の実装を注入する
さて、この FeatureANavigator
はどうやって FeatureAController
に注入したらよいでしょうか。
Dagger のような DI フレームワークをつかうのであれば、FeatureAController
用のモジュールを用意すれば簡単に FeatureANavigator
を注入できますが、
メルペイ Android では DI フレームワークを使用していないため、FeatureANavigator
のインスタンスをどこかに保持しておき、適宜 FeatureAController
から呼び出せる仕組みが必要です。
そこで :common_navigation
モジュールに NavigationRegistry
というオブジェクトを定義しインスタンスの管理を任せることにします。
次の例は NavigationRegistry
の実装で、新たに Navigation
というインタフェースを定義して、NavigationRegistry
に登録可能なオブジェクトを制約しています。
interface Navigation object NavigationRegistry { lateinit var navigationSet: Set<Navigation> private set @Throws(IllegalStateException::class) operator fun invoke(vararg navs: Navigation) { if (NavigationRegistry::navigationSet.isInitialized) { error("${NavigationRegistry.javaClass.simpleName} is already initialized") } navigationSet = navs.toSet() } inline fun <reified T : Navigation> of(): T = navigationSet.filterIsInstance<T>().first() }
この NavigationRegistry
にオブジェクトを登録するため、FeatureANavigation
インタフェースは Navigation
を継承するように変更します。
interface FeatureANavigation : Navigation { fun navigateToFeatureB(router: Router) }
このシングルトンオブジェクトをつかって :feature_a
モジュールで FeatureANavigator
のインスタンスを得るには次のように実装します。
class FeatureAController : Controller() { private val navigation: FeatureANavigation = NavigationRegistry.of<FeatureANavigation>() fun openFeatureBController() { navigation.navigateToFeatureB(router) } }
一方 :app
モジュールでは、FeatureANavigator
のインスタンスを NavigationRegistry
に登録する作業をします。
class App : Application() { override fun onCreate() { super.onCreate() NavigationRegistry( FeatureANavigation(), // ...... ) } }
これで、:app
モジュールから FeatureAController
に対して画面遷移の実装を注入できます。
おわりに
この記事で解説した画面遷移の実装は非常にシンプルで、ほぼ Controller
の実装から画面遷移のロジックのみを委譲しただけのように作っています。もし仮に画面遷移のロジックに何らかの条件分岐が現れても、入力がシンプルなためユニットテストも容易です。
一方で、:app
モジュールでの実装が多くなることは否めません。特に、FeatureANavigator
のインスタンスを NavigationRegistry
に登録する作業は画面が増えればそれだけ作業も増えます。あるいは、インスタンスを登録し忘れて期待通り動かないことも考えられます。
現在この煩雑さを解消するための仕組みを組み立てているところですので、また後ほど解説記事を書いてみようと思います。
明日は @vvakame さんの社内ツールについての記事となります。お楽しみに!
*1:この仕組みができる以前から、ビルドの効率化や関心の分離などの目的でマルチモジュールな構成にするプラクティスはありました。