Kotlin 协程 (十一) ——— Flow 末端操作符、组合/展平操作符

926 阅读3分钟

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

上一篇文章中,我们学习了 Flow 的过渡操作符 transform()、map(),限长操作符 take()、takeWhile()、drop()、dropWhile()。

这篇文章我们继续学习 Flow 的操作符,包括末端操作符 collect()、reduce() 等,组合/展品操作符 zip()、flatMapConcat() 等。

一、末端操作符

末端操作符指的是放在 Flow 的最后,用于启动 Flow 收集的操作符。

之前的文章中介绍过,Flow 是一种冷流,如果下游不收集,上游就不会发射数据。通常下游都是通过 collect() 函数启动收集,但 Flow 的末端操作符并不是只有 collect()。

1.1. collect()

collect() 是最常用的收集函数,同时,它也是所有末端操作符的基础,其他末端操作符都是在 collect() 的基础上做的封装。

runBlocking {
    flowOf(1, 2, 3).collect {
        println("Collect $it")
    }
}

运行程序,输出如下:

Collect 1
Collect 2
Collect 3

1.2. toList()、toSet()

这两个操作符的含义是将 Flow 收集到的值保存到 List/Set 中:

runBlocking {
    val list = flowOf(1, 2, 3).toList()
    println(list.joinToString())
    val set = flowOf(1, 2, 3).toSet()
    println(set.joinToString())
}

运行程序,输出如下:

1, 2, 3
1, 2, 3

不妨查看一下这两个操作符的源码,toList() 和 toSet() 的源码如下:

public suspend fun <T> Flow<T>.toList(destination: MutableList<T> = ArrayList()): List<T> = toCollection(destination)

public suspend fun <T> Flow<T>.toSet(destination: MutableSet<T> = LinkedHashSet()): Set<T> = toCollection(destination)

两者都调用了 toCollection() 函数,那我们再看一下 toCollection() 的实现:

public suspend fun <T, C : MutableCollection<in T>> Flow<T>.toCollection(destination: C): C {
    collect { value ->
        destination.add(value)
    }
    return destination
}

可以看到,这两个操作符最终就是调用的 collect() 函数,只是把收集到的结果存到了列表或集合中。

1.3. first()、single()

first() 操作符用于取 Flow 中的第一个数,源码如下:

public suspend fun <T> Flow<T>.first(): T {
    var result: Any? = NULL
    collectWhile {
        result = it
        false
    }
    if (result === NULL) throw NoSuchElementException("Expected at least one element")
    return result as T
}

源码中使用了 collectWhile() 函数,取到第一个值后,collectWhile() 就返回了 false,不再继续收集后续的值。

从源码中还可以看出,如果 Flow 中一个值也没有,first() 会抛出 "Expected at least one element" 的异常。

single() 操作符用于获取 Flow 中唯一的值,源码如下:

public suspend fun <T> Flow<T>.single(): T {
    var result: Any? = NULL
    collect { value ->
        require(result === NULL) { "Flow has more than one element" }
        result = value
    }

    if (result === NULL) throw NoSuchElementException("Flow is empty")
    return result as T
}

可以看到,single() 的源码仍然是使用 collect() 函数启动收集,收集到第一个值后,将其记录到 result 变量中并返回。

如果后续还有值,则会抛出 "Flow has more than one element" 异常。如果一个值都没有收集到,则会抛出 "Flow is empty" 异常。

使用示例:

runBlocking {
    val first = flowOf(1, 2, 3).first()
    println(first)
    val single = flowOf(1).single()
    println(single)
}

运行程序,输出如下:

1
1

1.4. reduce()、fold()

reduce 的意思是减少,fold 的意思是折叠。reduce() 函数通常译为归约,fold() 函数通常译为累积。

reduce() 函数的作用是:遍历所有的值,将第一个值与第二个值合并成一个值;这个值再和第三个值合并成一个值,以此类推。

reduce() 函数源码如下:

public suspend fun <S, T : S> Flow<T>.reduce(operation: suspend (accumulator: S, value: T) -> S): S {
    var accumulator: Any? = NULL

    collect { value ->
        accumulator = if (accumulator !== NULL) {
            @Suppress("UNCHECKED_CAST")
            operation(accumulator as S, value)
        } else {
            value
        }
    }

    if (accumulator === NULL) throw NoSuchElementException("Empty flow can't be reduced")
    @Suppress("UNCHECKED_CAST")
    return accumulator as S
}

使用示例:

runBlocking {
    val reduce = flowOf(1, 2, 3).reduce { accumulator, value ->
        println("accumulator: $accumulator, value: $value")
        accumulator + value
    }
    println(reduce)
    val fold = flowOf(1, 2, 3).fold(0) { accumulator, value ->
        accumulator + value
    }
    println(fold)
}

运行程序,输出如下:

accumulator: 1, value: 2
accumulator: 3, value: 3
6

在这个例子中,我们利用 reduce() 函数将 Flow 中的元素累加起来,并返回了所有数值的和。

