見出し画像

Android版『NAVITIME』 リアーキテクチャのすべて

こんにちは、うつぼです。ナビタイムジャパンで『NAVITIME』アプリのAndroid開発を担当しています。

2年前より『NAVITIME』Androidアプリのリアーキテクチャを推進していました。私はリードエンジニアとして、開発や設計、レビュー、そして開発チームの最適化などを行ってきました。
この度それが完了したため、導入したアーキテクチャや開発の工夫を振り返ろうと思います。

※リアーキテクチャは、リニューアルの一環として行われ、アプリとしてはUIも新しく刷新しています。リニューアルしたアプリは現在、GooglePlayにて段階的に公開しています。

なぜリアーキテクチャするのか、リアーキテクチャ開始時の構想などについては下記をご覧ください。

アプリの構成

リアーキテクチャを振り返る上で必要な、アプリの構成です。

  • MVVM + レイヤードアーキテクチャ

  • Databinding

  • 画面はFragment + Jetpack Navigation

画面はJetpack Composeじゃないの!?と思われるかもしれませんが、プロジェクト立ち上げ当時はまだJetpack Composeはdevelopチャンネルで、破壊的変更や不具合があり、機能的にも表現が難しいUIもあったため採用を見送りました。

アーキテクチャとマルチモジュールと工夫

モジュールは、機能ごとの分割とレイヤードアーキテクチャのレイヤーでの分割をしています。(概ね当初の構想のまま進めることができました。)

推奨されるアーキテクチャやモジュール化の利点については、公式ドキュメントが発表されたため、気になる方は参照ください。
アーキテクチャガイドは2021年12月、モジュール化ガイドは2022年8月と、リアーキテクチャ中に発表されたため、『NAVITIME』アプリの構成とは異なる点もあります。

ここでは、リアーキテクチャ後の各モジュールについて説明します。
下記がモジュール構成図で、モジュールと矢印の依存線を表しています。

モジュール構成図(一部)

各モジュールについて、公式ドキュメントに記載があるモジュール(レイヤー)はその情報にも触れつつ、『NAVITIME』アプリでの構成を説明します。

Appモジュール

ApplicationクラスやLauncherActivity、起動時の初期化に関わるクラス、メインのAndroidManifest.xmlなどが配置されます。
見やすさの観点から依存線は表示していませんが、DIの関係上appモジュールは全モジュールを参照しています。

ビルドの観点から見ると、全モジュールを見ている=ビルドの際に他のどのモジュールに変更が加わってもビルドされるので、コード量は小さい方が良いと言われています。

Featureモジュール

機能ごとにモジュールを分割します。狙いとしては、コード管理の面と、ビルド速度向上があります。

コード管理面

具体的なメリットとしては、

  • 機能に関わるロジックが分離できること

  • その機能でしか使わないリソースを閉じ込められる

などがあります。

特にリソースに関しては、strings.xmlやdimens.xmlなどをfeature1_strings.xmlといったファイル名にすることで、検索性を上げてみました。

また、複数モジュールで同じリソースidが定義されてしまうと競合してしまうため、それを避けるためにresourcePrefixを定義しています。
これを定義するとfeature1に定義するstringやdrawableリソース名にfeature1_という接頭辞をつけないとエラーに倒すことができます。

// build.gradle
android {
    ...
    resourcePrefix = "feature1_"
}

// strings.xml
<resource>
    <string name="feature1_button_text">機能1ボタン</string>
</resource>

ビルド速度向上

公式ドキュメントにもありますが、増分ビルドの場合に速度が速くなることがあります。
Feature1,2があった場合に、1にしか手を加えなければ、増分ビルド時に1だけがビルドされるようになります。

ビルド速度だけ見ると、細かく分けたほうがメリットがありますが、コード管理で見るとモジュールが大量になって構成が見づらくなるため、一概に細かくが良いとは言えません。

『NAVITIME』アプリでは、「ルート検索」「地図」「時刻表/交通」など主に下タブの構成に合わせてFeatureモジュールを作成してみましたが、整理しやすいと感じています。

リアーキテクチャ後の『NAVITIME』アプリ

