Kotlin Flow combine 源码剖析:一个 Channel 如何优雅合并多条流

1 阅读5分钟

combine函数定义

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实际处理两个流函数

​
​
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 最标准的用法。

/**
 * 三个输入流由调用方注入,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
        )
}

输入状态由调用方持有,谁产生谁往流里写:

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 可以让三个请求同时发出,总耗时约等于最慢的那个接口,而不是三个相加。

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

三个请求之所以是并行的,就来自源码里的这一段:

for (i in 0 until size) {
    launch {          // 每个上游一个独立协程,同时开始 collect
        flows[i].collect { value -> resultChannel.send(Update(i, value)) }
    }
}

同时也要清楚它的代价:因为要等每个上游都至少发出一个值才第一次回调,所以这里必须三个接口全部返回,loading 才会变 false。如果你希望「谁先回来先渲染谁」,那 combine 就不合适了 —— 应该拆成多个独立状态,或者用 onEach 各自更新。

用之前必须知道的几点

这几条都能在源码里找到出处:

  1. 第一次回调的时机:必须每个上游都至少发出一个值。源码靠 remainingAbsentValuessize 一路减到 0 才第一次调用 transform
  2. 之后每次回调:任一上游发射都会触发一次,值取「各流的最新值」。对应源码里的 latestValues[index] = element.value —— 就是就地覆盖。
  3. 高频源一定要防抖:输入框这种每敲一个字发一次的场景,先 debounce(300).distinctUntilChanged(),否则下游会被反复重算。
  4. 同源连发会被合并:同一轮里如果同一条流连发了多个值,源码里那段 epoch 判断会提前中断本批,避免拿同一来源的多个值反复计算 —— 这就是「批量接收优化」的实际价值。
  5. 取消是联动的:下游一取消,内部 flowScope 结束,所有上游的收集协程一起被取消,不需要手动一个个处理。对应 flowScope { ... } 那行。
  6. 异常会打断整条链:任一上游抛异常,combine 直接终止。记得把 catch 放在 combine 之后,否则拦不到。
  7. zip 的区别:两者都会合并多个流,但 zip严格按顺序一一配对(第 1 个配第 1 个,第 2 个配第 2 个),任一上游慢下来整体就等着;combine各自取最新值,谁快谁多都能跟上。要「配对」用 zip,要「组合状态」用 combine