🫰

moleculeを使ってJetpack Composeの状態管理を楽にする

2022/08/28に公開

最近、Flowを使って異なるいくつかのデータソースからデータを取得してそれらをcombineしてUIモデルとして扱おうと思った時に、combineする数が多くなればなるほど読みにくくなって、ロジックの部分が複雑になっていると感じて、その問題を解消するライブラリがないかなと探していたらMoleculeというJetpack Composeのライブラリを見つけたので、どんな感じで使えるのか調べてみました。

moleculeを使わないでやるとどうなるか?

まず、今まで通り普通にcombineを使ってUIモデルにまとめようと思うとこんな感じになります。

sealed interface MainUiModel {
  object Loading : MainUiModelModel
  data class Data(
    val name: String,
    val country: String,
  ) : MainUiModel
}

class MainPresenter(
  private val userDb: UserDb,
) {
  fun transform(events: Flow<Nothing>): Flow<MainUiModel> {
    return combine(
      db.users().onStart { emit(null) },
      db.countries().onStart { emit(0) },
    ) { user, country ->
      if (user == null) {
        Loading
      } else {
        Data(user.name, country)
      }
    }
  }
}

このやり方だと何が問題になってくるのか?

  • データソースが増えた時にロジックが複雑にって理解するのが難しくなる。
  • Loadingを返り値として返すために初期値を設定しなければならない。それによって、プレゼンテーションレイヤーがドメインオブジェクトを操作することになるので、あまり良くない。

moleculeを使うとどうなるのか?

まず依存関係を追加します。

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath 'app.cash.molecule:molecule-gradle-plugin:0.4.0'
  }
}

id 'app.cash.molecule'

Composable関数を使って、StateFlow<MainUiModel>を返すことができるようになって、その結果初期値をビューレイヤーで同期的に読み取ることができるようになります🙌

@Composable
fun MainPresenter(
  userFlow: Flow<User>,
  balanceFlow: Flow<String>,
): ProfileModel {
  val user by userFlow.collectAsState(null)
  val country by balanceFlow.collectAsState("")

  return if (user == null) {
    Loading
  } else {
    Data(user.name, country)
  }
}
val userFlow = db.users()
val countryFlow = db.countries()
val models: StateFlow<MainUiModel> = scope.launchMolecule(clock = ContextClock) {
  MainPresenter(userFlow, countryFlow)
}

launchMoleculeを起動して、MainPresenterをアウトプットするStateFlowは、渡されたCoroutineScopeで実行することができます。
これによって、ビューレイヤーで簡単にStateFlowを消費できるようになりました👋

どうやって動いているのか?

ここで、launchMoleculeの内部を見てみようと思います。

fun <T> CoroutineScope.launchMolecule(
  clock: RecompositionClock,
  body: @Composable () -> T,
): StateFlow<T> {
  var flow: MutableStateFlow<T>? = null

  launchMolecule(
    clock = clock,
    emitter = { value ->
      val outputFlow = flow
      if (outputFlow != null) {
        outputFlow.value = value
      } else {
        flow = MutableStateFlow(value)
      }
    },
    body = body,
  )

  return flow!!
}
fun <T> CoroutineScope.launchMolecule(
  clock: RecompositionClock,
  emitter: (value: T) -> Unit,
  body: @Composable () -> T,
) {
  val clockContext = when (clock) {
    RecompositionClock.ContextClock -> EmptyCoroutineContext
    RecompositionClock.Immediate -> GatedFrameClock(this)
  }

  with(this + clockContext) {
    val recomposer = Recomposer(coroutineContext)
    val composition = Composition(UnitApplier, recomposer)
    launch(start = UNDISPATCHED) {
      recomposer.runRecomposeAndApplyChanges()
    }

    var applyScheduled = false
    val snapshotHandle = Snapshot.registerGlobalWriteObserver {
      if (!applyScheduled) {
        applyScheduled = true
        launch {
          applyScheduled = false
          Snapshot.sendApplyNotifications()
        }
      }
    }
    coroutineContext.job.invokeOnCompletion {
      composition.dispose()
      snapshotHandle.dispose()
    }

    composition.setContent {
      emitter(body())
    }
  }
}

launchMoleculeの引数では、clock: RecompositionClockというのがあります。
RecompositionClockって何なのでしょうか?
みてみます🥸

enum class RecompositionClock {
  /**
   * Use the MonotonicFrameClock that already exists in the calling CoroutineContext.
   * If none exists, an exception is thrown.
   *
   * Use this option to drive Molecule with the built-in Android frame clock.
   */
  ContextClock,

  /**
   * Install an eagerly recomposing clock. This clock will provide a new frame immediately whenever
   * one is requested. The resulting flow will emit a new item every time the snapshot state is invalidated.
   */
  Immediate,
}

RecompositionClockには、2つContextClockImmediateというのがあります。この2つが何意味しているのかというと、Jetpack Composeは再コンポジションの際に、常に次のフレームを待って作業を開始するので、新しいフレームが送られてくるタイミングを知るために、CoroutineContextMonotonicFrameClockというものに依存しているらしいです。
MonotonicFrameClockはこんな感じのinterfaceになっています。

interface MonotonicFrameClock : CoroutineContext.Element {
    suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R

    override val key: CoroutineContext.Key<*> get() = Key

    companion object Key : CoroutineContext.Key<MonotonicFrameClock>
}

MonotonicFrameClockは、ディスプレイのフレームのタイムソースと、次のフレームでアクションを実行する機能を提供しています。
moleculeもJetpack Composeをベースにして作られたものなので、フレームクロックも必要になります。
しかし、moleculeMonotonicFrameClockを提供しない状況で実行されることがあるので、moleculeを使用する時にはクロック動作を指定する必要があるということになっています。
だからRecompositionClockを渡してあげるのですね🤓

ContextClockImmediateが何を意味しているのかみてましょう。

ContextClockは、Dispatchers.Main上で使うとフレームレートと同期して実行されます。
Immediateは、囲んでいるFlowがアイテムを出力する準備ができた時にすぐにフレームを作成します。

まとめ

  • moleculeを使うことで、FlowからStateFlowを簡単に作ることができる。
  • combineしてUIモデルを作るときに、データソースが増えてもロジックが複雑になりにくくなり理解しやすくなる。

参考

https://github.com/cashapp/molecule

Discussion