前言
可以用特殊符号来代替函数本身去使用的函数叫operator function,就是操作符函数,而用来替代函数的符号就是所谓的operator,也就是操作符,例如Kotlin中的list[0]等价于list.get(0),而Flow的操作符是另一个概念,它指的是用一个或者多个flow对象来创建出另一个新的Flow对象的函数。这种函数就叫做Flow的操作符。Flow有多达几十个的操作符函数。
filter系列操作符
filter 系列的操作符用于对数据流中的元素进行过滤,只保留满足特定条件的元素。
scope.launch {
flow1.filter { it?.rem(2) == 0 }.collect { println("1: $it") } // 过滤掉奇数
flow1.filterNot { it?.rem(2) == 0 }.collect { println("2: $it") } // 过滤掉偶数
flow1.filterNotNull().filter { it % 2 == 0 }.collect { println("1: $it") } // 去空之后过滤偶数
flow1.filterNotNull().filterNot { it % 2 == 0 }.collect { println("2: $it") }// 去空之后过滤偶数
// 下边这两种写法的区别是:泛型的版本是用的kotlin的关键字reified来实现的,而另一个就是普通的泛型函数
// 第一种写法很简洁,能用它就用它,如果你想指定的哪个类型是在软件运行过程中动态决定的,就使用函数参数的形式,因为<>里边必须是提前写死的类型。
flow2.filterIsInstance<String>().collect { println("3: $it") }// 只剩下String类型的字符串
flow2.filterIsInstance(String::class).collect { println("4: $it") }// 只剩下String类型的字符串
flow2.filterIsInstance<List<String>>().collect { println("3: $it") }
flow2.filterIsInstance(List::class).collect { println("4: $it") }
flow2.filter { it is List<*> && it.firstOrNull()?.let { item -> item is String } == true }
.collect { println("5: $it") }
}
如果你写的泛型类型是例如下边这种数据结构的时候,这种写法是不允许的:
listOf("A", "B"), listOf(1, 2))
// flow2.filterIsInstance(List<String>::class).collect { println("3: $it") }
只能这样写:
flow2.filterIsInstance<List<String>>().collect { println("3: $it") }
这是由于kotlin的语法限制,使用reified关键字就可以破除这个限制。还需要注意,reified只能解决外围<>的类型,多重的<>他就没有办法了。例如这种:<List<String>>,只能解决外围的list,而内部的string是没法处理的。下边这种情况下,只能识别list,而不能区分内部的string或者是Int。
listOf("A", "B"), listOf(1, 2))
如果你真的想过滤到这一层,获取string,你需要使用filter来实现:
flow2.filter { it is List<*> && it.firstOrNull()?.let { item -> item is String } == true }
.collect { println("5: $it") }
distinctUntilChanged与distinctUntilChangedBy
distinctUntilChanged用来去掉连续的重复的数据,例如:
val flow2 = flowOf(1,1,1,2,2,3,4,4,3,3,5,6,5)
flow2.distinctUntilChanged().collect { println("1: $it")
输出的数据是1,2,3,4,3,5,6,5,只能去掉连续重复的,非连续的无法去除。
distinctUntilChangedBy,有一个必填的函数类型参数,可以对类型元素做一些处理:
val flow1 = flowOf("rengwuxian", "RengWuXian", "rengwuxian.com")
flow1.distinctUntilChangedBy { it.uppercase() }.collect { println("3: $it") }
timeout, sample, debounce()
timeout 操作符用于在指定的时间内没有收到下一个值时抛出一个 TimeoutCancellationException,从而中止流的执行。
flow {
emit(1)
delay(1100)
emit(2)
}.timeout(1000)
.catch { e -> println("Timeout occurred: $e") }
.collect { value -> println(value) }
timeout(1000) : 如果流中间的任何操作超过 1000 毫秒未能发出新值,则流将被取消,并且会触发 catch 块来处理超时异常。抛出的是TimeoutCancellationException.
sample 操作符用于周期性地发出最近发射的值。它类似于节流(throttle),每隔指定的时间发出最新的值。
flow {
emit(1)
delay(100)
emit(2)
delay(100)
emit(3)
delay(500)
emit(4)
}.sample(300)
.collect { value -> println(value) }
sample(300) : 每 300 毫秒采样并发出最新的值,就是只在到达300ms这个节点的时候才会发送数据。而不会在flow结束的时候顺便发到下游去,最后发送的值如果不在sample节点上,会被直接丢弃。在上面的示例中,虽然发出了多个值,但只有在 300 毫秒时,最近的那个值(例如,3)会被发出。4会被丢弃,因为发送4的时候是在700ms时,发完flow就结束了,而此时的sample还未采集数据,因此就被丢弃了。
debounce 操作符用于在指定的时间窗口内只发射最后一个值。与sample的区别是,每次到来一个新的值,都会重新开始计时。因此,如果一直都有新的数据到来,会导致debounce一直无法发送数据,它通常用于防止短时间内的快速、连续事件处理,确保只有在事件平息(静止)后才处理最新的一个事件。
flow {
emit(1)
delay(100)
emit(2)
delay(100)
emit(3)
delay(500)
emit(4)
}.debounce(300)
.collect { value -> println(value) }
debounce(300) : 每次发射值时都会延迟 300 毫秒。如果在这段时间内没有新值发出,则发射该值;否则,将重新计算延迟时间。在上面的示例中,1, 2, 3 都会被跳过,而 4 会被发出,因为它之后没有新的值在 300 毫秒内发出。
适用场景:
timeout: 适用于需要防止流无限期等待的场景,如网络请求或长时间处理任务时。sample: 适用于需要定期获取流中的最新数据,定时刷新,而不需要处理每一个事件的场景,如实时监控数据或快速变化的 UI 更新。debounce: 适用于需要在事件停滞后处理最后一个事件的场景,如搜索框输入提示等。按钮点击去抖动(也就是连续点击)不适合用该方式,因为我们需要在第一次点击事件的时候就去触发点击逻辑。不能让用户有延迟体验。Flow中想要实现重复点击的可以用下边这种方式:
fun <T> Flow<T>.throttle(timeWindow: Duration): Flow<T> = flow {
var lastTime = 0L
collect {
// 核心,超时才触发另一次事件。
if (System.currentTimeMillis() - lastTime > timeWindow.inWholeMilliseconds) {
emit(it)
lastTime = System.currentTimeMillis()
}
}
}
drop()/dropWhile()/take()/takeWhile()
drop() 操作符用于跳过流中的前 n 个元素,之后的元素会正常发射。
flowOf(1, 2, 3, 4, 5)
.drop(2)
.collect { value -> println(value) }
解释:drop(2) : 跳过前两个元素 1 和 2,然后开始收集 3、4 和 5。
dropWhile() 操作符用于根据给定的条件跳过流中的元素,直到条件不再满足时开始发射元素。
flowOf(1, 2, 3, 4, 5, 3, 3)
.dropWhile { it < 3 }
.collect { value -> println(value) }
解释:dropWhile { it < 3 } : 在元素小于 3 时继续跳过,直到遇到 3 开始发射,之后的元素 4 和 5 以及重复的3也会被发射。
如果是类似下边这种的,在元素不等于 3 时继续跳过,直到遇到 3 开始发射,之后的元素 4 和 5 以及重复的3也会被发射,因为这个判断条件只执行一次:
flowOf(1, 2, 3, 4, 5, 3, 3)
.dropWhile { it!= 3 }
.collect { value -> println(value) }
take() 操作符用于只获取流中的前 n 个元素,之后的元素将被忽略。
flowOf(1, 2, 3, 4, 5)
.take(3)
.collect { value -> println(value) }
解释:take(3) : 只收集前 3 个元素,之后的 4 和 5 会被忽略。
takeWhile() 操作符根据给定的条件收集元素,一旦条件不再满足,流就会终止发射。
flowOf(1, 2, 3, 4, 5)
.takeWhile { it < 4 }
.collect { value -> println(value) }
解释:takeWhile { it < 4 } : 只要元素小于 4,就会被收集。一旦遇到不满足条件的元素(如 4),流就会停止。
如果是类似下边这种的,在元素不等于 3 时继续跳过,直到遇到 3 开始发射,之后的元素 4 和 5 以及重复的1和2也不会被发射,因为这个判断条件只执行一次:
flowOf(1, 2, 3, 4, 5, 1, 2)
.takeWhile { it != 3 }
.collect { value -> println(value) }
总结:
drop(n): 跳过前n个元素。dropWhile { condition }: 根据条件跳过元素,直到条件不再满足,后边全部接收。take(n): 只收集前n个元素。takeWhile { condition }: 根据条件收集元素,直到条件不再满足,后边全部抛弃。
map()/mapNotNull()/mapLatest()
map() 操作符用于将流中的每个元素应用指定的转换函数,返回一个新的流,其中每个元素都被转换为新的值。map内部的实现其实也用的是transform。
flowOf(1, 2, 3, 4, 5)
.map { it * 2 }
.collect { value -> println(value) }
// 输出:
2
4
6
8
10
解释: map { it * 2 } 将流中的每个元素都乘以 2,生成一个新的流并收集结果。
mapNotNull() 操作符与 map() 类似,但它会忽略转换函数返回 null 的元素。只有返回非 null 的元素才会进入最终的流。
flowOf(1, 2, 3, null, 5)
.mapNotNull { it?.let { it * 2 } }
.collect { value -> println(value) }
// 输出:
2
4
6
10
解释:mapNotNull { it?.let { it * 2 } } 会将非 null 的元素乘以 2,并忽略流中的 null 值。
mapLatest() 操作符用于将流中的元素应用转换函数,并且在处理最新的元素时,如果在转换过程中流发出了新的元素,先前的转换操作将被取消。这对于处理需要处理时间的操作非常有用,例如网络请求。
flow {
emit(1)
delay(100)
emit(2)
delay(100)
emit(3)
}.mapLatest { value ->
println("Processing $value")
delay(150) // 模拟耗时操作
"Result $value"
}.collect { result ->
println("Collected $result")
}
// 输出:
Processing 1
Processing 2
Processing 3
Collected Result 3
解释:mapLatest { value -> ... } 在处理 1 时,流发出了 2,因此取消了对 1 的处理。处理 2 时,同样被 3 取消。最终只有 3 的处理结果被收集。
总结
map():将流中的每个元素转换为新的值。mapNotNull():将流中的每个元素转换为新的值,并忽略null值,等价于map+fillterNotNull, 也等价于filter + map,一般mapNotNull取代的是后一种写法mapLatest():将流中的每个元素转换为新的值,如果在处理过程中有新的元素发出,则取消先前的处理,仅处理最新的元素,也比较适合做搜索提示。map()``mapNotNull()``mapLatest()还是有区别的,前两者是同步的,也就是上游给下来的数据,需要处理完了,往下游发送了,上游的下一条数据才开始生产。而mapLatest()没有这个限制,当处理当前数据的时候,上游依然可以生产下一条数据。并且如果上游生产的下一条数据到来了,会直接处理这条上游给的新数据,怎么做到的?用的是Channel。
transform系列操作符
可以理解为一种更加底层的map。但是它的返回值是unit。transform 操作符允许你将输入流的每个值转换为一个或多个值,可以是同一类型,也可以是不同的类型。它不同于一些更简单的操作符(如 map 或 filter),因为它不仅限于对输入元素进行一对一的转换,还可以:
- 发射多个值。
- 条件性地发射值。
- 执行异步代码。
- 发射不同类型的值。
transform
使用示例:
flowOf(1, 2, 3, 4)
.transform { value ->
emit(value * 2) // 发射转换后的值
emit(value + 1) // 发射另一个转换后的值
}
.collect { println(it) }
// 输出:
2
2
4
3
6
4
8
5
在这个示例中,transform 操作符对流中的每个整数执行了两次不同的转换,并依次发射了这些结果。
transform 的优势
- 灵活性:可以用来处理复杂的转换逻辑,而不仅限于简单的映射或过滤,还可以不发送数据。
- 异步支持:可以在
transform块中调用suspend函数,轻松处理异步任务。 - 多种数据发射:允许根据条件或其他逻辑发射多个数据,这比
map和filter更强大。
总结
transform 操作符非常适合那些需要灵活处理流数据的场景,尤其是当你需要执行复杂逻辑、异步操作或在流的处理过程中发射多个值时。
transforWhile
transformWhile 是一个操作符,常用于流式编程中(如 Kotlin 的 Flow API),它类似于 transform 操作符,但有一个重要的区别:它的函数参数是有返回值的,这也让transformWhile具备了根据指定的条件来决定何时停止处理流中的元素的能力
transformWhile 操作符在遇到某个特定条件为 false 时就会停止对流数据的处理。换句话说,它会持续地应用转换逻辑并发射元素,直到条件不再满足为止。这使它成为一种有用的操作符,可以用来截断流,类似于 takeWhile 操作符,但它提供了更强大的数据转换能力。因此,我们可以认为transforWhile就是transform+takeWhile的结合。
使用示例:
flowOf(1, 2, 3, 4, 5, 6)
.transformWhile { value ->
emit(value * 2) // 将每个值乘以2并发射
value < 4 // 只要值小于4,就继续发射
}
.collect { println(it) }
// 输出结果
2
4
6
8
在这个例子中,transformWhile 会对流的每个元素进行转换(乘以 2),并发射结果,只要 value < 4 的条件为真。一旦流中的值不再满足该条件(如 5 不小于 4),操作符就会停止处理流的后续元素。
transformWhile 的优势
- 条件控制:它允许你在流的处理中设置停止条件,增强了流的灵活性和可控性。
- 更强的转换能力:不仅可以控制流的终止,还可以同时发射多个值或执行复杂的转换逻辑。
- 效率:减少了不必要的数据处理和发射,提高了性能。
transformWhile vs. 其他操作符
- vs.
takeWhile:takeWhile只控制流的长度,而transformWhile可以在控制流长度的同时,执行更复杂的转换逻辑。 - vs.
transform:transform可以处理整个流的所有元素,而transformWhile会根据条件提前停止处理。
总结
transformWhile 是一个强大的操作符,特别适合那些需要对流数据进行条件性截断并同时执行复杂转换的场景。它结合了 takeWhile 的条件控制和 transform 的灵活转换能力,非常适用于需要动态流控制的场景。
transformLatest
transformLatest 操作符的主要作用是:在流中发射一个新元素时,它会取消对之前元素的处理,并开始处理最新的元素。这意味着,如果在处理一个元素的过程中又发射了一个新元素,transformLatest 会立即停止之前的处理逻辑,转而处理最新的元素。
使用示例
假设你有一个流,每隔 500 毫秒发射一个整数,并且你希望对每个整数执行一些长时间运行的异步操作(例如每个操作需要 1000 毫秒)。使用 transformLatest 操作符,你可以确保每次只处理最新的元素,而不是等待之前所有的操作完成。
flow {
emit(1)
delay(500)
emit(2)
delay(500)
emit(3)
}
.transformLatest { value ->
println("Processing $value")
delay(1000) // 模拟长时间运行的操作
println("Completed $value")
}
.collect()
// 输出
Processing 1
Processing 2
Processing 3
Completed 3
解释:
- 当
1被发射时,开始处理1。 - 500 毫秒后,
2被发射,由于transformLatest操作符会取消对1的处理,因此立即停止对1的处理并开始处理2。 - 再过 500 毫秒,
3被发射,同样,transformLatest会取消对2的处理,立即转而处理3。 - 最后,只会完成对最新的
3的处理。
transformLatest 的优势
- 只处理最新数据:适用于只关心最新数据的场景,例如实时更新或监控系统。
- 高效的资源利用:减少不必要的计算,避免了对旧数据的冗余处理。
- 异步支持:在处理过程中可以使用
suspend函数,支持异步操作。
transformLatest vs. 其他操作符
- vs.
mapLatest:mapLatest也是只处理最新数据的操作符,但它通常用于一对一的简单映射。transformLatest更加灵活,支持更复杂的转换和多次发射。 - vs.
flatMapLatest:flatMapLatest将流转换为另一个流,并仅保留最新的转换结果。而transformLatest提供了更细粒度的控制,可以在流处理中发射多个值、执行异步操作等。
总结
transformLatest 是一种灵活且高效的操作符,适用于需要处理最新数据的场景。在涉及异步操作、实时更新或需要取消先前操作的情况下,transformLatest 是一个非常有用的工具。它让你的程序始终保持响应最新的数据变化,避免对过时数据的无用处理。
withIndex
withIndex 是一个扩展函数,用于将 Flow 中的每个元素与其对应的索引进行配对。它将每个元素转换为一个 IndexedValue 对象,包含两个属性:index 和 value。使用 withIndex,可以在 Flow 的数据处理过程中同时获取每个元素的索引和内容,这在处理需要知道数据位置的流操作时非常有用。
使用示例:
假设有一个 Flow,它发射一系列的字符串,我们希望在处理每个字符串时知道它在流中的索引。可以使用 withIndex 操作符来实现。
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val flow = flowOf("A", "B", "C")
.withIndex() // 为每个元素附加索引
// 用于中间过程操作flow的。
flow.collect { indexedValue ->
println("Index: ${indexedValue.index}, Value: ${indexedValue.value}")
}
// 也可以像下边这么写。
flow.collect { (index, data) ->
println("Index: ${index}, Value: ${data}")
}
// collectIndexed 用于收集数据的时候
flow1.collectIndexed { index, value ->
println("1: $index - $value")
}
}
// 输出
Index: 0, Value: A
Index: 1, Value: B
Index: 2, Value: C
解释:
withIndex将流中的每个元素转化为一个IndexedValue对象,其中包含了元素的索引 (index) 和元素的值 (value)。- 使用
collect收集流数据并输出每个元素的索引和值。
withIndex 的优势
- 索引访问:方便地获取流中每个元素的索引,适合需要跟踪位置的流处理。
- 简单易用:直接将元素与索引关联,而不需要手动计算或维护索引。
- 流式处理:与其他
Flow操作符兼容,能在流式处理数据时保持非阻塞性和响应性。
总结
withIndex在 Flow 中可以为每个流元素附加一个索引。它提供了一种简便的方法来访问流中元素的位置,适用于需要位置感知的流处理任务。
withIndex与collectIndexed如何选择?
一个在中间过程中使用,一个在收集时使用。
- 使用
withIndex: 当需要在多个流操作(如map、filter等)中访问元素的索引时,withIndex提供了更大的灵活性。 - 使用
collectIndexed: 当只在收集数据时需要索引,并且不需要额外的中间操作时,collectIndexed是一个简单明了的选择。
reduce/runningReduce/flod/runningFold系列操作符
操作符概述
在集合中,例如list也有这样的操作符,我们今天讲的是flow版本的reduce。
reduce:对流中的所有元素进行聚合操作,最终返回一个单一值,是一个挂起函数,因为启动了collect收集流程,collect函数必须要在协程中运行。fold:类似于reduce,但提供一个初始值来开始累积操作。runningReduce:逐步对流中的元素执行聚合操作,并在每一步返回当前的聚合结果,不是挂起函数,不需要启动收集过程,返回的结果是flow,每一个计算后的数据都会作为一条数据发送。runningFold:类似于runningReduce,但提供一个初始值,并在每一步返回当前的聚合结果。
reduce
reduce 操作符对流中的元素应用一个累积函数,返回一个最终的累积值,不是返回flow对象,它的调用会触发collect流程。reduce 需要流中至少有一个元素,否则会抛出异常。
语法:
flow.reduce { accumulator, value ->
accumulator + value
}
-
accumulator:累加器,保存中间累积结果。 -
value:当前流元素。 使用示例:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val sum = flowOf(1, 2, 3, 4)
.reduce { acc, value -> acc + value }
println(sum) // 输出: 10
}
解释:
reduce将流中的所有元素相加,返回最终的和(10)。
fold
fold 操作符类似于 reduce,但它接受一个初始值,用于在开始时提供一个累积的初始状态,这样即使流为空,也能返回初始值。
语法:
flow.fold(initial) { accumulator, value ->
accumulator + value
}
-
initial:初始值。 -
accumulator:累加器,保存中间累积结果。 -
value:当前流元素。
使用示例:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val sum = flowOf(1, 2, 3, 4)
.fold(10) { acc, value -> acc + value }
println(sum) // 输出: 20
}
解释:
fold以10为初始值,将流中的所有元素相加,返回累积结果(20)。
fold还可以这样使用,它的返回值和初始值是一样的,这也是玉reduce的另一个区别,不仅可以提供一个初始值,而且还可以通过初始值的类型控制最终返回的类型。
list.fold("ha") { acc, i -> "$acc - $i" }.let { println("List folded to string: $it") }
runningReduce
runningReduce 操作符类似于 reduce,但它会在每个步骤输出当前的累积结果,生成一个流,而不是一个最终值。这使得你可以在流的每个阶段看到累积的进展。
语法:
flow.runningReduce { accumulator, value ->
accumulator + value
}
-
accumulator:累加器,保存中间累积结果。 -
value:当前流元素。
使用示例:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
flowOf(1, 2, 3, 4)
.runningReduce { acc, value -> acc + value }
.collect { println(it) }
}
// 输出
1
3
6
10
解释:runningReduce 逐步累积流的元素,输出每一步的累积和。
runningFold
runningFold 操作符类似于 runningReduce,但它接受一个初始值,并在每个步骤返回当前的累积结果。它生成一个包含每一步累积结果的流。有一个方便的别名,叫做scan
语法:
flow.runningFold(initial) { accumulator, value ->
accumulator + value
}
使用示例:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
flowOf(1, 2, 3, 4)
.runningFold(10) { acc, value -> acc + value }
.collect { println(it) }
// scan与runningFold等价
flowOf(1, 2, 3, 4)
.scan(10) { acc, value -> acc + value }
.collect { println(it) }
}
// 输出结果
10
11
13
16
20
解释:runningFold 以 10 为初始值,每一步累积一个新的和,并输出当前累积状态。
区别总结
| 操作符 | 描述 | 适用场景 |
|---|---|---|
reduce | 对流中的所有元素执行累积操作,返回一个单一值。 | 需要最终的累积结果;流中至少有一个元素。 |
fold | 类似于 reduce,但有一个初始值来开始累积操作。初始值类型不一定是给定的集合的元素类型 | 需要最终的累积结果,并且希望指定一个初始值;即使流为空也可以返回结果。 |
runningReduce | 对流中的元素逐步执行累积操作,返回每一步的结果流。 | 需要每一步的累积结果,而不仅仅是最终结果;希望观察累积进展或中间结果。 |
runningFold | 类似于 runningReduce,但有一个初始值并返回每一步的结果流。 | 需要每一步的累积结果,并希望指定一个初始值;在整个累积过程中需要输出中间状态。 |
选择使用的建议
- 使用
reduce:当只需要一个最终的累积结果时。 - 使用
fold:当需要一个最终结果并有一个初始值时。 - 使用
runningReduce:当需要在流式处理过程中看到累积的进展或中间结果时。 - 使用
runningFold:当需要一个累积初始值,并希望看到流处理过程中的每一步结果时。
onEach
onEach 是在 Kotlin 的 Flow 中提供的一个中间操作符,用来在流的数据项被发射时执行一个指定的动作。这个操作符通常用于在不改变流中数据的情况下执行副作用,比如记录日志、调试信息或者更新 UI 等,这个操作符可以调用多次,调用几次就处理几次。
使用方式:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val flow = flowOf(1, 2, 3, 4, 5)
.onEach { value ->
println("Received: $value")
}
.map { value ->
value * 2
}
flow.collect { value ->
println("Processed: $value")
}
}
解释:
flowOf(1, 2, 3, 4, 5): 创建一个发射从 1 到 5 的流。onEach { value -> println("Received: $value") }: 在每个数据项被发射时打印 "Received: x",其中x是该项的值。map { value -> value * 2 }: 将每个数据项乘以 2。collect { value -> println("Processed: $value") }: 收集流中的数据项,并打印 "Processed: x",其中x是处理后的值。
关键点:
- 副作用操作:
onEach不改变流中的数据项,而是用于处理副作用。 - 顺序执行:
onEach执行的动作与数据的发射顺序一致。
常见用途:
- 日志记录: 可以在数据流动时记录日志,帮助调试。
- UI 更新: 在收集之前,进行一些 UI 的更新或通知操作。
- 数据验证: 可以在不改变流的情况下验证数据的正确性。
chunked()
Flow.chunked(size: Int) 是一个实验性功能,允许将流中的元素按块进行分组,每块包含指定数量的元素。这个函数返回一个新的 Flow,其中每个元素都是一个列表,包含原始流中按照指定大小分组的元素。chunked 操作符的 size 参数指定了每个块的大小。如果流的元素个数不能被 size 整除,那么最后一个块将包含剩余的所有元素。
使用示例:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
fun main() = runBlocking {
val flow = flowOf(1, 2, 3, 4, 5, 6, 7, 8, 9)
flow.chunked(3).collect { chunk ->
println("Received chunk: $chunk")
}
}
解释:
flowOf(1, 2, 3, 4, 5, 6, 7, 8, 9): 创建一个发射从 1 到 9 的流。chunked(3): 将流中的元素按 3 个为一组进行分块。最终生成的流中的每个元素是一个包含 3 个元素的列表(或子列表)。collect: 收集流中的每个块并打印。
try/catch和Flow的异常可见性
异常管理管理的都是已知异常,而不是未知异常,当然也有管理未知异常的方式,比如 UncaughtExceptionHandler 拦截所有的线程和协程的异常。已知的异常被正确的处理了,就是正常。
以一个简单的示例来说明flow的异常处理:
val flow1 = flow {
try {
for (i in 1..5) {
// 数据库读数据(可能报错)
// 网络请求(可能报错)
emit(i)
}
} catch (e: Exception) {
println("Error in flow(): $e") // 这个代码会导致collect的外边的try-catch无效。
throw e
}
}
// Exception Transparency
scope.launch {
try {
/*
flow1.collect(object : FlowCollector<Int>{
override suspend fun emit(value: Int) {
val contributors = unstableGitHub.contributors("square", "retrofit")
println("Contributors: $contributors")
}
})
*/
// 上边的代码才是完整的写法,flow1的emit()函数其实就是这个大括号的代码。collect其实只是启动了生产过程。
// 对flow1执行collect,其实就是执行flow {}大括号里边的代码。
// 当emit函数执行的时候(也就是每条数据发送的时候),其实就是collect {}执行的时候。
// 这也就是为什么flow1.collect {}外层的tray-catch无法捕获异常了,因为flow { try { 已经捕获异常了。
// 这种写法会导致:由于collect函数被包裹住了,所以除了上游数据生产的异常之外,还把下游的数据消费也拦截了,
// 这个在业务逻辑是不符合预期的。因为一般的,上游的生产者并不关心下游的消费异常。那么如何处理这种情况呢?
flow1.collect {
val contributors = unstableGitHub.contributors("square", "retrofit")
println("Contributors: $contributors")
}
} catch (e: TimeoutException) {
println("Network error: $e")
} catch (e: NullPointerException) {
println("Null data: $e")
}
flow1的emit()函数其实就是这个大括号的代码。collect其实只是启动了生产过程。 对flow1执行collect,其实就是执行flow {}大括号里边的代码。 当emit函数执行的时候(也就是每条数据发送的时候),其实就是collect {}执行的时候。 这也就是为什么flow1.collect {}外层的tray-catch无法捕获异常了,因为flow { try { 已经捕获异常了。这种写法会导致:由于collect函数被包裹住了,所以除了上游数据生产的异常之外,还把下游的数据消费也拦截了,这个在业务逻辑是不符合预期的。因为一般的,上游的生产者并不关心下游的消费异常。那么如何处理这种情况呢?
可以这样修改:
val flow1 = flow {
for (i in 1..5) {
try {
// 数据库读数据(可能报错)
// 网络请求(可能报错)
} catch (e: Exception) {
println("Error in flow(): $e") // 这个代码会导致collect的外边的try-catch无效。
}
emit(i)
}
}
让emit在try-catch外边执行。
或者直接将异常抛出:
val flow1 = flow {
try {
for (i in 1..5) {
// 数据库读数据(可能报错)
// 网络请求(可能报错)
emit(i)
}
} catch (e: Exception) {
println("Error in flow(): $e") // 这个代码会导致collect的外边的try-catch无效。
throw e
}
}
不要在Flow里边try-catch,它说的其实就是不要用try-catch把emit包裹住,并不是不能使用try-catch了。要保证异常的可见性。
我们再上边的基础上延伸一下,如果我们添加了一个map操作符,那么(1)下游大括号里边抛出的异常会经过map的代码块吗?(2)另外一个就是map中抛出的异常,这个异常会走向哪里?
val flow1 = flow {
try {
for (i in 1..5) {
// 数据库读数据(可能报错)
// 网络请求(可能报错)
emit(i)
}
} catch (e: Exception) {
println("Error in flow(): $e") // 这个代码会导致collect的外边的try-catch无效。
throw e
}
}.map { throw NullPointerException() }
.onEach { throw NullPointerException() }
.transform<Int, Int> {
val data = it * 2
emit(data)
emit(data)
}
map里边抛出的异常也会被上游的emit外边的try-catch拦截,上游是下游的数据来源,对于大部分操作符,当下游抛出异常并未未处理的时候,都会经过上游的emit,进而被上游的emit处理。之所以说是大部分,因为有一些操作符并不是面向的数据生产和消发送过程。因为自然就不会被拦截了。比如onStart()操作符。他在flow数据刚开始数据收集的时候就开始执行代码块了,所以它的异常不会被上层的emit拦截。以及下游的异常在往外抛的时候,并不会经过它的代码块。不过transform是个例外,因为他需要主动调用emit来发送数据。所以关键就在于emit函数是否是可见的,可见的我们就可以观察到异常,不可见的,就无法观察异常。
总结
- 下游的异常往外抛的时候,会依次经过每一个emit调用,所以为了不让异常被吞掉,不应该用任何try-catch包住任何一个emit,包住了就需要抛出来。
- 不只是最下边的collect的异常代码会被拦截,中间操作符的异常代码块也是会被拦截的。
catch()操作符
catch 操作符用于捕获上游 Flow 中发生的异常,并对异常进行处理。catch 操作符允许你在 Flow 中安全地处理异常,而不会终止 Flow 的收集过程。
假如有类似于下边的这个代码:
val flow1 = flow {
try {
for (i in 1..5) {
// 数据库读数据(可能报错)
// 网络请求(可能报错)
emit(i)
}
} catch (e: Exception) {
println("Error in flow(): $e") // 这个代码会导致collect的外边的try-catch无效。
throw e
}
}
catch操作符只是将emit的异常过滤出来了,另外,它还有个特性,就是不会捕获CancellationException.
catch 操作符的基本示例:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val flow = flow {
emit(1)
emit(2)
throw RuntimeException("Something went wrong")
emit(3)
}
flow.catch { e ->
println("Caught exception: $e")
emit(-1) // 处理完异常后可以选择再emit一些数据
}
.collect { value ->
println(value)
}
}
解释:
-
Flow 的创建:
flow块中定义了一个简单的 Flow,它依次发射1,2,然后抛出一个异常。 -
catch 操作符: 当 Flow 中的上游操作抛出异常时,
catch会捕获该异常。在catch块中,你可以对异常进行处理(例如记录日志、发射一个新的值或者忽略异常继续处理)。 -
异常处理: 在
catch块中,我们捕获了RuntimeException,并打印出异常信息,同时发射一个-1表示错误状态。 -
继续收集: 即使 Flow 中发生了异常,只要在
catch中处理了异常,Flow 的收集过程仍然可以继续。
注意事项:
-
catch 应在 collect 之前使用:
catch操作符只能捕获它之前的 Flow 操作符中的异常。因此,catch必须在collect之前使用,且在 Flow 中间操作之后使用。若在collect之后使用,则无法捕获collect中的异常。 -
rethrow 异常: 如果你希望在处理后继续抛出异常,可以使用
throw重新抛出:
catch { e ->
println("Caught exception: $e")
throw e // 重新抛出异常
}
总结
catch 操作符在处理流中的异常时不会中断整个流的收集过程。它使得在处理复杂流的场景中异常处理更加灵活和可控。它与try-catch的区别是,catch是在flow的后边工作的,而try-catch是在flow内部工作的。因此,对于catch操作符,如果error到达了catch里边,这个时候其实Flow已经死掉了。所以我们选择的时候,能选try-catch的就尽量用try-catch。当我们无法从Flow的内部修改它的流程的时候,这个时候只能使用catch来处理异常流程了。
fun main() = runBlocking<Unit> {
val scope = CoroutineScope(EmptyCoroutineContext)
val flow1 = flow {
for (i in 1..5) {
// 数据库读数据
// 网络请求
if (i == 3) {
throw RuntimeException("flow() error")
} else {
emit(i)
}
}
}.catch {
println("catch(): $it")
emit(100)
emit(200)
emit(300)
// throw RuntimeException("Exception from catch()")
}/*.onEach { throw RuntimeException("Exception from onEach()") }
.catch { println("catch() 2: $it") }*/
scope.launch {
try {
flow1.collect {
/*val contributors = unstableGitHub.contributors("square", "retrofit")
println("Contributors: $contributors")*/
println("Data: $it")
}
} catch (e: TimeoutException) {
println("Network error: $e")
}
}
delay(10000)
}
retry()和retryWhen()操作符
retry\retryWhen 与 catch的区别是,catch是接管了后续的数据发送,而retry\retryWhen则是重启了整个上游,或者选择在某些条件下不重启上游,而是让异常继续传递下去。
retry 操作符
retry 操作符用于在发生异常时简单地重试直到它为止的Flow链条,它后边的它管不了。可以指定重试的次数,以及可选的条件。
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val flow = flow {
emit(1)
emit(2)
throw RuntimeException("Something went wrong")
}
flow.retry(3) { e ->
// Retry up to 3 times if the exception is RuntimeException
println("Retrying due to $e")
e is RuntimeException
}
.catch { e ->
println("Caught exception: $e")
}
.collect { value ->
println(value)
}
}
// 带map的flow
val flow1 = flow {
for (i in 1..5) {
// 数据库读数据
// 网络请求
if (i == 3) {
throw RuntimeException("flow() error")
} else {
emit(i)
}
}
}.map { it * 2 }.retry(3) { // 这里的retry针对的是map,而map又会向上启动它的Flow
it is RuntimeException
}
解释:
retry(3): 表示最多重试 3 次。- 条件判断: 你可以在
retry的 lambda 中指定一个条件,只有当条件为true时才会进行重试。在示例中,只有在异常为RuntimeException时才会重试。
retryWhen 操作符
retryWhen 允许基于异常类型以及重试的次数、延迟等信息来决定是否重试。可以在 retryWhen 中进行更多的逻辑处理,比如动态调整重试间隔时间等。
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val flow = flow {
emit(1)
emit(2)
throw RuntimeException("Something went wrong")
}
flow
.retryWhen { cause, attempt ->
if (cause is RuntimeException && attempt < 3) {
println("Retrying... Attempt: $attempt due to $cause")
delay(1000) // 延迟 1 秒后重试
true // 返回 true 表示继续重试
} else {
false // 返回 false 表示不再重试
}
}
.catch { e ->
println("Caught exception: $e")
}
.collect { value ->
println(value)
}
}
解释:
-
retryWhen的参数:cause: 表示发生的异常。attempt: 表示当前是第几次重试(从 0 开始)。
-
逻辑处理: 可以在
retryWhen中处理多种情况,比如根据异常类型或重试次数来决定是否继续重试。在这个例子中,当异常是RuntimeException且重试次数少于 3 次时,会延迟 1 秒钟后继续重试。
总结
retry: 简单地基于异常类型或特定条件进行重试,适用于无需复杂逻辑的场景。retryWhen: 提供了更高的灵活性,允许根据重试次数、异常类型等动态决定是否继续重试以及何时重试,适用于需要复杂重试逻辑的场景。
onStart()/onCompletion()/onEmpty()全流程监听系列操作符
前边讲的retry和catch其实也是对于Flow的全流程的监听,只不过他们监听的是Flow的异常结束,而onStart()/onCompletion()/onEmpty()则是针对Flow的启动或结束的监听。
onStart:
onStart 用于在 Flow 开始收集之前执行某些操作。它通常用于初始化操作、发射初始值或日志记录。使用场景:
- 在 Flow 开始收集之前执行操作:
onStart会在 Flow 开始发射元素之前被调用,因此它适合用于执行一些在 Flow 收集之前的初始化操作。 - 发射初始值:可以在
onStart中发射一些初始值,这些值会在 Flow 的其他元素之前被收集到。 - 日志记录和调试:
onStart可用于记录日志或调试信息,以便跟踪 Flow 的开始状态。
onCompletion
onCompletion 用于在 Flow 完成收集后执行操作。它无论是正常完成还是因为异常终止,都会被调用。可以根据 cause 参数来区分是正常结束还是异常结束。可以在以下场景中使用:
-
清理操作:在 Flow 结束时执行清理工作,例如关闭资源。
-
日志记录:记录 Flow 结束的日志,或根据
cause判断是正常结束还是异常结束。
onEmpty 操作符
onEmpty 用于在 Flow 没有发射任何值(即空数据流)的情况下执行操作,也就是没有调用过emit。它在收集器未接收到任何值时触发,可以在它的代码块里边用于发射默认值或执行一些处理逻辑。
我们上边提到过,trycatch包裹住emit的时候,是可以获取到下游的处理异常的,这是因为emit干的事情就是把数据发送到下游去,所以try-catch捕获的实际上是下游处理过程中的异常。onStart开始干活的时候数据还没有开始生产。因此try-catch即便包裹了emit,也无法捕获下游的onStart的异常。但是catch操作符是可以捕获的。catch捕获的是整个flow里边的异常,只要他在上游就能捕获,不管是在生产过程中还是在生产过程前后的异常。
使用示例:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val flow = flow {
emit(1)
emit(2)
emit(3)
}
flow.onStart {
println("Flow is starting1")
emit(0) // 在Flow开始时发射一个初始值
}
.onCompletion {// 监听flow的结束,也就是所有的数据都发送完成了,无论是正常结束还是异常结束,正常结束为空,异常结束为异常原因,但是它不会拦截异常,而是会把异常继续往下抛。这也是它与catch的两个区别。
if (cause == null) {
println("Flow completed successfully")
} else {
println("Flow completed with exception: $cause")
}
}
.Empty {// 监听flow的正常结束,不能是异常结束,并且没有发送一条数据的时候触发。
println("Empty error: $it")
emit(-1)// 在空流的情况下发射一个默认值或执行其他处理逻辑。
}
.onStart {
println("Flow is starting2") // 这一行会先于第一行打印。
emit(0) // 在Flow开始时发射一个初始值
Throw RuntimeException("onStart error") // 这一个异常可以被catch操作符捕获。
}
.catch {println("catch: $it")}
.collect { value ->
println("Collected value: $value")
}
}
// 输出:
Flow is starting
Collected value: 0
Collected value: 1
Collected value: 2
Collected value: 3
解释:
onStart操作符:在 Flow 开始发射元素之前,onStart会被调用。在这个示例中,它首先打印了一条日志,然后发射了一个初始值0。- 初始值的发射:
emit(0)在onStart中发射的值会首先被收集,之后才是 Flow 正常发射的1, 2, 3。
常见使用场景:
- 发射初始值:在开始收集 Flow 之前,如果需要发射一些默认值或加载状态,以确保收集器能够处理这些状态。
- 初始化操作:在数据流开始之前,如果需要执行一些初始化操作,例如打开资源、启动计时器、设置变量等。
- 日志记录:可以使用
onStart来记录日志,以便更好地调试和监控 Flow 的行为。
注意事项:
onStart只会在 Flow 开始收集时触发一次,无论 Flow 后续会发射多少个值。onStart操作符中的操作会在 Flow 的上游操作符之前执行,这意味着如果上游操作符中存在一些副作用,onStart的操作会先于这些副作用执行。
总结
三个操作符在 Flow 的生命周期中提供了不同的切入点,可以让我们实现 Flow 的不同阶段执行特定的操作。onStart 在 Flow 开始时触发,onCompletion 在 Flow 完成时(无论是正常结束还是异常结束)触发,onEmpty 则在 Flow 没有发射任何值时(不能是异常结束,否则不触发)触发。
flowOn 操作符
flowOn 操作符改变的是在它之前定义的 Flow 的执行上下文(这点和catch很类似,因为catch也只是捕获它之前的异常)。也就是CoroutineContext的,一般用来切换线程, 它允许我们指定一个不同的调度器(例如,Dispatchers.IO、Dispatchers.Default、Dispatchers.Main 等)来运行 Flow 的上游操作,其他的CoroutineContext,比如coroutineName,他也可以切换。
使用示例:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking<Unit> {
val flow = flow {
println("Flow started on thread: ${Thread.currentThread().name}")
emit(1)
emit(2)
emit(3)
}
.flowOn(Dispatchers.IO) // 改变上游Flow执行的上下文
flow.collect { value ->
println("Collected $value on thread: ${Thread.currentThread().name}")
}
}
// 输出
Flow started on thread: DefaultDispatcher-worker-1
Collected 1 on thread: main
Collected 2 on thread: main
Collected 3 on thread: main
解释:
- 上游操作的执行线程:
flow块内的代码(发射数据的代码)因为使用了flowOn(Dispatchers.IO),所以它运行在Dispatchers.IO指定的线程池中。 - 下游操作的执行线程:
collect块是收集数据的地方,默认情况下,它运行在runBlocking所在的线程(即main线程)中。
为什么使用 flowOn?
- 线程切换:通过
flowOn,我们可以指定 Flow 的不同部分运行在不同的线程上。例如,我们可以在后台线程上处理数据,并在主线程上更新 UI。 - 性能优化:对于涉及到 I/O 操作或计算密集型任务的 Flow,上游操作通常会运行在
Dispatchers.IO或Dispatchers.Default上,而最终结果的收集和处理可能会在主线程(Dispatchers.Main)上进行。 - 简化代码:
flowOn简化了线程管理,因此不需要手动切换上下文或使用其他调度器,直接在 Flow 的定义中指定即可。
为什么不用withContext()、launch等可以切换CoroutineContext的函数?
首先我们一定要知道,emit本质上是在下游工作的,如果emit被withContext包裹住的话,那么也会改变下游的CoroutineContext。这会导致难以管理,因为写上游和下游的有可能是不同的人员,他们可能并不清楚对方到底是在哪个CoroutineDispatcher下运行的,贸然修改可能导致程序运行异常。不过不同于try-catch需要我们自己编码注意,真这么写了,程序会直接报错。 手动定制emit所在的运行协程,这是违规的。
注意事项
flowOn只能影响上游的操作:flowOn操作符只会影响它之前定义的代码块,而不会影响它之后的操作。它主要用于控制上游的执行上下文。但如果你想实现非常细腻的控制,比如在一个操作符内部做协程的上下文切换,withContext是更好的选择,flowOn无法达到这么细的粒度。- 多个
flowOn操作符:我们可以在 Flow 中使用多个flowOn,每个flowOn只会影响之前的操作。通常情况下,只需要一个flowOn就可以满足大部分需求。 - 影响性能:频繁的线程切换可能会影响性能,因此在使用
flowOn时,建议尽量减少不必要的线程切换。 collect代码块不支持使用flowOn操作符,它默认使用的coroutineContext就是你启动它的协程,如果还想定制,可以这样操作:
scope.launch {
/* 方法一
withContext(Dispatchers.IO) {
flow1.collect{
}
}*/
// 方法二,更优雅的写法,利用onEach操作符。
flow1.map {
it + 1
}.onEach {
println("Data: $it - ${currentCoroutineContext()}")
}.flowOn(Dispatchers.IO)
.collect {}
flow2.collect()
// 方式三,使用launchIn来定制,官方推荐。
flow1.map {
it + 1
}.onEach {
println("Data: $it - ${currentCoroutineContext()}")
}.launchIn(scope + Dispatchers.IO)
}
- flowOn不一定会返回flow对象,连续调用两个flowOn将会触发fuse(融合)效果例如:
val flow1 = flow {
println("CoroutineContext in flow(): ${currentCoroutineContext()}")
for (i in 1..5) {
emit(i)
}
}.map {
println("CoroutineContext in map() 1: ${currentCoroutineContext()}")
it * 2
}.flowOn(Dispatchers.IO) // 连续调用两个flowOn,只会留下第一个。
.flowOn(Dispatchers.Default)
我们之前知道,两个同类型的coroutineContext相加,左边的会被删掉,会留下右边的,而flowOn则是右边的删除,左边的留下。
- flowOn除了能和flowOn融合,还能和channelFlow融合。flowOn的coroutineContext的切换是用Channel实现的,而更确切的说,他和channelFlow在底层用的就是同一套实现,都是ChannelFlow。因此如例如下边的代码,flowOn会和昨天的channelFlow所提供的flow对象融合,会导致channelFlow运行在flowOn的coroutineContext上:
// 示例1
val flow2 = channelFlow {
println("CoroutineContext in channelFlow(): ${currentCoroutineContext()}")
for (i in 1..5) {
send(i)
}
}.flowOn(Dispatchers.IO)
// 示例2,map导致两者隔开,下边这种写法会导致flowOn创建一个新的ChannelFlow对象。但是channelFlow依然回因为后变的flowOn而运行在Dispatchers.IO上。
// 为什么呢?这是因为下边这个flowOn会在IO上调用上游的collect(),来启动上游的收集过程,那么最终这个上游的channelFlow就会依然在IO中启动。
val flow2 = channelFlow {
println("CoroutineContext in channelFlow(): ${currentCoroutineContext()}")
for (i in 1..5) {
send(i)
}
}.map { it }.flowOn(Dispatchers.IO)
// 示例2的等价代码:
val flow2 = flow {
println("CoroutineContext in channelFlow(): ${currentCoroutineContext()}")
for (i in 1..5) {
emit(i)
}
}.map { it }.flowOn(Dispatchers.IO)
更复杂的示例,假设我们有一个需要在后台线程中执行的计算任务,然后在主线程中收集结果:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking<Unit> {
val flow = flow {
println("Emitting on thread: ${Thread.currentThread().name}")
emit(doComplexCalculation()) // 复杂计算在IO线程中进行
}
.flowOn(Dispatchers.IO) // 上游操作在IO线程中执行
flow.collect { value ->
println("Collected $value on thread: ${Thread.currentThread().name}") // 在主线程中收集结果
}
}
suspend fun doComplexCalculation(): Int {
delay(1000) // 模拟耗时操作
return 42
}
// 输出
Emitting on thread: DefaultDispatcher-worker-1
Collected 42 on thread: main
在这个示例中,复杂计算操作在 Dispatchers.IO 线程池中执行,而结果收集则发生在主线程中。
总结
flowOn用于控制 Flow 操作的执行上下文。它允许我们在指定的线程或调度器上执行 Flow 的上游操作,从而使代码更具可控性和灵活性。- 使用
flowOn可以确保在适当的线程中执行耗时操作,而不会阻塞主线程,也可以保证在正确的线程中收集和处理结果。
buffer()/conflate()/collectLatest()/flowOn()/channelFlow
buffer()
buffer() 给flow增加缓冲功能,是一个用于在收集器和上游流之间添加缓冲区的操作符。它允许流在处理较慢的情况下暂存一定数量的数据项,从而避免上游的流被阻塞。它本质上也是通过Channel来实现的,包括它的缓冲参数的调节。Flow是线性的逻辑,这包括两点:第一对于每条数据,它是从最上游生产,然后沿着整个操作符链条,最后到collect代码块,第二,对于整个数据流来说,它的多条数据之间也是线性的,也就是第一条数据完全处理完成后才会开始下一条数据的生产,在上一个数的collect代码块执行完之前,下一条数据不会开始生产。假设我们现在有这样一个需求,希望emit生产的数据在在后续的流程中,对上一个数据的处理不影响下一个数据的生产,这个可能实现吗?首先,对于单独一条数据肯定不行的,因为你不可能做到对于一个数据,同时做两个不同的操作,但是对于数据流是可以的,我们可以让其中一部分数据中与另一部分数据同时做不同的操作,例如下边的代码:
val flow1 = flow {
for (i in 1..5) {
emit(i)
println("Emitted: $i - ${System.currentTimeMillis() - start}ms")
}
}.flowOn(Dispatchers.IO)
.map { it + 1 }
.map { it * 2 }
scope.launch {
flow1.mapLatest { it }.collect {
delay(1000)
println("Data: $it")
}
}
// 输出
Emitted: 1 - 143ms
Emitted: 2 - 144ms
Emitted: 3 - 144ms
Emitted: 4 - 144ms
Emitted: 5 - 144ms
Data: 4
Data: 6
Data: 8
Data: 10
Data: 12
我们让上游的生产与下游的处理在不同的CoroutineContext中,利用线程(协程)的切换实现了这个能力。这里请思考一个问题,这里我们没有配置缓冲,下游处理比较慢,但是还是没有漏数据呢?这是因为flowOn(Dispatchers.IO)是默认把缓冲能力打开了的,Channe是支持缓冲的,而Flowon就是基于Channel实现了的,所以自然而然的它也用上了Channel的缓冲功能。不过flowOn并不支持自定义缓冲配置,自定义缓冲配置需要用buffer,它的底层和flowOn以及ChannelFlow是一样的,都是利用ChannelFlow来实现的。直白点说就是使用buffer与flowOn创建的是同一个东西,只不过flowOn的话只能配置CoroutineContext,而使用buffer配置的就是缓冲。另外,多个flowOn之间,以及flowOn和ChannelFlow之间是有融合特性的。buffer也是具备的,比如这种写法flowOn().buffer()就只会创建一个flow对象,但这个对象会继承两个操作符的所有信息。
buffer源码,有两个参数,一个是capacity,缓冲区大小,另一个是onBufferOverflow,缓冲溢出策略,他们和channel的两个参数是完全一样的功能和效果。
public fun <T> Flow<T>.buffer(capacity: Int = BUFFERED, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND): Flow<T> {
public fun <E> Channel(
capacity: Int = RENDEZVOUS,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E> =
buffer的溢出策略有3个,除了默认的外,另外两个,随便选择哪一个都会覆盖前边的buffer的缓冲策略,例如下边的代码,执行之后缓冲区大小变为2,且策略也变化为DROP_OLDEST
buffer(1)
.buffer(2, BufferOverflow.DROP_OLDEST)
如果用的是Suspend,也就是默认的策略,那么会沿用左边的所有策略,缓冲区大小会按照给定的值相加,没有写就是0(虽然默认值是64)。
示例:
flow {
repeat(5) {
emit(it)
delay(100) // 模拟生产速度
}
}.buffer() // 添加缓冲区
.collect { value ->
delay(300) // 模拟消费速度较慢
println(value)
}
buffer操作符可以单独使用,不用非要搭配flowOn,原理也是创建了一个ChannelFlow,通过协程切换的方式实现了缓冲效果。如果想配置CoroutineContext,就用FlowOn,如果想配置缓冲区大小,就使用buffer,
特点:
-
buffer()操作符允许生产者继续产生数据,即使消费者处理数据的速度较慢。 -
默认情况下,
buffer()的容量为 64,如果缓冲区满了,生产者会被挂起,直到消费者处理了一些数据。
conflate()
conflate() 只缓冲最新一条数据的buffer(), 是一种优化手段,它会在消费者处理数据的速度赶不上生产者时,仅保留最新的数据项。它可以用来减少不必要的处理,但代价是可能会丢失一些中间值。
示例:
flow {
repeat(5) {
emit(it)
delay(100) // 模拟生产速度
}
}.conflate() // 仅保留最新值, 等价于buffer(CONFLATED)
.collect { value ->
delay(300) // 模拟消费速度较慢
println(value)
}
特点:
-
当消费者处理数据较慢时,
conflate()会丢弃前面未处理的数据项,只保留最新的一个值给消费者处理。 -
这种方式适用于你关心最新数据,而不需要逐个处理每个值的情况。
collectLatest()
collectLatest() 是一种特殊的收集操作符,是2个操作符的合并使用mapLatest(action).buffer(0).collect() mapLatest 只处理最新数据的操作符,那么我们可以知道,这个操作符并不会影响后续的生产流程,因此可以知道这个mapLatest也是需要多协程的支持的支持的,这个底层也是使用Channel来实现的,与buffer和flowOn用的是相同的底层支持。所以mapLatest也是可以跟bufer和flowOn融合,在默认情况下mapLatest也是有缓冲的。而后边如果加上buffer(0),就会把缓冲关闭,关闭缓冲与它的新数据打算当前数据生产这个核心特性无关,而与他的并发执行的这个附带特性有关。
关掉缓冲(buffer(0))会导致协程的挂起,而不是丢弃数据,只要你不填写在buffer的第二个参数填写 DROP_OLDEST 或者是 DROP_LAEST,Flow是不会私自丢弃数据的。缓冲的关闭导致的这种挂起所导致的挂起的实质结果就是上一条数据还没有处理完的时候如果再来新的数据,新数据不会被mapLatest()所转化,而是一直等着上一条数据的collect()的大括号执行结束,并且是在等待下游,而上游的生产并没有被卡住,因此如果有新来的数据,那么这个未被转化的数据就会被终止,而直接基于新来到的数据进行转化。这就是数据的丢失。基于下边的例子,说的更直白点,在第一条数据转化完成之后,还没有执行完collect的逻辑。第二条数据已经生产完成了,但是他需要等待第一条数据被打印之后才开始转化,而在等待过程中,第三条数据已经成功生产并且到达,同样的,稍后第三条也会被抛弃,最终留下的只有最后一条:
val flow1 = flow {
for (i in 1..5) {
emit(i)
println("Emitted: $i - ${System.currentTimeMillis() - start}ms")
}
}.buffer(1)
.buffer(CONFLATED)
.buffer(2, BufferOverflow.DROP_OLDEST)
// .conflate()
.map { it + 1 }
.map { it * 2 }
scope.launch {
flow1.mapLatest { it }. buffer(0).collect {
delay(1000)
println("Data: $it")
}
}
// 输出
Emitted: 1 - 184ms
Emitted: 2 - 184ms
Emitted: 3 - 184ms
Emitted: 4 - 184ms
Emitted: 5 - 184ms
Data: 4
Data: 12
下游只有第一条和最后一条打印了,其余的因为还没来得及转换就被抛弃了。从mapLatest{}里边就被扔掉了。不是在缓冲之后被扔掉了。而是直接没有机会转化出来。根本就没有进入缓冲之中。这就是mapLatest().buffer(0)的效果。他就是一个下游处理完成之前,上游就不再转化新数据的 mapLatest 。再来看看mapLatest(action).buffer(0).collect(),由于collect无逻辑,瞬间完成,因此它的缓冲根本派不上用场。所以buffer(0)写不写没有任何区别。
是当新的数据项到来时,如果之前的数据项处理尚未完成,则取消之前的数据处理,只处理最新的数据。
示例:
flow {
repeat(5) {
emit(it)
delay(100) // 模拟生产速度
}
}.collectLatest { value ->
println("Collecting $value")
delay(300) // 模拟处理耗时任务
println("Done $value")
}
特点:
-
当新数据项到达时,
collectLatest()会取消当前未完成的处理任务,立即开始处理最新的数据。 -
适用于你只对最新数据感兴趣的场景,并且希望快速响应最新的输入,而不关心中间状态。
总结
buffer():用于添加缓冲区,避免上游流被下游处理的速度限制。conflate():丢弃中间值,仅保留最新的值进行处理。collectLatest():取消对旧数据的处理,快速处理最新数据。
Flow自定义操作符
我们需要知道,Flow的操作符其实就是利用现成的Flow对象来创建另一个Flow对象,所以自定义操作符,肯定是一个Flow的拓展函数。因为Flow是一个泛型类型,因此我们最好将这个函数也写成泛型函数,用来确保最终输出的Flow和原来的Flow的类型参数的类型是相关的。自定义的时候需要把握两点,第一点就是要拿到上游数据,第二点就是发送自己处理过后的数据。通常自定义分为两部,你需要用flow或者channelFlow创建一个什么都不修改的Flow出来,然后基于这个flow去做定制,这里就要注意了,如果什么都不改,最好是把返回类型写成与调用类型一致的类型。既然使用了Flow和ChannelFlow创建最简单的函数体,我们就用flow{}吧,这样就创建了一个flow对象了。然后我们要让customOperator前边的flow和下游连接起来,把它的数据原封不动的转移到下游去,那应该怎么做?第一步,在Flow<T>的collect被调用的时候,也同时去调用上游的 collect,也就是我的数据开始收集的时候,启动上游的数据收集,那我是什么时候开始被调用collect的呢?调用collect的时候其实就是flow{}开始执行的时候。follow里边直接的this其实就是flow {},这个{}内部的实现体。,而实际上,还有一个间接的this.就是fun <T> Flow<T>.customOperator(): Flow<List<T>> 这个customOperator的调用者提供的this。这也就导致了我们下边的this@customOperator.collect可以简写为collect, 然后在flow{}内部直接使用emit就能把数据发送到下游了。
fun main() = runBlocking<Unit> {
val scope = CoroutineScope(EmptyCoroutineContext)
val flow1 = flowOf(1, 2, 3)
scope.launch {
// 这是自定义函数的完整写法,因为flow{}的参数就是FlowCollector
flow1.customOperator().collect(object : FlowCollector<List<Int>> {
override suspend fun emit(value: List<Int>) {
println("1: $value")
}
})
// 这是简写
flow1.customOperator().collect { println("1: $it") }
flow1.double().collect { println("2: $it") }
}
delay(10000)
}
fun <T> Flow<T>.customOperator(): Flow<List<T>> = flow {
// collect对应的是customOperator前边的Flow,用来拿到上游的数据,而emit对应的就是flow{}内部的逻辑了。
/*this@customOperator.*/collect {
emit(listOf(it))
emit(listOf(it))
}
}
fun Flow<Int>.double(): Flow<Int> = channelFlow {
collect {
send(it * 2)
}
}
当然,如果你想改变输出的元素类型,你甚至可以改成这样:
fun <T> Flow<T>.customOperator(): Flow<List<T>> = flow {
// 把上游的数据连续发送两次。
collect {
emit(listOf(it))
emit(listOf(it))
}
}
学后检测
一、单选题(每题1分,共10题)
1. 关于filterIsInstance<List<String>>(),下列说法正确的是?
A. 只能判断List外层类型
B. 能判断List和内部String类型
C. 只能判断String类型
D. 会自动过滤null
答案: A
解析: reified 只能识别最外层List类型,无法深入判断内部泛型类型(即List里的String),kotlin运行时泛型擦除导致如此。
2. 下列哪个操作符会在遇到第一个不满足条件的元素时就终止收集**?**
A. dropWhile
B. takeWhile
C. filter
D. filterNot
答案: B
解析: takeWhile只要遇到第一个不满足条件的元素就停止整个流,后续都不再处理;dropWhile是跳过,filter/filterNot只是筛选不终止。
3. mapLatest、transformLatest、collectLatest三者区别,下列哪项错误**?**
A. 都能取消未完成的上一个处理
B. mapLatest必须返回单个值,transformLatest可以多次emit
C. collectLatest不返回新的Flow
D. mapLatest和transformLatest都只能作为终结操作符
答案: D
解析: mapLatest和transformLatest都是中间操作符,可以继续链式调用。collectLatest是终结操作符,直接收集。
4. 下列哪个不是Flow的缓冲相关操作符?
A. buffer
B. conflate
C. collectLatest
D. debounce
答案: D
解析: debounce是防抖操作符,本质是限流,不属于缓冲类。buffer/conflate/collectLatest都涉及数据缓存或处理速率失衡。
5. flowOn(Dispatchers.IO)对于哪个阶段有效?
A. 上游生产数据
B. 下游collect收集
C. 上下游都切换
D. 只影响map等变换操作
答案: A
解析: flowOn只能切换它之前(上游)的生产、变换等操作所在的协程/线程,下游收集不受影响。
6. 下列哪个操作符能够让Flow在没有发射任何数据时自动emit一个默认值?
A. onStart
B. onEmpty
C. onCompletion
D. catch
答案: B
解析: onEmpty会在Flow未emit任何元素时触发,可以手动emit默认值,onStart/onCompletion只是生命周期回调。
7. Flow异常捕获最佳实践,下列哪个写法能保证下游异常不会被上游吞掉?
A. try-catch包住emit
B. try-catch包住collect
C. try-catch包住整个flow{}
D. catch操作符写在collect之后
答案: B
解析: 只有在collect收集时catch才是安全的;emit内try-catch会捕获到下游异常,导致上游吞掉本不该处理的异常。
8. 关于buffer和conflate,下列说法错误的是?
A. buffer容量默认64
B. conflate底层等价于buffer(CONFLATED)
C. buffer可以自定义容量和溢出策略
D. conflate不会丢弃任何中间数据
答案: D
解析: conflate只保留最新值,会丢弃中间未处理数据,适用于只关心最新数据的场景。
9. Flow的withIndex操作符产生的数据类型是?
A. Pair<Int, T>
B. IndexedValue<T>
C. List<T>
D. Map<Int, T>
答案: B
解析: withIndex生成IndexedValue对象,属性为index和value。
10. 以下哪个场景适合用debounce()操作符?
A. 按钮防连点
B. 实时高频数据展示
C. 搜索输入建议
D. 并行计算
答案: C
解析: debounce适合处理输入搜索建议等场景,能在输入停顿后处理最后一条输入。按钮防连点不建议用debounce。
二、多选题(每题2分,共8题)
1. Flow中catch操作符的特性有哪些?
A. 只能捕获它之前Flow链上的异常
B. 能捕获collect收集器抛出的异常
C. 不会捕获CancellationException
D. catch里可以继续emit数据
答案: A、C、D
解析: catch只能捕获它前面的异常;无法捕获CancellationException;catch可emit新数据。collect收集器异常不会被catch捕获。
2. 关于map、mapLatest、mapNotNull,以下哪些描述正确?
A. map每条数据转换一条
B. mapNotNull等价于map+filterNotNull
C. mapLatest和collectLatest都能中断上一个未完成的转换
D. mapNotNull会跳过null数据
答案: A、B、D
解析: map一对一转换,mapNotNull等价于map+filterNotNull,mapLatest和collectLatest不是一个级别的操作符(前者中间、后者收集)。
3. 以下哪些Flow操作符会导致提前终止流**?**
A. take
B. takeWhile
C. drop
D. transformWhile
答案: A、B、D
解析: take、takeWhile、transformWhile都能中断流。drop/dropWhile只是跳过元素,后续仍继续处理。
4. Flow中的buffer操作符,下列哪些说法正确?
A. 默认容量为64
B. 溢出策略可选SUSPEND、DROP_OLDEST、DROP_LATEST
C. 会影响上游和下游的并发
D. buffer和flowOn作用原理底层都是基于Channel
答案: A、B、C、D
解析: 都正确,buffer默认64,溢出策略三种,影响并发,底层都是ChannelFlow。
5. Flow中哪些属于“全流程监听”操作符?
A. onStart
B. onCompletion
C. onEmpty
D. catch
答案: A、B、C、D
解析: 这四个都是生命周期/全流程相关监听(异常、开始、结束、空流)。
6. 以下哪些属于Flow的“收集终止操作符”?
A. collect
B. collectIndexed
C. collectLatest
D. fold
答案: A、B、C、D
解析: 这些都属于终结操作符,会启动收集、终结整个Flow链。
7. 关于Flow的异常处理和重试,下列哪些描述正确?
A. retry/retryWhen可重启上游流程
B. catch适合下游业务处理
C. retryWhen能动态控制重试间隔/条件
D. retryWhen和catch都能捕获所有异常
答案: A、C
解析: retry/retryWhen能重启上游,retryWhen能动态控制重试条件和延迟,catch只能捕获链前面的异常且不能覆盖所有情况(如CancellationException)。
8. Flow下列哪些场景适合用conflate?
A. 只关心最新状态的UI刷新
B. 所有数据必须完整展示
C. 高频传感器采样数据
D. 实时监控、绘图等
答案: A、C、D
解析: conflate适合高频、只要最新值的场景;所有数据都要展示时不能用。
三、判断题(每题1分,共8题)
1. Flow中的dropWhile/drop与takeWhile/take是对数据的“前缀”操作。
答案:√
解析:四者都只作用于流的“前缀”部分,和后续无关。
2. onEach操作符适合做副作用,例如打印日志,但不会影响数据流内容。
答案:√
解析:onEach不改变流本身,只适合副作用。
3. runningReduce和reduce都是Flow的挂起终止操作符。
答案:×
解析:reduce是终结挂起操作符,runningReduce是中间操作符,返回Flow。
4. transformWhile等价于transform+takeWhile的组合效果。
答案:√
解析:transformWhile=transform+takeWhile,既能转换又能提前终止。
5. flowOn既能切线程也能切协程名等上下文参数。
答案:√
解析:flowOn本质是切换CoroutineContext,可以不只是线程。
6. collectLatest的本质是mapLatest(action).buffer(0).collect()的合成语法糖。
答案:√
解析:collectLatest底层实现和mapLatest+buffer(0)+collect等价。
7. try-catch可以随意包裹emit,业务异常一定不会丢失。
答案:×
解析:包裹emit会捕获到下游异常,容易吞掉真正业务异常。
8. launchIn(scope)可以替代collect直接在某个协程上下文中收集Flow。
答案:√
解析:launchIn可指定scope异步收集,无需手动launch+collect。
四、简答题(每题3分,共6题)
1. 简述Flow中catch和try-catch两种异常处理方式的本质区别。
答案:
- catch是Flow链的中间操作符,只捕获其之前操作符中的异常(如map/filter/emit等);
- try-catch在flow{}内部可包裹数据生产或emit代码,捕获上游异常;但如果包裹emit会“误捕”下游处理异常,可能导致异常漏报。
- 实际项目中:catch更安全,推荐catch处理链路异常,下游消费(collect)异常需在collect外层用try-catch。
2. 什么时候应选用onEmpty而不是onStart?
答案:
- onEmpty用于整个Flow“没有发射任何数据”时触发,可以用来发默认值或补偿。
- onStart每次Flow收集前必触发(无论有没有数据),用于初始化/日志等。
- 场景区分:只在流完全空时要补偿,用onEmpty;需要每次都做的(如loading、初始化),用onStart。
3. 简述buffer、conflate、collectLatest的区别。
答案:
- buffer:设置一个数据缓冲区,可提升上下游并发,不丢数据(默认64),可配置溢出策略。
- conflate:只保留最新一条数据,丢弃所有中间未处理数据,适合高频只关心最新值。
- collectLatest:下游每次只处理最新到来的数据,前一个未处理完就会被取消(只处理最新,且终结操作符)。
4. 简述mapNotNull与filter+map的区别。
答案:
- mapNotNull会把每一条数据先映射后过滤null,简化写法,避免中间产生null;
- filter+map一般先过滤null或某条件,再映射;
- 等价:mapNotNull{...} ≈ map{...}.filterNotNull()
- 性能和可读性mapNotNull更优(少一次流分发)。
5. Flow中如何自定义一个“连续去重”操作符?原理是什么?
答案:
- 可以用transform/transformWhile操作符,记录上一个值,每次emit新值时判断与上一个是否一致,不一致再发。
- 原理类似distinctUntilChanged,用于自定义流的连续去重逻辑(可针对复杂对象)。
6. 简述flowOn的底层实现原理,以及和withContext的区别。
答案:
- flowOn底层本质用Channel实现,将上游生产/变换部分转到指定CoroutineContext,收集端不变。
- withContext直接切换代码块到新线程/调度器,但在flow中用withContext包裹emit会导致下游协程错乱或报错。
- 总结:flowOn安全切换上游上下文,withContext适合局部协程、不能随意用在Flow链中。
五、编程题(每题6分,共3题)
1. 实现一个Flow操作符onlyOdd(),只发射奇数元素,要求自定义扩展函数,不能用现成filter。
答案:
fun Flow<Int>.onlyOdd(): Flow<Int> = flow {
collect { value ->
if (value % 2 != 0) emit(value)
}
}
// 解析:自定义扩展,collect获取上游,手动判断奇数再emit即可。
2. 编写一个使用buffer的例子,模拟上游每100ms生产数据,下游每300ms消费,打印缓冲带来的并发效果。
答案:
val flow = flow {
repeat(5) {
emit(it)
println("Emitted: $it - ${System.currentTimeMillis()}")
delay(100)
}
}.buffer()
runBlocking {
flow.collect {
println("Collected: $it - ${System.currentTimeMillis()}")
delay(300)
}
}
// 解析:buffer允许上游提前生产,下游慢处理不会卡住上游,能看到生产快于消费。
3. 编写一个带重试的Flow:每收到3就抛异常,最多重试2次,否则用catch发射-1,打印完整流程。
答案:
val flow = flow {
for (i in 1..5) {
if (i == 3) throw RuntimeException("err")
emit(i)
}
}.retry(2) { it is RuntimeException }
.catch {
emit(-1)
}
runBlocking {
flow.collect { println(it) }
}
// 解析:retry最多重试2次,失败catch发-1,测试流程完整。