Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

マルチモジュールなプロジェクトにおける画面遷移の実装

Merpay Advent Calendar 2019 の4日目担当は メルペイ Android チームの @KeithYokoma です。

Android アプリ開発ではこれまで、画面を構築するためのフレームワークとして ActivityFragment があり、画面遷移もそれぞれに異なる API を使って実装していました。 近年では Navigation Architecture Component が登場し、これまで自分の手で記述していた ActivityFragment 間の画面遷移が 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 では、次の図に示すようなマルチモジュール構成を採用しています。

f:id:KeithYokoma:20191129191451p:plain
各モジュールの構成

  • :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:この仕組みができる以前から、ビルドの効率化や関心の分離などの目的でマルチモジュールな構成にするプラクティスはありました。