通俗地讲,reduce() 函数就是遍历所有元素,将其两两合并,最后合并成一个元素。整个过程会让 Flow 的元素不断减少,所以取名为 reduce()。

fold() 函数和 reduce() 函数很相似,不过 fold() 函数需要设定一个初始值:

public suspend inline fun <T, R> Flow<T>.fold(
    initial: R,
    crossinline operation: suspend (acc: R, value: T) -> R
): R {
    var accumulator = initial
    collect { value ->
        accumulator = operation(accumulator, value)
    }
    return accumulator
}

使用示例:

runBlocking {
    val fold = flowOf(1, 2, 3).fold(0) { accumulator, value ->
        println("accumulator: $accumulator, value: $value")
        accumulator + value
    }
    println(fold)
}

运行程序,输出如下:

accumulator: 0, value: 1
accumulator: 1, value: 2
accumulator: 3, value: 3
6

可以看出,fold() 函数比 reduce() 函数多执行了一次初始值和第一个值合并的操作。

细心的读者可能已经注意到了,从源码中可以看出,reduce() 和 fold() 还有一个很大的不同,那就是传入的两个泛型类型。

reduce() 函数传入的泛型类型是 <S, T : S>,这表示第二个泛型必须和第一个泛型类型一样或者第二个泛型是第一个泛型的子类。

而 fold() 函数传入的泛型类型是 <T, R>,两者可以毫无关系。

这意味着 reduce() 函数将 Flow 中的各个元素后,合并后的数据类型必须和原始类型一样或是其子类。

而 fold() 函数可以将 Flow 中的各个元素合并成一个和原始类型毫不相关的数据。

举个例子:

runBlocking {
    val fold = flowOf(1, 2, 3).fold<Int, String>("initial") { accumulator, value ->
        println("accumulator: $accumulator, value: $value")
        "$accumulator $value"
    }
    println(fold)
}

这里我们使用 fold<Int, String>() 函数将一个发射 Int 数据的 Flow 合并成一个 String 数据。

运行程序,输出如下:

accumulator: initial, value: 1
accumulator: initial 1, value: 2
accumulator: initial 1 2, value: 3
initial 1 2 3

这里执行的逻辑是:

  • 先将 "initial" 和 1 结合,组合成 "initial 1"。
  • 再把 "initial 1" 和 2 结合,组合成 "initial 1 2"。
  • 再把 "initial 1 2" 和 3 结合,组合成 "initial 1 2 3"。

所以 fold() 操作符比 reduce() 操作符的适用性更广。

二、组合/展平操作符

组合操作符用于合并多个 Flow。展平操作符恰好相反,用于将一个 Flow 拆分成多个 Flow。

2.1. zip()

zip 译为拉链、压缩。试想一下拉链的工作机制:将两条拉链齿合并成一条拉链。

拉链

zip() 函数的作用也是类似的,用于将两个 Flow 合并成一个 Flow:

runBlocking {
    flowOf("a", "b", "c").zip(flowOf(1, 2, 3)) { a, b ->
        a + b
    }.collect {
        println(it)
    }
}

运行程序,输出如下:

a1
b2
c3

可以看到,zip() 函数将两个 Flow 合并了,合并的方式由 lambda 表达式传入。

有的读者可能会问了,如果两个 Flow 不一样长怎么合并呢?

做个简单的测试就能知道,在此例中,如果只延长第一个 Flow 或只延长第二个 Flow,输出的结果都是一样的。

由此可见,如果两个 Flow 不一样长,合并时将以较短的 Flow 为准。

多次调用 zip() 就能达到合并多个 Flow 的效果。

2.2. flatMapConcat()

flatMap() 是流式编程中常见的函数,包括 RxJava、Kotlin 集合都支持 flatMap() 函数。它的含义是将一列数据中的单个元素取出,每个元素构建出一个流。

flatMapConcat() 和 flatMap() 只有一点区别,我们来看一个例子:

runBlocking {
    flowOf("初中", "高中").flatMapConcat {
        flowOf(it + "一年级", it + "二年级", it + "三年级")
    }.collect {
        println(it)
    }
}

运行程序,输出如下:

初中一年级
初中二年级
初中三年级
高中一年级
高中二年级
高中三年级

在这个例子中,首先构建了一个包含初中高中两个元素的流,然后通过展平操作,用这两个元素各自构建出一个新的流 it + "一年级", it + "二年级", it + "三年级"

将每个元素都变成一个新的流,这就是 flatMap() 的含义。

但我们看到使用 flatMapConcat 之后,并没有返回两个 Flow,而是仍然只有一个 Flow,这是为什么呢?

这是因为构建出两个 Flow 之后,又将其拼接起来了,这就是 concat 的含义,concat 译为连接。

所以总结起来,flatMapConcat() 指的是将 Flow 中的单个元素转换成一个单独的流,然后再将各个流拼接起来组成一个新的流。

2.3. flatMapMerge()

flatMapMerge() 的含义和 flatMapConcat() 是类似的。唯一的区别是:在将单个元素转换成单独的流之后,flatMapMerge() 会将各个流合并起来组成一个新的流。

