Android版CODEアプリのアーキテクチャと使用ライブラリ

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。弊社には3人目のAndroidエンジニアを採用する予定があり現在準備中です。この記事では応募者が弊社で働くイメージを持ちやすくするために、Android版CODEアプリのアーキテクチャと使用ライブラリを広く簡潔に紹介します。

アーキテクチャ

アーキテクチャは5層構造のレイヤードアーキテクチャになっています。Android公式のアプリの推奨アーキテクチャのViewModelとRepositoryの間にUseCaseを加えた構造を採用しています。UseCaseを加えた理由はViewModelの単体テストの肥大化を防ぐためです。

アーキテクチャ

非同期処理

Kotlin CoroutinesをFlowを含めて使用してます。

RemoteDataStore

サーバサイドはRuby on Railsで作成されたREST APIになります。そのクライアントをRetrofitで作成しています。

LocalDataStore

Roomを使用して複数Activityにまたがるデータを永続化して、プロセスキルからの復帰に対応しています。フローを使用したリアクティブ クエリを使い、他の画面で行われた更新も抜けなくUIに反映するようにしています。(所謂いいね問題への対応)

Repository

上記DataStoreクラスを使ってデータの出し入れを代表するクラスです。単純なラッパーになっているケースが多いですが、fetch○○というメソッドでは、REST APIからデータ取得したあとRoomへの保存します。

UseCase

ViewModelの1メソッドに対してRepositoryを使う場合は1つのUseCaseクラスを作成しています。

ViewModel

DroidKaigi/conference-app-2021UnidirectionalViewModelを参考にして、stateとeffectをひとつずつ持っています。Viewの状態はstateが持ち、ダイアログ、Toast、画面遷移のような単発イベントがeffectになります。

ViewModelはこちらのインターフェースを実装するようにしています。ISingleLiveEventインターフェースはAndroid Architecture BlueprintsのSingleLiveEventのインターフェースです。

interface CodeBaseViewModel<State, Effect> {
 
    val state: LiveData<State>

    val effect: ISingleLiveEvent<Effect>
}

View

レイアウトはXMLで作っています。Jetpack Composeはまだ導入していません。 そして多くの画面でgroupie(RecyclerView)を使用しています。クエスト一覧画面のような、いかにもリストな画面だけでなく、詳細画面を含めて幅広く使用しています。こちらはクックパッドさんの事例と同じ内容になります。

クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

DI

現在はKoinを使用しています。DI導入時にDagger Hiltは無かったのですが、Koinは設定ミスの検出がビルド時ではなく実行時であるというデメリットを考えると、工数をかけてDagger Hiltに差し替える選択もありだと思います。

マルチモジュール

ビルドの高速化やレイヤードアーキテクチャへの強制力のために、レイヤー別と機能別のマルチモジュールを採用しています。レイヤー別モジュールにはremoteDataStore, localDataStore, repositoryがあります。UseCase以上は機能別モジュールに格納しています。マルチモジュールを導入する前のクラスはlegacyモジュールに格納しています。

規模

関連して規模感も説明します

項目
Activity数 128
アプリコード行数 160,447
テストコード行数 88,472

テスト

弊チームでは自動テストを重視しています。弊社はアジャイルを採用していて、素早く開発して素早く社内テストや一部ユーザを対象としたリリースをしてフィードバック頂いて、素早くアプリを変化させることを良しとしています。しかしデグレの懸念があると、それが障壁になります。

RemoteDataStore

APIクライアントのテストはOkttp3のMockWebServerを使い、インストゥルメント化単体テストを記述しています。

LocalDataStore

Roomのテストもインストゥルメント化単体テストで保存とクエリを確認しています。

Repository、UseCase、ViewModel

Repository、UseCase、ViewModelについてはMockKを使って単体テストを記述しています。coEveryメソッド等でひとつ下のレイヤーの返却値を定義したのち、テスト対象メソッドを呼び出し、coVerifySequence メソッド等でひとつ下のレイヤーに対して呼び出された内容を検証しています。 ViewModelについては、LiveDataの変化も onChanged メソッド呼び出しを確認する形で検証しています。

テストコードの例

ここでは家計簿の収入を登録および編集する画面を例にして、そのViewModelの実装とテストコードを紹介します。

収入を登録および編集する画面

※ 近日中にリニューアル予定の画面です。

まずテスト対象のViewModelです。収入を登録する部分以外は省略しています。

data class IncomeEditState(
    val progress: Progress = Progress.NOTHING,
    val price: Int = 0,
    val selectedIncomeCategoryId: Long = 0,
    val date: LocalDate = LocalDate.of(1900, 1, 1),
    val memo: String = ""
) {
    enum class Progress {
        LOADING, PROCESSING, NOTHING
    }
}

sealed class IncomeEditEffect {
    object Close : IncomeEditEffect()
}

