moleculeを使ってJetpack Composeの状態管理を楽にする
最近、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つContextClock
とImmediate
というのがあります。この2つが何意味しているのかというと、Jetpack Composeは再コンポジションの際に、常に次のフレームを待って作業を開始するので、新しいフレームが送られてくるタイミングを知るために、CoroutineContext
のMonotonicFrameClock
というものに依存しているらしいです。
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をベースにして作られたものなので、フレームクロックも必要になります。
しかし、molecule
はMonotonicFrameClock
を提供しない状況で実行されることがあるので、molecule
を使用する時にはクロック動作を指定する必要があるということになっています。
だからRecompositionClock
を渡してあげるのですね🤓
ContextClock
とImmediate
が何を意味しているのかみてましょう。
ContextClock
は、Dispatchers.Main
上で使うとフレームレートと同期して実行されます。
Immediate
は、囲んでいるFlowがアイテムを出力する準備ができた時にすぐにフレームを作成します。
まとめ
-
molecule
を使うことで、Flow
からStateFlow
を簡単に作ることができる。 -
combine
してUIモデルを作るときに、データソースが増えてもロジックが複雑になりにくくなり理解しやすくなる。
参考
Discussion