合并和拼接有什么区别呢?

区别就是合并是并行的,拼接是串行的。我们来看这样一个例子:

runBlocking {
    val startTime = System.currentTimeMillis()
    flow {
        repeat(3) {
            delay(1000)
            println("emit: $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
            emit(it)
        }
    }.flatMapConcat {
        flow {
            delay(5000)
            emit("flatMap $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
        }
    }.collect {
        println("Collect: $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
    }
}

在这个例子中,我们每隔 1s 发射一个数字,然后使用 flatMapConcat() 将发射的数字转换成一个流,这个流会等待 5s 后再将原数据发射出去。并且我们打印了程序运行时间和当前的线程。

运行程序,输出如下:

emit: 0, cost time: 1022ms, thread: Test worker @coroutine#1
Collect: flatMap 0, cost time: 6043ms, thread: Test worker @coroutine#1, cost time: 6043ms, thread: Test worker @coroutine#1
emit: 1, cost time: 7047ms, thread: Test worker @coroutine#1
Collect: flatMap 1, cost time: 12052ms, thread: Test worker @coroutine#1, cost time: 12053ms, thread: Test worker @coroutine#1
emit: 2, cost time: 13057ms, thread: Test worker @coroutine#1
Collect: flatMap 2, cost time: 18062ms, thread: Test worker @coroutine#1, cost time: 18062ms, thread: Test worker @coroutine#1

可以看到,程序总共耗时约 18s,因为每个数字等待 1s 发射,展开后再等待 5s 发射,重复三次,共花费 (1 + 5) * 3 = 18s。

换成 flatMapMerge() 会如何呢?

runBlocking {
    val startTime = System.currentTimeMillis()
    flow {
        repeat(3) {
            delay(1000)
            println("emit: $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
            emit(it)
        }
    }.flatMapMerge {
        flow {
            delay(5000)
            emit("flatMap $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
        }
    }.collect {
        println("Collect: $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
    }
}

运行程序,输出如下:

emit: 0, cost time: 1048ms, thread: Test worker @coroutine#2
emit: 1, cost time: 2065ms, thread: Test worker @coroutine#2
emit: 2, cost time: 3071ms, thread: Test worker @coroutine#2
Collect: flatMap 0, cost time: 6067ms, thread: Test worker @coroutine#3, cost time: 6069ms, thread: Test worker @coroutine#1
Collect: flatMap 1, cost time: 7072ms, thread: Test worker @coroutine#4, cost time: 7073ms, thread: Test worker @coroutine#1
Collect: flatMap 2, cost time: 8078ms, thread: Test worker @coroutine#5, cost time: 8079ms, thread: Test worker @coroutine#1

总共花费约 8s 时间,这是因为 flatMapMerge() 在将每个元素转换成流后,并不是简单的按顺序执行各个流,而是将原数据流和展开后的流按照时间顺序合并起来。

2.4. flatMapLatest()

flatMapLatest() 同样会将每个元素展平成一个流。一旦某个流展平了,那么之前展平的还没有处理完的流就会被取消,只发射最后一个流。这一点和 collectLatest() 有些类似。

我们将上文中的例子换成 flatMapLatest(),并添加一些日志:

runBlocking {
    val startTime = System.currentTimeMillis()
    flow {
        repeat(3) {
            delay(1000)
            println("emit: $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
            emit(it)
        }
    }.flatMapLatest {
        flow {
            try {
                println("Start flatMap $it")
                delay(5000)
                println("emit flatMap $it")
                emit("flatMap $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
            } catch (e: CancellationException) {
                println("flatMap $it cancelled")
            }
        }
    }.collect {
        println("Collect: $it, cost time: ${System.currentTimeMillis() - startTime}ms, thread: ${Thread.currentThread().name}")
    }
}

运行程序,输出如下:

emit: 0, cost time: 1051ms, thread: Test worker @coroutine#2
Start flatMap 0
emit: 1, cost time: 2066ms, thread: Test worker @coroutine#2
flatMap 0 cancelled
Start flatMap 1
emit: 2, cost time: 3097ms, thread: Test worker @coroutine#2
flatMap 1 cancelled
Start flatMap 2
emit flatMap 2
Collect: flatMap 2, cost time: 8104ms, thread: Test worker @coroutine#5, cost time: 8106ms, thread: Test worker @coroutine#1

首先,flatMapLatest() 会将三个元素都展平成一个流。

  • 当第二个元素展平后,在执行前会把尚未执行完的第一个流取消(flatMap 0 cancelled);
  • 当第三个元素展平后,在执行前会把尚未执行完的第二个流取消(flatMap 1 cancelled);

所以本例中,只收集到了最后一个展平后的流发出来的数据。

三、小结

本文我们学习了 Flow 的末端操作符,以及 Flow 的组合/展平操作符。这些操作符在每种响应式编程的语言中都很常见,读者只要掌握了任何一门响应式编程语言,那么学习这些内容应该都非常轻松。