Jetpack Composeを本番投入しました(ViewModel編)

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。去年、Android版CODEアプリのアーキテクチャと使用ライブラリを執筆した時点ではJetpack Composeを導入してなかったのですが、2月からJetpack Composeによる開発を開始して、6月にそれを使った新機能をリリースできました。そして現在、新機能の開発はすべてJetpack Composeで行っています。この記事ではAndroid版CODEアプリ開発チームにおける、Jetpack Compose導入のモチベーションや最初に採用した機能、技術選定の結果などを紹介します。

3部構成

この記事は3部構成を予定しています。

  • ViewModel編(今回)
  • マルチモジュール編
  • 画面遷移編

導入のモチベーション

品質の向上

Jetpack Composeは宣言的UIであり、UIには状態を持たず、ViewModel(この記事ではUIの状態管理担当クラスをViewModelと呼ぶようにします)にUIの状態を持っています。すでにCODEではViewModelに対して単体テストを書いてCIでチェックする体制ができているので、既存のViewを使うよりもデグレードを防げる範囲を広げることができます。カバレッジJaCoCoCodecovで確認しています。

ViewModelの単体テスト
ViewModelの単体テスト

タスクを分割しやすくする

Jetpack ComposeにはAndroid Studioによるプレビュー機能があります。まだ、データレイヤーやドメインレイヤーがまだ作られていなかったり、新規にジョインしたエンジニアにそれらをいきなりお任せするのは難しいときでも、先行してComposable関数を作って頂くことが可能です。プルリク単位では、Android Studioによるプレビューを持って完了とすることが可能です。

Android Studioによるプレビュー機能
Android Studioによるプレビュー機能

最初にJetpack Composeを採用した機能

CODEには家計簿としての機能があります。

カレンダー グラフ
カレンダー グラフ

さらに、繰り返し一括登録という、例えば毎月スマホの通信費に3000円使っているといった定期的な収入・支出を登録する機能がありますが、長くiOS版にありAndroid版に無い状態が続いていました。開発できるタイミングが来て、新規に2画面作成することになったので、このタイミングで導入しました。

繰り返し一括登録の一覧 繰り返し一括登録の編集
繰り返し一括登録の一覧 繰り返し一括登録の編集

参考にしたOSS

ViewModelの書き方はDroidKaigi/conference-app-2021を参考にしています。すべてが一緒というわけではなく、CODEの歴史的経緯や事情に合わせて、変えている部分があります。

ViewModel

ViewModelの共通インターフェース

Jetpack Composeを使う画面で使用するViewModelの共通インターフェースがこちらになります。

interface UnidirectionalViewModel<EVENT, EFFECT, STATE> {
    /**
     * 初期状態を返却する
     */
    fun createDefaultState(): STATE
    /**
     * 画面の状態
     */
    val state: LiveData<STATE>
    /**
     * 画面に対する効果(画面遷移など)
     */
    val effect: Flow<EFFECT>
    /**
     * 画面で発生したライフサイクルイベントやユーザ操作をオブジェクトとして渡す
     *
     * @param event イベントオブジェクト
     */
    fun event(event: EVENT)
}

DroidKaigi/conference-app-2021ではStateFlowを使用していますが、CODEでは、これまで使用していたViewModelの単体テストにおいてLiveDataの値の変化をチェックするための拡張関数を引き続き使用するために、LiveDataを使用しています。次節から、Jetpack ComposeにおけるLiveDataの使用方法を実装例とともに解説します。

ViewModelの実装例

繰り返し一括登録の一覧画面で登録された情報を削除する部分を例に解説します。

あるユーザは月1000円の動画配信サービスを契約していましたが、解約することにしたので、未来の登録を削除する操作をしました。

繰り返し一括登録の削除
繰り返し一括登録の削除

※ 削除処理は非同期で行われてるため、すぐには一覧に反映されません。

