Molecule
使用 Jetpack Compose[1] 构建 StateFlow 或 Flow 流。
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。 因此,使用ContextClock在Main上运行的 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")
}
- …并且不是 Jetpack Compose UI! ↩︎