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を使うよりもデグレードを防げる範囲を広げることができます。カバレッジもJaCoCoとCodecovで確認しています。
タスクを分割しやすくする
Jetpack ComposeにはAndroid Studioによるプレビュー機能があります。まだ、データレイヤーやドメインレイヤーがまだ作られていなかったり、新規にジョインしたエンジニアにそれらをいきなりお任せするのは難しいときでも、先行してComposable関数を作って頂くことが可能です。プルリク単位では、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エンジニアを募集しています。