この操作で使用されたViewModelの実装がこのようになります。 削除処理以外の処理は省略しています。

class SubscriptionListViewModelImpl(
    private val subscriptionListDeleteUseCase: SubscriptionListDeleteUseCase
) : SubscriptionListViewModel() {

    override fun createDefaultState() = SubscriptionListState()

    private val _state = MutableLiveData(createDefaultState())

    override val state: LiveData<SubscriptionListState> = _state

    // effectは省略

    /**
     * 削除対象ID
     */
    private var deleteId = 0L

    /**
     * 全期間を削除or未来の登録を削除
     */
    private var deleteAll = false

    override fun event(event: SubscriptionListEvent) {
        viewModelScope.launch {
            when (event) {
                is SubscriptionListEvent.Delete -> {
                    // モーダルボトムシートで削除が選択された
                    // 削除対象ID
                    deleteId = event.id
                    // 全期間を削除 or 未来の登録を削除
                    deleteAll = event.deleteAll
                    // 削除確認ダイアログを表示する
                    _state.update {
                        copy(showConfirmDelete = true)
                    }
                }
                SubscriptionListEvent.OkConfirmDelete -> {
                    // 削除確認ダイアログでOKボタンが押された
                    try {
                        // 削除確認ダイアログを閉じる
                        // 削除プログレスを表示する
                        _state.update {
                            copy(
                                showConfirmDelete = false,
                                progress = SubscriptionListProgress.DELETE
                            )
                        }
                        // 削除処理を実行する
                        val result = subscriptionListDeleteUseCase.execute(
                            deleteId,
                            deleteAll
                        )
                        // プログレスを非表示にする
                        // 一覧を更新する
                        // 削除完了ダイアログを表示する
                        _state.update {
                            copy(
                                progress = SubscriptionListProgress.NO,
                                items = result.items.map {
                                    SubscriptionListStateItem(
                                        it.subscription, it.iconSpendingCategory
                                    )
                                },
                                showEndDelete = true
                            )
                        }
                    } catch (e: Throwable) {
                        // エラー処理は省略
                    }
                }
                SubscriptionListEvent.CancelConfirmDelete -> {
                    // 削除確認ダイアログでキャンセルされた
                    // 削除確認ダイアログを閉じる
                    _state.update {
                        copy(
                            showConfirmDelete = false
                        )
                    }
                }
                SubscriptionListEvent.CloseEndDelete -> {
                    // 削除完了ダイアログが閉じられた
                    _state.update {
                        copy(
                            showEndDelete = false
                        )
                    }
                }
            }
        }
    }
}

updateメソッドはMutableLiveDataの値の一部フィールドをdata classのcopyメソッドで更新するための拡張関数です。

fun <T> MutableLiveData<T>.update(action: T.() -> T) {
    value = requireNotNull(value).action()
}

ViewModelのテストコード

前回の記事と同様にMockKを使って単体テストを記述しています。coEveryメソッド等でUseCaseの返却値を定義したのち、ViewModelのeventメソッドを呼び出し、coVerifySequence メソッド等でUseCaseの呼び出しとLiveDataの値の変化を確認しています。LiveDataの値の変化はObserverのonChangedメソッドの呼び出しを確認する形で検証しています。