UiCommonモジュール

全てのFeatureモジュールから依存されるモジュールです。例えば以下のようなものが入ります。

  • Fragment共通の拡張関数

  • Fragmentに渡すSafeArgsのクラス

  • 結果を返すFragmentが使うクラス

  • 共通で利用するBindingAdapter

  • 機能共通で利用するstrings/dimensなどの定義

デザインシステムとしてAtomicDesignを導入し、原子・分子・有機体のクラスも入っています。
AtomicDesignについては、アプリのレイアウトファイルやクラスにまで適用することで、容易に使いまわしができ、実装効率向上・見た目の統一という面で非常に効果があったと思います。(詳しくは長くなるので別の機会に…)

前述したresourcePrefixについてですが、UiCommonは除外しています。
リソースファイルに関しては、UiCommonには一般的なものやモジュール共通の定義が主なため、prefixが付かない自然な名前になるようにしました。

UseCaseモジュール

ビジネスロジックを記載するUseCaseクラスが入るモジュールです。

公式ドキュメントでは「ドメインレイヤ」に当たり、以下のように説明があります。

複雑なビジネス ロジック、または複数の ViewModel で再利用される単純なビジネス ロジックをカプセル化します。すべてのアプリにこのような要件があるわけではないため、このレイヤはオプションです。

アーキテクチャガイド

『NAVITIME』アプリでは、後述するRepositoryモジュールと合わせて、UI・Androidパッケージのクラスに依存しないモジュールを目指し、単体テストを書きやすくしています。

UseCaseクラスには、複数のrepositoryを使ってデータを生成したり、ApiのレスポンスをUI層で使いやすいように加工したりする役割があります。

/**
 * 時刻表ウィジェット用のUseCase
 * 時刻表ウィジェットを扱うリポジトリと、ウィジェット全体のエラーを扱うリポジトリ2つのを持って必要に応じて利用する
 */
