combine函数定义
js
public fun <T1, T2, R> Flow<T1>.combine(flow: Flow<T2>, transform: suspend (a: T1, b: T2) -> R): Flow<R> = flow { combineInternal(arrayOf(this@combine, flow), nullArrayFactory(), { emit(transform(it[0] as T1, it[1] as T2)) }) }
combineInternal实际处理两个流函数
kotlin
private typealias Update = IndexedValue<Any?>
@PublishedApi
internal suspend fun <R, T> FlowCollector<R>.combineInternal(
flows: Array<out Flow<T>>,
arrayFactory: () -> Array<T?>?, // 需要靠数组工厂绕开 JVM 上的数组类型问题
transform: suspend FlowCollector<R>.(Array<T>) -> Unit
): Unit = flowScope { // 使用 flow scope:源流中发生的任何取消都会连带取消整个作用域
val size = flows.size
if (size == 0) return@flowScope // 空输入,直接返回
val latestValues = arrayOfNulls<Any?>(size)
latestValues.fill(UNINITIALIZED) // 相比 Array(size) { UNINITIALIZED },字节码更小、执行更快
val resultChannel = Channel<Update>(size)
val nonClosed = LocalAtomicInt(size)
var remainingAbsentValues = size
for (i in 0 until size) {
// 每条流对应一个协程:记录自己的最新值,并把结果发送到下游
launch {
try {
flows[i].collect { value ->
resultChannel.send(Update(i, value))
yield() // 模拟公平调度,让每条流都有机会发射
}
} finally {
// 所有流都已结束时,关闭 channel
if (nonClosed.decrementAndGet() == 0) {
resultChannel.close()
}
}
}
}
/*
* 批量接收优化:按批读取更新,但一旦遇到
* 来自同一条流的第二个值,就立刻中断本批
*/
val lastReceivedEpoch = ByteArray(size)
var currentEpoch: Byte = 0
while (true) {
++currentEpoch
// 开始一批
// 本轮中的第一次接收应当是挂起式的
var element = resultChannel.receiveCatching().getOrNull() ?: break // channel 已关闭,无需继续
while (true) {
val index = element.index
// 更新值
val previous = latestValues[index]
latestValues[index] = element.value
if (previous === UNINITIALIZED) --remainingAbsentValues
// 检查轮次
// 同一轮次内收到了来自同一条流的第二个值 ------ 中断本批
if (lastReceivedEpoch[index] == currentEpoch) break
lastReceivedEpoch[index] = currentEpoch
element = resultChannel.tryReceive().getOrNull() ?: break
}
// 数据足够时,处理本批结果
if (remainingAbsentValues == 0) {
/*
* 如果 arrayFactory 返回 null,就可以省掉数组拷贝,
* 因为那是我们自己的安全转换器,会立即拆解这个数组
*/
val results = arrayFactory()
if (results == null) {
transform(latestValues as Array<T>)
} else {
(latestValues as Array<T?>).copyInto(results)
transform(results as Array<T>)
}
}
}
}
实际场景
光看源码容易忘,下面两个例子都是业务里每天都会遇到的写法。
场景一:登录页表单校验 ------ 三个输入合成一个「按钮能不能点」
账号、密码、是否勾选协议,任意一个变化都要重新判断登录按钮的可用状态。这是 combine 最标准的用法。
arduino
/**
* 三个输入流由调用方注入,ViewModel 只负责「组合计算」这一件事,
* 不持有输入状态,所以也不需要 onXxxChange 这类回写方法。
*/
class LoginViewModel(
account: StateFlow<String>,
password: StateFlow<String>,
agreed: StateFlow<Boolean>
) : ViewModel() {
/** 三个输入任意一个变化,都会重新算出按钮是否可点 */
val submitEnabled: StateFlow<Boolean> =
combine(account, password, agreed) { acc, pwd, ok ->
acc.length >= 6 && pwd.length >= 8 && ok
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
}
输入状态由调用方持有,谁产生谁往流里写:
kotlin
class LoginActivity : AppCompatActivity() {
// 输入状态放在界面层
private val account = MutableStateFlow("")
private val password = MutableStateFlow("")
private val agreed = MutableStateFlow(false)
private val viewModel: LoginViewModel by viewModels {
viewModelFactory {
initializer { LoginViewModel(account, password, agreed) }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
// 输入框 -> 状态流
binding.etAccount.doAfterTextChanged { account.value = it.toString() }
binding.etPassword.doAfterTextChanged { password.value = it.toString() }
binding.cbAgreed.setOnCheckedChangeListener { _, checked -> agreed.value = checked }
// 状态流 -> 视图
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.submitEnabled.collect { binding.btnLogin.isEnabled = it }
}
}
}
}
这样写的好处:ViewModel 不持有输入状态,纯粹是「多个流 → 一个流」的组合逻辑,单测时直接塞三个 MutableStateFlow 进去就能断言输出;同一套组合规则也能原样复用给别的界面(比如注册页、改密码页),不用复制粘贴。
场景二:首页数据聚合 ------ 多个接口并行拉取,合成一个 UI 状态
首页要展示用户信息、未读数、Banner 三块内容,接口是分开的。用 combine 可以让三个请求同时发出,总耗时约等于最慢的那个接口,而不是三个相加。
kotlin
data class HomeUiState(
val user: User? = null,
val unread: Int = 0,
val banners: List<Banner> = emptyList(),
val loading: Boolean = true,
val error: String? = null
)
class HomeViewModel(
private val api: HomeApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
/** 把一次挂起请求包装成「只发一个值」的 Flow,并切到 IO 线程 */
private fun <T> request(block: suspend () -> T): Flow<T> =
flow { emit(block()) }.flowOn(dispatcher)
val uiState: StateFlow<HomeUiState> = combine(
request { api.getUser() },
request { api.getUnreadCount() },
request { api.getBanners() }
) { user, unread, banners ->
HomeUiState(
user = user,
unread = unread,
banners = banners,
loading = false
)
}
.catch { e -> emit(HomeUiState(loading = false, error = e.message)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUiState())
}
三个请求之所以是并行的,就来自源码里的这一段:
scss
for (i in 0 until size) {
launch { // 每个上游一个独立协程,同时开始 collect
flows[i].collect { value -> resultChannel.send(Update(i, value)) }
}
}
同时也要清楚它的代价:因为要等每个 上游都至少发出一个值才第一次回调,所以这里必须三个接口全部返回,loading 才会变 false。如果你希望「谁先回来先渲染谁」,那 combine 就不合适了 ------ 应该拆成多个独立状态,或者用 onEach 各自更新。
用之前必须知道的几点
这几条都能在源码里找到出处:
- 第一次回调的时机 :必须每个上游都至少发出一个值。源码靠
remainingAbsentValues从size一路减到0才第一次调用transform。 - 之后每次回调 :任一上游发射都会触发一次,值取「各流的最新值」。对应源码里的
latestValues[index] = element.value------ 就是就地覆盖。 - 高频源一定要防抖 :输入框这种每敲一个字发一次的场景,先
debounce(300).distinctUntilChanged(),否则下游会被反复重算。 - 同源连发会被合并:同一轮里如果同一条流连发了多个值,源码里那段 epoch 判断会提前中断本批,避免拿同一来源的多个值反复计算 ------ 这就是「批量接收优化」的实际价值。
- 取消是联动的 :下游一取消,内部
flowScope结束,所有上游的收集协程一起被取消,不需要手动一个个处理。对应flowScope { ... }那行。 - 异常会打断整条链 :任一上游抛异常,
combine直接终止。记得把catch放在combine之后,否则拦不到。 - 和
zip的区别 :两者都会合并多个流,但zip是严格按顺序一一配对 (第 1 个配第 1 个,第 2 个配第 2 个),任一上游慢下来整体就等着;combine是各自取最新值 ,谁快谁多都能跟上。要「配对」用zip,要「组合状态」用combine。