🐷

Jetpack Composeを使用したMVVMアーキテクチャの最小実装サンプル

2022/04/09に公開

Jetpack Composeを使用したMVVMアーキテクチャの最小実装サンプルを作成しました。

GitHubで公開しています。
https://github.com/hiroa365/simple-compose-mvvm

なぜ作ったか?

仕事でJetpack Cokposeを試す機会があったので公式の情報とネットの情報でアーキテクチャを学んだのですが、シンプルな実装サンプルであまり良いものが見つかりませんでした。
最小の実装サンプルがあると理解がしやすいと考えて作ることにしました。

作成したアプリは、
画面中央のボタンを押すと数字がカウントアップする、単純なカウンターアプリです。

公式のアーキテクチャガイドについて

Android公式のアーキテクチャガイドに、推奨アーキテクチャが記載されています。(このアークテクチャガイドは2022年の1月頃に大きく改定されていて、おそらくMVVMと思われる記述が綺麗に消えていました。当時、新規Androidアプリのアーキテクチャ設計をして頻繁に見ていたので、職場内で衝撃が走ったのを覚えていますw)

以下の図は、推奨アーキテクチャガイド内の各レイヤの図を合成して作成ました。

この図からザックリと特徴をまとめると、

  • アプリは、UI、Domain、Dataの3つのレイヤに分かれる
  • UI レイヤは、UI ElementとStateHolder(=ViewModel)で構成される。
    • (図には無いが)StateHolderは、UIstateを保持している
  • ドメインレイヤは、あってもなくても良い
  • データレイヤは、RepositoryとDataSourceで構成されている

また、構成毎に役割で見て行くと、次のようになります。

  • UI Element
    • UIデータを画面にレンダリング
  • StateHolder(=ViewModel)
    • データを保持してUIに公開
    • データレイヤのデータをUIデータに変換
    • ユーザー入力イベントをUIデータに変換
  • ドメインレイヤ(オプション)
    • 複雑なビジネスロジックのカプセル化
    • 複数のViewModelで再利用されるビジネスロジックのカプセル化
  • Repository
    • アプリデータを公開。
    • ビジネスロジックを含む
  • DataSource
    • ファイル、ネットワーク ソース、ローカル データベースなどデータソースごとに作成

UIレイヤについて

今回作成したサンプルは、UIレイヤのUI Element、StateHolder、UI Stateの構成を最小実装しているので、その部分を深掘りしておきます。

公式のUIレイヤによると、UIレイヤは、単方向データフロー(UDF)でデータを管理しましょう、と書かれています。(図は、少し加工しています)

このような役割で動きます

  • UI Elementで発生したEventをViewModelに送り
  • ViewModelでは、eventを元にstateを更新
  • stateの変化を検出してUI Elementがstateのでーたをレンダリング

上記のアーキテクチャガイドを参考に最小構成のサンプルアプリを作成しています。

実装

今回実装したサンプルアプリは、主にView、ViewModel、Stateの3つのクラスに分かれています。
それぞれの役割を実装とともに簡単に説明していきます。

View

ViewではStateの内容に沿った表示を作成します。
MainScreenのviewModel.state.collectAsState()でViewModel内のStateを監視しています。Stateに変化があると再コンポーズされて画面の表示が変化します。

Composableの再利用性を高めるために、ViewModelを引き回さずに、state、eventに分解して引数で渡します。

ViewはStateの内容を表示する役割に限定しているのでStateの変更はしません。
Viewで発生したボタン押下のイベントをViewModelに通知して、ViewModelがStateを更新しています。

MainScreen.kt
@Composable
fun MainScreen(
    viewModel: MainScreenViewModel = hiltViewModel(),
) {
    /**
     * ViewModelで保持しているStateを監視
     * 変化があった場合は再コンポーズされる
     */
    val state by viewModel.state.collectAsState()

    /**
     * ViewModelがこの関数内だけで使用するため、
     * ViewModelにイベントを渡す場合は、Eventを送るためのリスナーを渡している
     */
    MainScreen(
        state = state,
        onClickCountUp = { viewModel.onEvent(OnClickCountUpEvent) }
    )
}

@Composable
private fun MainScreen(
    state: MainScreenState,
    onClickCountUp: () -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        /**
         * Stateのカウンターを表示する
         */
        Text(text = state.counter.toString())
        /**
         * ボタン押下でStateのカウンターを直接カウントアップせずに、
         * クリックイベントをViewModelに投げる
         */
        Button(onClick = onClickCountUp) {
            Text(text = "Count Up")
        }
    }
}

ViewModel

VewModelでは、ViewからonEventメソッドで送られてくるイベントの処理と、Stateの更新を行います。
画面のボタンをクリックすると、onEventが呼ばれるので、Stateのカウンターを増やしています。

MainScreenViewModel.kt
@HiltViewModel
class MainScreenViewModel @Inject constructor(): ViewModel() {

    /**
     * StateはStateFlowで保持
     */
    private val _state = MutableStateFlow(MainScreenState.initValue)
    val state = _state.asStateFlow()

    private fun currentState() = _state.value
    private fun updateState(newState: () -> MainScreenState) {
        _state.value = newState()
    }

    /**
     * Viewから送られるイベントを処理
     */
    fun onEvent(event: MainViewEvent) {
        when (event) {
            OnClickCountUpEvent -> onClickCountUpEvent()
        }
    }

    private fun onClickCountUpEvent() {
        val oldState = currentState()
        updateState { oldState.copy(counter = oldState.counter + 1) }
    }
}

State

Viewの表示状態を保持します。今回は画面に表示しているカウンターしかありませんが、
表示/非表示フラグなどもState内で管理します。

MainScreenState.kt
data class MainScreenState(
    val counter: Int
)

いずれ作る

Androidのアーキテクチャガイドを見ると、
View、StateHolder+State、Usecase、Repositoryの4階層が基本となっているように思います。
今期は、View、StateHolder+State、の部分だけ作りましたが、Usecase、Repositoryも含めたサンプルを近いうちに作りたいと思います。

Discussion