🐻‍❄️

MoleculeでAndroidのViewModelにおける計算処理の問題点を改善してみる

2022/12/11に公開

Android Advent Calendar 2022の11日目の記事です。
少々遅れましたが投稿したのでぜひ見てください。

はじめに

MoleculeはJetpack Composeを利用して、宣言的に状態を生成するライブラリです。ざっくりいうとアプリの状態を生成するコードをJetpack ComposeのComposable関数で実装できるライブラリになります。今回はこのMoleculeを利用してAndroidのViewModelにある状態管理のコードを改善していこうと思います。

GitHub - cashapp/molecule: Build a StateFlow stream using Jetpack Compose

@Composable
fun ProfilePresenter(
  userFlow: Flow<User>,
  balanceFlow: Flow<Long>,
): ProfileModel {
  val user by userFlow.collectAsState(null)
  val balance by balanceFlow.collectAsState(0L)

  return if (user == null) {
    Loading
  } else {
    Data(user.name, balance)
  }
}

AndroidのViewModelで計算処理を実装してみる

実装サンプル

まずは以下のようなModelとViewModelとを定義して、ユーザーが入力を更新したら、出力を再計算する処理を実装してみます。今のAndroidアプリの開発でLiveDataまたはStateFlowを利用して、リアクティブに値が更新されるケースが一般的かなと思います。なので以下のようにStateFlowを利用して実装をしてみています。

  • inputをMutableStateFlowで宣言して、最新の入力値を保持できるようにする
  • outputをStateFlowで宣言して、入力値から計算した出力値を保持できるようにする
  • modelをStateFlowで宣言して、保持している入力値・出力値をまとめあげてModelとして購読可能になる
  • updateInputを宣言して、入力値の更新ができるようにする
data class CalculatorModel(
    val input: Int,
    val output: Int
)
class CalculatorViewModel() : ViewModel() {
    private val input: MutableStateFlow<Int> = MutableStateFlow(0)

    private val output: StateFlow<Int> = input.map { input ->
        input * 2
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)

    val model: StateFlow<CalculatorModel> = combine(input, output) { input, output ->
        CalculatorModel(input, output)
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CalculatorModel(0, 0))

    fun updateInput(newInput: Int) {
        input.value = newInput
    }
}

問題点

このような実装だとModelがどのように計算されるのか追いづらくなるという問題点が出てきます。例えばModelはoutputが更新されたら再生成されるようになっていますが、どのタイミングでoutputが更新されるのか理解するためには、以下の順番でコードを追っていく必要があります。このコードを追っていく作業は直感的ではなく、計算処理に必要なパラメータが増えると急激に複雑度が高まっていく傾向があります。

class CalculatorViewModel() : ViewModel() {
    // 3. inputはMutableStateFlowなので、任意のタイミングで更新される
    private val input: MutableStateFlow<Int> = MutableStateFlow(0)

    // 2. outputはinputの変更に反応して、再計算される
    private val output: StateFlow<Int> = input.map { input ->
        input * 2
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)

    // 1. inputとoutputの更新に反応して、Modelを再生する
    val model: StateFlow<CalculatorModel> = combine(input, output) { input, output ->
        CalculatorModel(input, output)
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CalculatorModel(0, 0))

    // 4. inputは計算イベントのタイミングで更新される
    fun calc(newInput: Int) {
        input.value = newInput
    }
}

Moleculeを利用して計算処理を改善してみる

実装サンプル

今回は上記の問題を解決するためにViewModelのコードをMoleculeのコードを置き換えを行います。MoleculeではJetpack ComposeのComposable関数で処理を組み立てていきます。今回のような計算処理の実装をするにはJetpack ComposeのrememberやLaunchedEffectなどの仕組みを駆使して実装していきます。

  • eventをFlowで宣言して、外部からの計算イベントを受信できるようにする
  • inputをrememberで宣言して、最新の入力値を保持できるようにする
  • outputをrememberで宣言して、入力値から計算した出力値を保持できるようにする
  • LaunchedEffectでeventの購読を開始して、計算イベント通知時に計算処理を実行できるようにする
  • inputとoutputの変更に応じて、modelを生成して、戻り値で返すようにする
sealed interface CalculatorEvent {
    data class Calc(val input: Int) : CalculatorEvent
}

@Composable
fun CalculatorPresenter(event: Flow<CalculatorEvent>): CalculatorModel {
    var input by remember { mutableStateOf(0) }
    var output by remember { mutableStateOf(0) }

    LaunchedEffect(Unit) {
        event.collect { event ->
            when (event) {
                is CalculatorEvent.Calc -> {
                    input = event.input
                    output = event.input * 2
                }
            }
        }
    }

    return CalculatorModel(input, output)
}

改善点

上記のようにMoleculeのコードを記述しましたが、ViewModelのコードに比べ、Moleculeを利用したほうが、Modelはinputとoutputが変化したら更新されるということがより明確にできました。

// inputとoutputが変化したらmodelが再計算されるという関係性がより簡単に理解できる
@Composable
fun CalculatorPresenter(event: Flow<CalculatorEvent>): CalculatorModel {
    var input by remember { mutableStateOf(0) }
    var output by remember { mutableStateOf(0) }
		︙ 省略
    return CalculatorModel(input, output)
}

またinputやoutputはCalculatorEvent.Calcが通知時に更新されるというのが直感的にわかるようになり、基本上から順番にコードを見ていくだけで計算処理の仕組みが理解できるようになりました。

@Composable
fun CalculatorPresenter(event: Flow<CalculatorEvent>): CalculatorModel {
    // 1. inputとoutputによってModelは構築される
    var input by remember { mutableStateOf(0) }
    var output by remember { mutableStateOf(0) }

    // 2. inputとoutputはCalculatorEvent.Calcで更新される
    LaunchedEffect(Unit) {
        event.collect { event ->
            when (event) {
                is CalculatorEvent.Calc -> {
                    input = event.input
                    output = event.input * 2
                }
            }
        }
    }

    return CalculatorModel(input, output)
}

まとめ

MoleculeではJetpack Composeの仕組みを利用してComposable関数にて処理を記述するのが最大の特徴だと思いますが、Jetpack Composeを書き慣れている方であれば、宣言的に状態生成の処理を書けるので、読みやすいと感じました。今回のような簡単なケースであれば読みやすいということもあるので、Moleculeに関しては実際の開発しているアプリに適用してみて、どんなコードになるかは確かめたいと感じました。

Discussion