class TimetableWidgetUseCase @Inject constructor(
    private val timetableRepository: TimetableWidgetRepository,
    private val appWidgetRepository: AppWidgetRepository,
) {
    
  /**
   * 手動更新の際に、成功したら結果を返す、失敗したらエラー情報を更新する
   * ※戻り値のCallResultは、Successだと取得したいデータ、Errorだとexceptionが入るsealed interfaceです
   */
  suspend fun forceUpdateTimetable(setting: TimetableWidgetSetting): CallResult<Unit> {
        return when (val result = timetableRepository.fetchTimetable(setting.toSearchParameter())) {
            is CallResult.Success -> {
                clearAndInsertTimetable(setting.widgetId, result.data)
                timetableRepository.updateNextFetchTime(setting.widgetId, tomorrowNow())
                appWidgetRepository.saveConnectionFailureInfo(ConnectionFailureInfo(0, ZonedDateTime.now()))
                CallResult.Success(Unit)
            }
            is CallResult.Error -> {
                incrementFailureCount()
                result
            }
        }
    }
/**
 * 住所検索のUseCase
 */
class AddressSearchUseCase @Inject constructor(
    private val repository: AddressSearchRepository,
    @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) {
    /**
     * 住所コードや国コードなどを使って該当する住所をリストで取得する
     * その後、市区町村などの五十音の頭文字でグルーピングする
          * 数が多い場合があるのでDispatchers.Defaultを使用
     */
    suspend fun fetchAddressList(
        addressCode: String, countryCode: CountryCode?, divisionCode: String?
    ): CallResult<Map<String, List<AddressListResponse>>> = withContext(defaultDispatcher) {
        repository.fetchAddressList(addressCode, countryCode, divisionCode).map { list ->
            list.groupBy { it.indexName }
        }
    }
}

コストがかかる処理を行う場合には、CoroutinesのDispatchers.Defaultを指定しUIスレッドに負担がかからないようにしています。
MVVMにおいて、ViewModelは肥大化しやすいため、なるべく負担を減らすという意味でも作った意味はあると思っています。

Repositoryモジュール

公式ドキュメントでは「データレイヤ」に当たり、以下のように説明があります。

アプリのデータレイヤには、ビジネス ロジックが含まれています。ビジネス ロジックはアプリに価値をもたらすものであり、アプリがデータを作成、保存、変更する方法を決定するルールで構成されています。

アーキテクチャガイド

『NAVITIME』アプリでは、目的のために複数のDataSourceを持ち、Remote/Local/Cacheなどを切り替えて、必要なデータの受け渡しを行うクラスが入っています。

internal class RouteSearchRepositoryImpl @Inject constructor(
    private val routeRemoteDataSource: RouteSearchDataSource,
    private val routeMemoryCacheDataSource: RouteMemoryCacheDataSource,
    private val routeDbCacheDataSource: RouteDbCacheDataSource,
...

Repositoryで利用するDataSourceクラスに関しては、インターフェースをRepositoryに定義・利用し、後述のInfraモジュールはそれを実装するようにしています。

interface RouteSearchDataSource {
    suspend fun fetchRoute(...): Result...
...

DataSourceのメソッドを利用する際のCoroutineDispatcherの指定もここで行うルールにしており、基本的にはDispatchers.IOを指定します。
当初、同じスレッドへの切り替えを行う場合にコストが発生するだろうと思い、DataSourceではなくRepositoryでスレッド指定をすることにしました。
しかし同じ場合コストは発生しないようなので、DataSourceで指定した方が、その処理をどのスレッドで扱うか末端で定義できるため良いのかもしれません。

override suspend fun startRouteSearch() {
    withContext(ioDispatcher) {
        // 通信やDB処理
...

Infraモジュール

Api・Database・SharedPreferencesなど、データソース自体を定義するモジュールです。

各データソース クラスは、ファイル、ネットワーク ソース、ローカル データベースなど、1 つのデータソースのみを処理する役割を担う必要があります。データソース クラスは、データ オペレーションのためにアプリとシステムの橋渡しをします。

アーキテクチャガイド

UseCase/RepositoryはAndroidパッケージに依存しないと言いましたが、Infraは依存することになります。

モジュール内のクラスには大きく2種類あります。

  • Repositoryモジュールに定義されているDataSourceのinterfaceの具象クラス

  • データソース自体のクラス(Api・Database・SharedPreferencesなど)

工夫

ApiやDatabaseへのリクエストにはクエリを利用することが多く、プリミティブなString/Intなどが多く存在します。
DataSourceまではアプリで利用するオブジェクトで持ってきて、リクエストする際にString/Intなどに変換する責任をここで負うことで、Repository以上のレイヤではオブジェクトという意味のあるまとまりでやり取りすることができるようにしています。
また、リクエストパラメータへの変換は、渡すオブジェクト自体に関数を作るわけではなく、DataSourceクラスだけに定義することで、リクエストするときに必要なロジックが他モジュールから見えないようにしています。

class RouteRemoteDataSource @Inject constructor(private val api: RouteApi) : RouteSearchDataSource {
    override suspend fun fetchRoute(parameter: RouteSearchParameter): Result... {
        // InfraモジュールまではRouteSearchParameterというオブジェクトで扱い、実際にAPIに送る文字列はここで生成
        // そのロジックもここに隠蔽する
        return api.fetch(parameter.time.toRequestParameter(), ...)
    }

    private fun LocalDateTime.toRequestParameter(): String {
        return format(DateTimePattern.yyyyMMdd_HHmmss)
    }
}

例えば日付の形式が変わったときに、修正はこのクラスだけになります。
API仕様を知っているこのクラスが変わることは、APIを知らない別の層を修正するより自然だと考えます。

DomainModelモジュール

アプリで扱うオブジェクトクラスを定義する場所です。表示用のオブジェクトなど、UIのみで扱うものはUiCommonモジュールに置くため、それ以外を置くことになります。
逆に言うと、Ui層でしか使わないものを定義しないように注意しなければなりません。

名の通り、アプリで扱うドメイン知識をクラスで定義したものを置くモジュールです。
これはcommonモジュール以外の全てのモジュールから参照されます。

工夫

enumクラスもこのモジュールに定義することが多いですが、その際に気を付けていることがあります。

例えば運賃にきっぷ料金を使うかIC料金を使うかのenumクラスを見てみます。

enum class FareDisplayType {
    TICKET,
    IC
}

これは、ユーザーがどちらを選んだかをアプリ(SharedPreferences)に保存しています。そのため、その保存の値を紐付ける必要があります。
また、UI上では「きっぷ」「IC」と表示するため、その文字列リソースとも紐付ける必要があります。

単純に考えると下記のようなenumになります。

enum class FareDisplayType(@StringRes val stringRes: Int, val preferenceValue: String) {
    TICKET(R.string.ticket, "ticket"),
    IC(R.string.ic, "ic")
}

が、stringResはUIのみに関わるもので、prefereneceKeyはInfraのみに関わるものなので、ほぼ全てのモジュールから参照されるDomainModelにクラスに定義してしまうのは範囲が広すぎます。

そのため、必要最低限のモジュールで拡張関数を作ることで、必要なモジュールからのみ参照できるようにしています。

// UiCommonモジュールのFareDisplayTypeExt.kt
val FareDisplayType.stringRes: Int
    @StringRes
    get() = when(this) {
        FareDisplayType.TICKET -> R.string.ticket
        FareDisplayType.IC -> R.string.ic
    }


// Infraモジュールの検索条件を保存するPreferencesクラス内
private val FareDisplayType.prefenceKey: String
    get() = when(this) {
        FareDisplayType.TICKET -> "ticket"
        FareDisplayType.IC -> "ic"
    }

Commonモジュール

全てのモジュールから参照されるモジュールです。
ListやCoroutinesに生やす拡張関数など、あらゆるモジュールで使うものを定義するところです。
ビルド面で見ると、ここに変更が入ると全てのモジュールがビルドされることもあり、特定の機能でしか使わないものなどは入れないようにしています。

DevToolモジュール

検証の際に、サーバーの向き先を変更したい・この値を変えたい、などの要望が出てきます。
変更した方が効率的に検証を進めることができますが、万が一それが本番公開するアプリに入ってしまうと、大変なことになってしまいます。

検証・本番を同じコードで中で分岐させることが簡単な方法ですが、オペレーションミスが起こりえます。
そこで、モジュールとして切り出して検証ビルドの際しか検証機能を参照しない(=本番ビルドにはコードが入らない)ようにすることで安全性を高めました。

// appのbuild.gradle.kts
// debugとbetaというバリアントだけで依存するようにし、releaseビルドにはコード自体が入らないようにしている
debugImplementationModules(
    Modules.DEV_TOOL
)
betaImplementationModules(
    Modules.DEV_TOOL
)
// ModulesやdebugImplementationModules関数に関しては後述

レイヤと機能の分割

『NAVITIME』アプリでは、依存関係の複雑さを避けるため、レイヤのモジュールは機能では分割しない方針にしていました。UseCase/Repository/Infraモジュールは一つで、機能ごとには分かれていません。

ただ、ビルド速度の観点からするとこちらもFeatureの単位ぐらいで分けても良かったかもと振り返っています。

導入ライブラリと工夫

導入したライブラリや機能について、工夫した点についてです。

Kotlin Coroutines

非同期処理において、以前のアプリはAsyncTaskやRxJavaが入ったものでしたが、Coroutinesに統一しました。
理由としては、非同期を同期的にかけるsuspend関数の可読性のメリットと、FlowというKotlin純正のStreamライブラリが利用できるからです。

『NAVITIME』アプリでは、

  • 単一の非同期処理はsuspend関数

  • イベント送信(ViewModelからFragmentへのクリックイベントなど)はSharedFlow

  • 変化の購読(Fragmentで通信結果を受け取る場合など)はStateFlow

というルールで実装を行いました。

結果、狙い通り、

  • suspend関数による複数の通信やデータのロードを順番に行う際の可読性向上

  • Flowの数々のoperatorを利用した変換や、ObserverパターンによってFragment(UI)が受動的に変化する実装

を実現することができました。

Jetpack Navigation

Navigationも導入して良かったライブラリです。
画面遷移の一元管理・遷移引数の制限指定といった面で、強力にコードの安全性を高めてくれたと感じています。

しかし、いくつか扱いが難しい点もあったため、2つの工夫を行いました。

1. InputArg

Navigationのgraph(xml)では、safeArgsを用いて型安全に遷移先にデータを渡すことが可能です。

ただ、

  • Listは直接渡せずArrayにする必要がある

  • カスタムクラスを指定する場合に補完が効かない

といった問題がありました。
そこで、「画面遷移時に渡すデータはxxInputArgというクラスを作成する」というルールにしました。

@Parcelize
@Keep
data class SelectedRouteInputArg(
    val selectedIndexList: List<Int>,
    val routeSearchParameter: RouteSearchParameter,
    ...
) : Parcelable

これにより、最初こそカスタムクラスを定義する必要がありますが、引数が増えてもxmlの修正は必要なく、List型を含めどんな型でもそのまま入れて画面遷移時に渡すことができます。

画面遷移時の入力情報がxmlではなくクラスとして定義できるという、可読性の面でもメリットがあったと感じています。参照性の面でも、それぞれの引数がどこで使われているかはxmlでは分かりませんがクラスなら簡単に調べることができます。

また、Fragmentに渡した画面遷移時のデータをViewModelでも使いたいということは多く、コンストラクタで渡す際にはAssistedInjectを行うのですが、そこでもこのInputArgクラス一つを渡すだけで良いので、保守性の高いコードが書けるようになったと思っています。

class SelectedRouteViewModel @AssistedInject constructor(
    @Assisted private val input: SelectedRouteInputArg,
    ...
) : ViewModel() {
    init {
        input.selectedIndexList....
    }
}

AssistedInjectについてはこちらの記事が参考になります。
(※記事に載せるために改めて見たのですが、AssistedInjectよりSavedStateHandleを使ったほうが良いようです。勉強せねば!)

2. Navigatable

Navigationで画面遷移する際、idを指定する場合は遷移先に渡す引数があっているかを気にしながら実装する必要があります。(xmlを確認したり)
これは、実行時エラーを招く可能性を秘めていて、アプリの安全性が高いとは言えません。

// ライブラリのnavigateメソッド
public open fun navigate(@IdRes resId: Int, args: Bundle?) {
    navigate(resId, args, null)
}
// Fragment。第2引数はBundleなので、クラスが誤っていてもコンパイルエラーにはならない
navigate(R.id.route_list_fragment, RouteListInputArg())

そこで注目なのがDirectionsクラスです。

Navigationのxmlのactionに定義した情報がxxDirectionsとして自動生成され、それを使うことで型安全に画面遷移を行うことができます。

// ライブラリのnavigateメソッド
public open fun navigate(directions: NavDirections) {
    navigate(directions.actionId, directions.arguments, null)
}

RouteTopFragmentから、RouteListFragmentへ遷移する例です。

// navigation_graph.xml
<fragment android:id="+id/route_top_fragment" ...>
    <action android:id="+id/to_routeList"
            app:destination="@id/route_list_fragment" ...>
</fragment>

<fragment android:id="+id/route_list_fragment" ...>
    <argument
        android:name="input"
        app:argType="RouteListInputArg"/>
</fragment>
// RouteTopFragmentからの移動
val action = RouteTopFragmentDirections.toRouteList(RouteListInputArg(...))
findNavController().navigate(action)

Directionsを使うと、型安全に画面遷移が行えます。

しかし、このままでは画面遷移する箇所にxxDirectionsを直書きしなければならないため、間違ったDirectionsを書いたり、遷移元Fragmentにない遷移を実行してしまうことが発生します。

そこで、Navigatableというinterfaceを作成し、画面遷移時には遷移元のFragmentから遷移可能なDirectionだけにしか遷移できないようにしました。

// Navigatableの主な実装
interface Navigatable<T> {
    val navDirections: T

    fun Fragment.navigate(navOptions: NavOptions? = null, action: T.() -> NavDirections) {
        try {
            findNavController().navigate(directions, navOptions)
        } catch (ignore: IllegalArgumentException) {
        }
    }
}
class RouteTopFragment : Fragment(R.layout.fragment_route_top), Navigatable<RouteTopFragmentDirections.Companion> {
    override val navDirections = RouteTopFragmentDirections
...

// 画面遷移処理
private fun showRouteList(...){
    val input = RouteListInputArg(...)
    navigate{
        toRouteList(input)
    }
}

これにより、定義した画面のみに遷移できるだけでなく、実装時も画面遷移候補が補完として出てくるようになるメリットもあります。

デメリットとしては、リストなどで複数項目を同時押しした場合の考慮をしなければならないところですが、Navigatable側で吸収できるのでそこまで困りませんでした。
同時押しの場合、1つ目の遷移が実行された後は、画面が変わっているので、その画面に定義がないDirectionsのactionで移動しようとすると強制終了してしまいます。見た目としては、2つ目は遷移しなくて良いため、今回はtry/catchで該当のExceptionをcatchして対応しています。

buildSrcとbuild.gradle.kts

Gradleの機能で、ここに記載したプラグインや定義を他モジュールで参照することができます。
プロジェクトルートにbuildSrcというフォルダを作り、必要なファイルを追加するだけで導入できます。

また、今回リアーキテクトするにあたり、groovyのbuild.gradleからkotlinで書けるbuild.gradle.ktsに移行しました。
下記のようなメリットがあります。

  • build.gradleをKotlinで書ける

  • エラーを表示してくれる

  • AndroidStudioで補完が効き、コードから定義元や呼び出し元の参照ができる

『NAVITIME』アプリで導入したbuildSrcを紹介します。
(※試行錯誤しながら行ったため、もっと良いやり方があるかもしれません。もしお気づきの点があれば教えていただけると嬉しいです!)

Versions.kt

アプリで使うsdkバージョンやバージョン名、利用するライブラリのバージョンを定義しています。

object Versions {

    object App {
        const val compileSdkVersion = 31
        const val targetSdkVersion = 31
        const val versionName = "11.0.0"
        ...
    }

    internal object AndroidX {
        const val activityKtx = "1.5.0"
        const val constraintLayout = "2.1.1"
        ...
    }
...

Libraries.kt

利用するライブラリの定義です。バージョンはVersions.ktを参照しているため、バージョン更新はVersions.ktを更新するだけで済みます。

object Libraries {
    object AndroidX {
        const val appCompat = "androidx.appcompat:appcompat:${Versions.AndroidX.appCompat}"
        const val activityKtx = "androidx.activity:activity-ktx:${Versions.AndroidX.activityKtx}"
        const val fragmentKtx = "androidx.fragment:fragment-ktx:${Versions.AndroidX.fragmentKtx}"
        const val navigation = "androidx.navigation:navigation-fragment-ktx:${Versions.AndroidX.navigation}"
        const val navigationUi = "androidx.navigation:navigation-ui-ktx:${Versions.AndroidX.navigation}"
...

Modules.kt

モジュールをenumとして定義しています。

enum class Modules(val value: String, val resourcePrefix: String? = null) {
    APP("app", "app_"),
    USE_CASE("useCase"),
    INFRA("infra"),
    ROUTE("route", "route_"),
...

こうすることで、resourcePrefixをrootのbuild.gradleでprojectのnameからenumを引くことで、一つの共通処理として定義することができました。

// rootのbuild.gradle.kts
subprojects {
    afterEvaluate {
        extensions.getByName("android").castApply<BaseExtension> {
            val currentModule = requireNotNull(deps.Modules.values().find { it.value == project.name }) {
                "could not find the specified module name ${project.name} at root/build.gradle.kts"
            }
            val resourcePrefix = currentModule.resourcePrefix
            if (!resourcePrefix.isNullOrEmpty()) {
                resourcePrefix(resourcePrefix)
            }

DependencyHandlerExt.kt

以前より、ライブラリやモジュールをimplementationする際にもう少しきれいに書けないものかと思うことがありました。
ktsにしたことで、拡張関数を書けるようになったので、build.gradleの記載を改善してみました。

fun DependencyHandler.implementations(vararg dependencyNotation: Any) {
    dependencyNotation.forEach {
        implementation(it)
    }
}

// org.gradle.kotlin.dslにある、implementation関係の拡張関数がbuildSrcでは読み込めないので、ライブラリ実装をコピー
fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? =
    add("implementation", dependencyNotation)

Libraries.ktがあるので、利用側ではこのように書けます。

// 利用側(build.gradle.kts)
dependencies {
    implementations(
        Libraries.AndroidX.appCompat,
        Libraries.AndroidX.coreKtx,
        Libraries.AndroidX.activityKtx,
        Libraries.AndroidX.fragmentKtx,
        Libraries.AndroidX.liveDataKtx,
        Libraries.AndroidX.lifecycleJava8,
        Libraries.AndroidX.lifecycleService,
        Libraries.AndroidX.recyclerView,
        Libraries.AndroidX.constraintLayout,
        Libraries.AndroidX.navigation,
        ....
    )
}

Moduleもenum定義しているので、拡張を書くと便利になりました。

fun DependencyHandlerScope.implementationModules(vararg modules: Modules) {
    modules.forEach {
        implementation(project(":${it.value}"))
    }
}

// buildSrcでprojectを読み込むためにライブラリ実装をコピー
fun DependencyHandler.project(path: String, configuration: String? = null): ProjectDependency =
    project(
        if (configuration != null) mapOf("path" to path, "configuration" to configuration)
        else mapOf("path" to path)
    ) as ProjectDependency
// 利用側(build.gradle.kts)
dependencies {
    implementationModules(
        Modules.USE_CASE,
        Modules.INFRA,
        Modules.ROUTE,
        ...
}

新しいライブラリ・モジュールの追記は度々発生するので、Kotlinで書けることはとても便利だと感じています。

features.gradle.kts

機能モジュールで使うライブラリには、共通で使うものが多々あります。
buildSrcにfeatures.gradle.ktsという共通定義を作ることで、各機能モジュールのbuild.gradleではそれをpluginとして参照することができました。

// buildSrcのfeatures.gradle.kts
import deps.Libraries
import deps.Modules
...

apply(plugin = "com.android.library")
apply(plugin = "kotlin-android")
...

dependencies {
    implementationModules(
        Modules.COMMON,
        Modules.USE_CASE,
        ...
    )

    implementations(
        Libraries.AndroidX.appCompat,
        Libraries.AndroidX.coreKtx,
        Libraries.AndroidX.liveDataKtx,
        ...
     )
}
// featureモジュール(routeのbuild.gradle.kts)

plugins {
    // これだけで上記設定(features.gradle.kts)が読み込める
    id("features")
}

モジュールが多くなると、それぞれに同じことが記載されていると修正が大変なので、共通化は効果があったと感じています。

value class

Kotlin1.5からStableになった、「プリミティブ型を用途に合わせたクラスにできる」という機能です。

具体的には、「このIntは "時間" なのか "距離" なのか」「 "分" なのか "秒" なのか」といったことを表現することができます。また、StringなどのID(一意識別子なども、何のIDなのかという意味をもたせることができます。

分を表すMinutesクラス

@JvmInline
@Parcelize
value class Minutes(val value: Int) : Parcelable, Comparable<Minutes> {

    override fun compareTo(other: Minutes): Int {
        return this.value - other.value
    }
}

compareToを実装することで、Minutes型のままifで比較することができます。

val a = Minutes(0)
val b = Minutes(1)
if (a < b) { // 通常はa.value < b.value、とフィールドを記載する
    ...
}

ブックマークのID

@JvmInline
@Parcelize
value class RouteBookmarkId(val value: Int) : Parcelable

料金

@Parcelize
@JvmInline
value class Fare(val value: Float) : Parcelable {
    operator fun plus(other: Fare): Fare = Fare(value + other.value)
}

plus operatorを実装することで、そのまま + で足し算をすることができます。

value classが便利なのが引数に取る場合で、プリミティブにしてしまうと、たとえ引数名で説明していても、異なる用途の値を入れることができますが、value classにすると型が異なるためコンパイルエラーにすることができます。

また、UI層で表示用のロジックが必要なときに、UI層でvalue classに対して拡張関数を定義することで、プリミティブクラスに大量に拡張関数が生えるといったアンチパターンを避け、必要最低限の公開範囲のメソッドを作成することもできます。

val Fare.displayValue: String
    get() = DecimalFormat("#,###.##").format(value)
// 「1,200」という形でカンマで区切った文字列を出力する

data classでも実現できますが、value classコンパイル時にはプリミティブ型になるので、パフォーマンス面でメリットがあります。

コードの治安を良くできる機能だと感じていて、現在は一部しか導入できていませんが、増やしていくことでより安全なコードになると感じています。

詳しくはこちらで説明されています。

開発運用での工夫

detekt(静的解析)の導入と効果

今回、コード品質を効率的に保つため、ユニットテストやAndroidLintの他にdetektを導入していました。導入効果は強く実感できました。

導入の詳しい話は下記より。

開発期間が長かったため、それだけ人の出入りもあり、技術レベルやルールの定着に差がある場合もありました。
ですが、静的解析に設定したルールは自動的に解析・指摘してくれることで、ルールを守っていくことに対するコストがとても少なくなりました。

また、複雑度が多いなどの静的解析結果から、メソッドの分割単位や設計について議論するきっかけができたこともあり、導入してよかったと感じています。

リアーキテクチャ前後のコードメトリクス

リアーキテクチャ前のコードはJavaとKotlinが混在しているため、lizardというライブラリを使ってリアーキテクチャ前後のコードメトリクスを計測してみました。

各項目で、リアーキテクチャ後の方が大幅に改善していることが分かります。detektの導入の他にも、リアーキテクチャ自体や、アプリ機能の変化といった複数の要因があると思いますが、定量的にも改善が確認することができました。

不具合解析の工夫

Crashlyticsは導入してないアプリがないほどに有名なライブラリですが、そのログ機能を使って不具合の特定をしやすくしています。

具体的には、画面(Fragment)が開いたときにその画面名を送るようにしてます。

// MainActivity,kt
navHostFragment.childFragmentManager
    .registerFragmentLifecycleCallbacks(object : FragmentManager.FragmentLifecycleCallbacks() {
        override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) {
            FirebaseCrashlytics.getInstance().log(f::class.qualifiedName.orEmpty())
            ...

こうすることで、クラッシュが発生したユーザーがどのような画面遷移をしたのかが分かり、スタックトレースだけでは分かりづらい不具合の調査がしやすくなります。

Crashlyticsの画面

今後の展望

モジュールやレイヤの分割といった、リアーキテクチャの根幹部分は整ったと感じています。(もちろんまだ改善点はありますが)
今後は、新たな技術導入や運用改善の観点で以下の3つを行いたいと考えています。

1つ目は、Jetpack Composeが入れられなかったことが心残りなので、今後導入してきたいと考えています。
幸いリアーキテクチャでロジック/UIを分離したことで、導入しやすい構成になっていると思います。
個人的にはJetpack ComposeはJava→Kotlinのようなデファクトスタンダードの変化がありそうと考えているので、力を入れていきたいです。

2つ目は、せっかくinterfaceを用いてテスタブルな設計にしているのに、肝心のユニットテストがあまり書けていないので、テストカバレッジも上げていきたいところです。

3つ目は、プロジェクトのルールに対応したLintを作成することで、安全なコードを自動的に解析していける仕組みも出来たら良いなと感じています。
カスタムLintについては知識が乏しいので、まずはその勉強からといったところです。

最後に

2年以上かかったリアーキテクチャですが、無事リリースすることが出来て良かったです。
個人的にも技術力の向上を感じていて、開発速度向上やより安全なコードを書けるようになったことに加え、アプリ開発をより高い視点から見ることができるようになったと感じています。

アプリについては、Javaコードで書かれていた時代からは考えられないほどコードが整理され、開発速度も上がりました。それによって、ユーザーにより早く新しい価値を届けられることを期待しています。