Molecule - 使用Jetpack Compose构建StateFlow流

1,043 阅读4分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

Molecule

通过Jetpack Compose构建StateFlow或者Flow流.

fun CoroutineScope.launchCounter(): StateFlow<Int> = launchMolecule(clock = ContextClock) {
  var count by remember { mutableStateOf(0) }

  LaunchedEffect(Unit) {
    while (true) {
      delay(1_000)
      count++
    }
  }

  count
}

简介

Jetpack Compose UI 使得用逻辑构建声明式UI更容易.

val userFlow = db.userObservable()
val balanceFlow = db.balanceObservable()

@Composable
fun Profile() {
  val user by userFlow.subscribeAsState(null)
  val balance by balanceFlow.subscribeAsState(0L)

  if (user == null) {
    Text("Loading…")
  } else {
    Text("${user.name} - $balance")
  }
}

不幸的是, 我们正在混合业务逻辑和展示逻辑, 这使得测试比它们分开时更加难以进行. 展示层也直接交互于存储层, 这创建了不受欢迎的耦合. 除此之外, 如果我们想用相同的逻辑(在其它平台是可能的)增强不同的展示, 对不起, 办不到.

业务逻辑解析成类似presenter的对象修复了这3件事.

在Cash应用中, presenter对象像过去一样通过Kotlin协程的Flow或者RxJava的Observable , 暴露了展示模型的单一流.

sealed interface ProfileModel {
  object Loading : ProfileModel
  data class Data(
    val name: String,
    val balance: Long,
  ) : ProfileModel
}

class ProfilePresenter(
  private val db: Db,
) {
  fun transform(events: Flow<Nothing>): Flow<ProfileModel> {
    return combine(
      db.users().onStart { emit(null) },
      db.balances().onStart { emit(0L) },
    ) { user, balance ->
      if (user == null) {
        Loading
      } else {
        Data(user.name, balance)
      }
    }
  }
}

这些代码是可以的, 但组合响应流的仪式将非线性扩展. 这意味着越来越多的使用数据源和越来越复杂的逻辑, 使得响应式代码越来越难以理解.

尽管同步发出了Loading状态, Compose UI 依然要求初始值用于所有的Flow或者Observable. 这是一种分层冲突, 因为视图层无法规定合理的默认值, 因为表示者层控制模型对象.

Molecule修复了这些问题. 我们的presenter能够返回StateFlow<ProfileModel>, 它的初始状态能够被Compose UI在视图层同步读取. 并且通过使用Compose, 我们也能够通过使用基于Kotlin语言的命令式代码而非包含RxJava包API的响应式代码来构建模型对象.

@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)
  }
}

生成模型的可组合函数可以通过launchMolecule来运行.

val userFlow = db.users()
val balanceFlow = db.balances()
val models: StateFlow<ProfileModel> = scope.launchMolecule(clock = ContextClock) {
  ProfilePresenter(userFlow, balanceFlow)
}

运行ProfilePresenter并且和StateFlow分享输出的协程会在给定的CoroutineScope.

在视图层, 消费模型对象的StateFlow变得无关紧要.

@Composable
fun Profile(models: StateFlow<ProfileModel>) {
  val model by models.collectAsState()
  when (model) {
    is Loading -> Text("Loading…")
    is Data -> Text("${model.name} - ${model.balance}")
  }
}

更多信息可以查看launchMolecule文档.

Flow

除了StateFlow之外, Molecule能够创建常规的Flow.

这是个presenter例子, 更新了会使用常规的Flow:

val userFlow = db.users()
val balanceFlow = db.balances()
val models: Flow<ProfileModel> = moleculeFlow(clock = Immediate) {
  ProfilePresenter(userFlow, balanceFlow)
}

以及这个计算器例子:

fun counter(): Flow<Int> = moleculeFlow(clock = Immediate) {
  val count by remember { mutableStateOf(0) }

  LaunchedEffect(Unit) {
    while (true) {
      delay(1_000)
      count++
    }
  }

  count
}

更多信息请查看moleculeFlow文档.

用法

添加buildscript依赖并且在每一个想要调用launchMolecule或者定义@Composable函数来使用Molecule的模块中应用插件.

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

apply plugin: 'app.cash.molecule'

开发版本的快照在Sonatype的快照仓库可用.

帧时钟

无论任何时候Jetpack Compose重组, 它都要在开始工作之前等待下一帧. 它依赖于CoroutineContext里面的MonotonicFrameClock来知晓新帧发送的时候. Molecule就是底层的Jetpack Compose, 所以它也要求帧时钟: 直到帧发送了, 值才会产生并且重组发生.

然而, 不像Jetpack Compose, Molecule有时候也会运行在不提供MonotonicFrameClock的环境下. 因此Molecule的全部API都要求你指定自己偏好的时钟行为:

  • RecompositionClock.ContextClock的行为像Jetpack Compose: 它将从调用的coroutineContext中提取MonotonicFrameClock,并使用它进行重新编译. 如果没有MonotonicFrameClock, 它将会抛出异常. ContextClock和Android的AndroidUiDispatcher.Main工作的时候是有用的. Main拥有内置的MonotonicFrameClock, 与设置的帧率保持同步. 所以运行在Main上并拥有ContextClock的Molecule也会和帧率以相同的锁节奏运行. 干净又利索! 当然你也可以提供自己的BroadcastFrameClock来实现自己的帧率.
  • RecompositionClock.Immediate将会构建一个即时时钟. 该时钟会在任何时候封闭流发出数据项时生成一帧. (对于StateFlow而言情况总是这样.) Immediate可在没有时钟的情况下使用, 无需任何额外接线. 它对于单元测试或者非主线程运行Molecule有用.

测试

使用moleculeFlow(clock = Immediate)并且使用Turbine进行测试. moleculeFlow在Turbine上就像任何其它流一样运行.

@Test fun counter() = runTest {
  moleculeFlow(RecompositionClock.Immediate) {
    Counter()
  }.test {
    assertEquals(0, awaitItem())
    assertEquals(1, awaitItem())
    assertEquals(2, awaitItem())
    cancel()
  }
}

如果你正在Android模块的JVM上单元测试Molecule, 请在项目的AGP配置上设置如下内容.

android {
  ...
  testOptions {
    unitTests.returnDefaultValues = true
  }
  ...
}