Molecule 中文翻译

599 阅读4分钟

Molecule

使用 Jetpack Compose[1] 构建 StateFlowFlow 流。

fun CoroutineScope.launchCounter(): StateFlow<Int> = launchMolecule(mode = 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 这样的对象可以解决这三个问题。

在 Cash App 中,我们的 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(): 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](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.Flow).collectAsState(kotlin.Any,kotlin.coroutines.CoroutineContext))[Observable](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.Flow).collectAsState(kotlin.Any,kotlin.coroutines.CoroutineContext)) 用法指定一个初始值。这是一种分层违规,因为view 层不应该负责决定模型对象的默认值,因为这个职责应该由 presenter 层来承担。

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(mode = 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](https://cashapp.github.io/molecule/docs/latest/molecule-runtime/app.cash.molecule/launch-molecule.html) 文档

Flow

除了 StateFlow,Molecule 还可以创建普通的 Flow

这是更新为使用普通 Flow 的 presenter 示例:

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

和计数器示例:

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

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

  count
}

更多信息请参见 [moleculeFlow](https://cashapp.github.io/molecule/docs/latest/molecule-runtime/app.cash.molecule/molecule-flow.html) 文档

使用方法

添加构建脚本依赖并将插件应用于每个需要调用 launchMolecule 或定义用于 Molecule 的 @Composable 函数的模块。

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

apply plugin: 'app.cash.molecule'
``

帧时钟

每当 Jetpack Compose 进行重组时,它总是等待下一帧开始工作。 它依赖于其 CoroutineContext 中的 MonotonicFrameClock 来知道何时发送新的帧。 Molecule 在底层就是 Jetpack Compose,所以它也需要一个帧时钟:在发送帧并进行重新组合之前,不会产生值。

然而,与 Jetpack Compose 不同的是,Molecule 有时会在不提供 MonotonicFrameClock 的情况下运行。 因此,所有的 Molecule API 都要求你指定你喜欢的时钟行为:

  • RecompositionMode.ContextClock的行为类似于 Jetpack Compose:它会从调用的coroutineContext中找出 MonotonicFrameClock 并使用它进行重新组合。 如果没有 MonotonicFrameClock,它将抛出异常。 ContextClock对于Android的[AndroidUiDispatcher.Main](https://cashapp.github.io/molecule/docs/latest/molecule-runtime/app.cash.molecule/-android-ui-dispatcher/-companion/-main.html)很有用。 Main内置了一个与设备帧率同步的 MonotonicFrameClock。 因此,使用 ContextClockMain 上运行的 Molecule 也将与帧率同步运行。 很棒! 你还可以提供自己的 BroadcastFrameClock 来实现自己的帧率。
  • RecompositionMode.Immediate 将构建一个立即时钟。 当封闭流准备发出项目时,此时钟将产生一帧。 (对于 StateFlow,这总是成立的。) Immediate 可以在完全没有时钟可用的地方使用,无需任何额外的布线。 它可能用于单元测试,或者用于在主线程之外运行 molecules。

测试

使用 moleculeFlow(mode = Immediate) 并使用Turbine进行测试。你的 moleculeFlow 将像 Turbine 中的任何其他流一样运行。

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

如果你在 Android 模块上的 JVM 中对 Molecule 进行单元测试,请在项目的 AGP 配置中设置以下内容。

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

自定义 Compose 编译器

每个版本的 Molecule 都配有一个特定的 JetBrains Compose 编译器版本,该版本与单个版本的 Kotlin 兼容(参见上面的版本表)。可以使用 Gradle 扩展来指定新版本的 Compose 编译器或替代的 Compose 编译器。

要使用新版本的 JetBrains Compose 编译器版本:

molecule {
  kotlinCompilerPlugin.set("1.4.8")
}

要使用替代的 Compose 编译器依赖项:

molecule {
  kotlinCompilerPlugin.set("com.example:custom-compose-compiler:1.0.0")
}

  1. …并且不是 Jetpack Compose UI! ↩︎