class SubscriptionListViewModelTest {
    @Test
    fun deleteFuture() = runBlocking {
        val mockStateObserver = viewModel.state.mockObserver("state")
        // 削除UseCaseの返却を定義する
        coEvery {
            subscriptionListDeleteUseCase.execute(3L, false)
        } returns SubscriptionListDeleteUseCaseResult(/* 省略 */)
        // 削除を選ぶ
        viewModel.event(SubscriptionListEvent.Delete(3L, false))
        // OKボタンを押す
        viewModel.event(SubscriptionListEvent.OkConfirmDelete)
        // 削除完了ダイアログを閉じる
        viewModel.event(SubscriptionListEvent.CloseEndDelete)
        // UseCaseの呼び出しとLiveDataの値の変化を確認する
        coVerifySequence {
            // 初期状態
            mockStateObserver.onChanged(SubscriptionListState())
            // 削除確認ダイアログを表示する
            mockStateObserver.onChanged(SubscriptionListState(showConfirmDelete = true))
            // 削除確認ダイアログを閉じる
            // 削除プログレスを表示する
            mockStateObserver.onChanged(
                SubscriptionListState(
                    progress = SubscriptionListProgress.DELETE,
                    showConfirmDelete = false
                )
            )
            // 削除処理を行う
            subscriptionListDeleteUseCase.execute(3L, false)
            // プログレスを非表示にする
            // 削除完了ダイアログを表示する
            // 一覧を更新する
            mockStateObserver.onChanged(
                SubscriptionListState(
                    progress = SubscriptionListProgress.NO,
                    showEndDelete = true,
                    items = listOf(/* 省略 */)
                )
            )
            // 削除完了ダイアログを非表示にする
            mockStateObserver.onChanged(
                SubscriptionListState(
                    progress = SubscriptionListProgress.NO,
                    items = listOf(/* 省略 */)
                    showEndDelete = false
                )
            )
        }
    }
}

LiveDataをCompose用のStateに変換する

androidx.compose.runtime:runtime-livedata ライブラリを使い、LiveDataをCompose用のStateに変換しています。

val state = viewModel.state.observeAsState(viewModel.createDefaultState()).value

LiveDataとStateFlowは似ていますが、LiveDataは初期状態が必須でないところに違いがあります。 参考 StateFlow、Flow、LiveData

なので LiveData<T>.observeAsState メソッドに初期値を渡さないとnullableな値を持つStateに変換されてしまい使いにくくなってしまいます。そのため、ViewModelの共通インターフェースに初期値としてnon-nullなStateを返すメソッドを定義しています。

Preview用ViewModel

画面全体をAndroid Studioでプレビューするときは、固定の状態を持ったPreview用のViewModelを作成して、Composable関数に渡しています。画面で発生したユーザ操作をEventオブジェクトとして渡す設計は、ユーザ操作ごとにメソッドがある設計とは違い、後で操作が増えてもプレビュー用のViewModel実装の変更が不要な点が良いです。

/**
 * 繰り返し一括登録の一覧画面のプレビュー用ViewModel
 *
 * @param プレビュー用の固定の状態
 */
class SubscriptionListViewModelPreview(
    private val previewState: SubscriptionListState
) : SubscriptionListViewModel() {

    override fun createDefaultState() = previewState

    override val state: LiveData<SubscriptionListState>
        get() = MutableLiveData(createDefaultState())

    override val effect: Flow<SubscriptionListEffect>
        get() = flow { }

    override fun event(event: SubscriptionListEvent) {
    }
}

/**
 * 繰り返し一括登録の一覧画面のプレビュー(0件ケース)
 */
@Preview
@Composable
fun SubscriptionListScreenPreviewEmpty() {
    CodeComposeTheme {
        // 0件の時の状態
        val previewState = SubscriptionListState(
            progress = SubscriptionListProgress.NO
        )
        // 固定の状態を持ったViewModelを作成する
        val viewModel = SubscriptionListViewModelPreview(previewState)
        // そのViewModelを持ってComposable関数を呼び出す
        SubscriptionListScreen(viewModel)
    }
}

まとめ

今回はCODEに対するJetpack Composeの導入について、ViewModelの設計を解説しました。UIの状態はLiveDataで持って、プレビューと単体テストに対応することができました。

次回はJetpack Composeのプレビュー高速化ために設計変更したモジュール構成を説明いたします。

Androidエンジニア募集中

弊社リサーチ・アンド・イノベーションでは例えば一緒にJetpack Composeを書きたいAndroidエンジニアを募集しています。

採用情報 - Androidエンジニア