class IncomeEditViewModel(
    private val incomeCreateOrUpdateUseCase: IncomeCreateOrUpdateUseCase
) : ViewModel(),
    CodeBaseViewModel<IncomeEditState, IncomeEditEffect> {

    private val _state = MutableLiveData(IncomeEditState())
    override val state: LiveData<IncomeEditState>
        get() = _state

    private val _effect = SingleLiveEvent<IncomeEditEffect>()
    override val effect: ISingleLiveEvent<IncomeEditEffect>
        get() = _effect

    val error = ApiErrorViewModelHelper()

    /**
     * 登録/更新ボタンが押された時に呼ばれる
     *
     * @param id サーバ側ID。0Lの時は登録。
     * @param price 金額
     * @param incomeCategoryId カテゴリID
     * @param date 日付
     * @param memo メモ
     */
    fun submit(
        id: Long,
        price: Int,
        incomeCategoryId: Long,
        date: LocalDate,
        memo: String
    ) = viewModelScope.launch {
        try {
            // 処理中としてプログレスを表示する
            _state.value = state.value?.copy(progress = IncomeEditState.Progress.PROCESSING)
            // 収入を登録するUseCaseを呼び出す
            // UseCase内部では登録API呼び出しと収入一覧の再フェッチが行われている
            incomeCreateOrUpdateUseCase.createOrUpdate(id, price, incomeCategoryId, date, memo)
            // 画面を閉じる
            _effect.value = IncomeEditEffect.Close
        } catch (e: Throwable) {
            // エラー処理
            error.onError(e)
        } finally {
            // プログレスを元に戻す
            _state.value = state.value?.copy(progress = IncomeEditState.Progress.NOTHING)
        }
    }
}

LiveDataの変化をMockKで確認するための拡張関数です。

fun <T> LiveData<T>.mockObserver(name: String = ""): Observer<T> {
    val mockObserver = mockk<Observer<T>>(relaxed = true, name = name)
    observeForever(mockObserver)
    return mockObserver
}

fun <T> ISingleLiveEvent<T>.mockObserver(name: String = ""): Observer<T> {
    val mockObserver = mockk<Observer<T>>(relaxed = true, name = name)
    observeForever(mockObserver)
    return mockObserver
}

ViewModelのテストはこのようになります。

@ExperimentalCoroutinesApi
@Test
fun submit() = testDispatcher.runBlockingTest {
    val stateMockObserver = viewModel.state.mockObserver()
    val effectMockObserver = viewModel.effect.mockObserver()
    viewModel.submit(
        0L,
        1500,
        2L,
        LocalDate.of(2021, 8, 10),
        "メモ"
    )
    // LiveDataの変化とUseCase呼び出しを確認する
    coVerifySequence {
        stateMockObserver.onChanged(IncomeEditState())
        stateMockObserver.onChanged(
            IncomeEditState(
                progress = IncomeEditState.Progress.PROCESSING
            )
        )
        incomeCreateOrUpdateUseCase.createOrUpdate(
            0L, 1500, 2L, LocalDate.of(2021, 8, 10), "メモ"
        )
        effectMockObserver.onChanged(IncomeEditEffect.Close)
        stateMockObserver.onChanged(
            IncomeEditState(
                progress = IncomeEditState.Progress.NOTHING
            )
        )
    }
}

※ 説明のために簡略化しているので、実際のプロダクトのコードとは異なります。

View層

EspressoAppium等を使ったUIの自動テストはほぼ無く、現在の課題です。

ワークフロー

主にAWS CodeBuildGitHub Actionsを使用しています。

CI

プルリクへのPUSH毎に以下の内容を実行しています。

処理時間が25分かかるので、キャッシュの設定やRobolectricの使用などで短くすることを検討中です。

CD

DeployGateを使った社内テスト版の配布やGoogle Playへのリリースもワークフロー化されています。

分析

Firebase Analyticsで画面遷移やアプリに埋め込んだイベントを送っています。さらにBigQuery Exportを設定して、BigQueryにGoogle DataStudioJupyterからアクセスすることで、柔軟にデータ分析を行っています。データ分析エンジニアが居るのでグロースのためのデータ分析は、ほぼその方が担当しています。Androidチームでは重要機能が適切に動作しているかの確認や、ユーザから報告のあった不具合の調査のための分析を主に行っています。

古いコード

前述したアーキテクチャは開発の新しい画面で採用しているもので、以前開発した画面では違う技術やライブラリが使われています。

Fluxアーキテクチャ

少し前に開発した画面ではDroidKaigi/conference-app-2019を参考にしたFluxアーキテクチャを採用していて、View層とRepository層の間はActionCreatorとStoreになっています。こちらは単体テストが記述されているので、改修するときもそのままFluxアーキテクチャを使い続けようと考えています。

レガシーコード

さらに以前に開発された画面はJavaで書かれていて、API呼び出しもAsyncTask + HttpUrlRequestになっています。単体テストは無いです。このようなコードに対する考え方は、動いているコードは弄らず機能改修するときはActivity単位で全部新しく作っています。弊社PdMは技術的負債の解消について理解がある方で助かっています。

まとめ

安全かつ高速にアプリを進化させるために有用となるガイドライン策定や技術の導入は何でもしようと思います。まだ不完全なところがあるので、一緒に頑張って頂ける方を募集する予定です。