可露希尔的协程笔记(下)

73 阅读19分钟

前言

现在这个时间点,关于协程的优秀文章已经很多了。在此也分享一下我学习协程时的笔记。我写东西比较啰嗦,整个笔记字数超过了限制,所以分上下两部分发了。上篇记录协程的基础内容,下篇记录 Channel 和 Flow.但记录下篇时正值我辞职换工作,所以下篇可能不那么完善,缺失了一些东西,等忙完这段时间稳定下来以后再补上吧。

Channel 与 Flow

ChannelFlow 都是处理数据流的工具。Flow 是 Kotlin 团队后出的,针对 Channel 以及其他异步流工具的痛点而改进的工具,也是目前应用比较多的。所以 Channel 只会简单着墨,这一节会重点介绍 Flow。这一节的内容我很推荐先去看看 Roman Elizarov 的演讲以及他的文章

Hot🔥 与 Cold❄️

学这一节的内容很容易看到的概念,热流及冷流。先简略解释一下什么是热(Hot),什么是冷(cold)。

如果一个数据源主动的,独立(于订阅者)的生成数据,不管这个数据是否有消耗,然后存储这个数据,那我们称其为热数据源。相对的,如果一个数据源是惰性的,只有当有需要时,即这个数据源有订阅者时,才生成数据,且不存储任何数据,则我们称其为冷数据源。热源生成的数据流即是热流,冷源生成的则是冷流。

事实上,我们平时使用的大部分数据结构都是分冷热的,比如 Collections (List, Set) 就是热的,而 Sequence, Steam 就是冷的。

val hotList = buildList {
	repeat(3) {
		println("create Data and Store $it")
		add(it)
	}
}.map { println("hot list $it") }

// create Data and Store 0
// create Data and Store 1
// create Data and Store 2
// hot list 0
// hot list 1
// hot list 2

val coldSequence = sequence {  
    repeat(3) {  
        println("lazy create $it")  
        yield(it)  
    }  
}.map { println("cold sequence $it") }
println("sequence forEach")
coldSequence.forEach { it }

// sequence forEach
// lazy create 0
// cold sequence 0
// lazy create 1
// cold sequence 1
// lazy create 2
// cold sequence 2

👉尝试一下

可以观察到,热源 List 会将数据放入内存中,然后将数据发送到 map 操作符,然后执行计算并生成结果。每个步骤都一步一步的发生,是命令式的。冷源的行为就不同,冷源产生一个数据就计算一个,以反应式的方式工作。而 Flow 也相同,Flow 也是响应式的。

fun nameIs(): Flow<String> = flowOf("A", "B", "C").map { name ->
    recordName(name)
    "name is $name"
}

fun recordName(user: String): String {
    println("record: $user")
    return user
}

// record: A
// name is A
// record: B
// name is B
// record: C
// name is C

👉尝试一下

冷流热流的相对优劣会在稍后讲 ChannelFlow 的时候再介绍。Kotlin 中最初提供的 Channel 只能处理热流,Kotlin 的开发者意识到了这个问题而推出了用于处理冷流的 Flow.随着时间发展,Flow 也拥有了处理热流的能力,这些稍后细说。

Channel

Channel 目前没什么优势区间,应用场景较窄,可以直接学 Flow.等真的需要的时候再学。

从概念上,Channel 就像一个队列,元素从一端添加,并从另一端接收。从使用上,Channel 就像一个通道,不同的协程可以通过 Channel 对象实现数据的发送和接收。使用 Channel() 函数创建实现类。

Channel 是热的,Channel 很适合本质上非常热的源,无需请求即可存在的数据源。

public interface Channel<E> : SendChannel<E>, ReceiveChannel<E>

public fun <E> Channel(  
    capacity: Int = RENDEZVOUS,  
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,  
    onUndeliveredElement: ((E) -> Unit)? = null  
): Channel<E>

Channel 设计上基于生产者消费者模式,Channel 接口实现了 SendChanelReceiveChannel 两个接口,通过它们完成生产 send() 和消费 receive().

Channel 是用来处理热流的工具。上一节已经介绍过热源会独立的生产数据,Channel 中的参数 capacity 缓冲大小就是控制 Channel 生产数据数量的。capacity 默认为 0,代表着它发送一条数据后就会挂起,等待有人接受数据后解除挂起继续执行。

capacity 有如下预设值:

  • RENDEZVOUS 默认值,0,无缓冲区。

rendezvous-channel.png

  • UNLIMITED 无限制缓冲区

unlimited-channel.png

  • CONFLATED 合并,发送的新元素会覆盖老元素,接受者只会获得最新元素

conflated-channel.gif

  • BUFFERED 默认64

buffered-channel.png

val channel = Channel<Int>()  
lifecycleScope.launch {  
    for (x in 1..10) {  
        println("add ${x * x}")  
        channel.send(x * x)  
    }  
}  
// 可以给一个按钮设置点击监听器来接收数据
binding.fab.setOnClickListener {  
    lifecycleScope.launch {  
        repeat(5) { println(channel.receive()) }  
        println("Done!")  
    }  
}

// add 1

这段代码可以放在 Android Studio 中执行,可以设置一个点击监听触发接收。但在 PlayGround 中则无法在没有接收者的情况下执行。因为 Channel 在无人接收数据的情况下会在 send 处一直挂起而导致超时夹断。从这里也初见端倪,可以看到 Channel 一个很明显的缺点:在没有接收者的情况下,Channel 仍会计算下一个值并且挂起。这不仅是资源的浪费,也需要注意可能的取消操作。

  • onBufferOverflow 控制缓冲区溢出时的操作,默认为暂停发送值的尝试。
  • onUndeliveredElement 可选,当元素已发送但未传递给消费者时调用。这个稍后细说。

Channel 也支持被遍历以获取当前发送数据

for (c in channel) { println("$c") }

先看发送端 SendChannel

// 调用过 close 则返回 true
@ExperimentalCoroutinesApi  
public val isClosedForSend: Boolean

// 发送元素
public suspend fun send(element: E)

// select 中调用的
public val onSend: SelectClause2<E, SendChannel<E>>

// send的同步变体,非挂起函数
public fun trySend(element: E): ChannelResult<Unit>

// 关闭通道 首次调用返回 true 后续调用返回 false
public fun close(cause: Throwable? = null): Boolean

然后是接收端 ReceiveChannel

// close发送端且所有已发送项目均处理完或cancel接受端返回true
@ExperimentalCoroutinesApi  
public val isClosedForReceive: Boolean

// 如果通道为空返回 true 如果isClosedForReceive为true则返回false
@ExperimentalCoroutinesApi  
public val isEmpty: Boolean

// 接收 
public suspend fun receive(): E
public suspend fun receiveCatching(): ChannelResult<E>
public fun tryReceive(): ChannelResult<E>

// 消费 执行给定的代码块,消耗channel中所有元素,然后取消
public inline fun <E, R> ReceiveChannel<E>.consume(block: ReceiveChannel<E>.() -> R): R
public fun <T> ReceiveChannel<T>.consumeAsFlow(): Flow<T>
// 类似 for 循环,执行后会取消
public suspend inline fun <E> ReceiveChannel<E>.consumeEach(action: (E) -> Unit): Unit


// select中的接收 如果发送端关闭则抛出异常
public val onReceive: SelectClause1<E>
// 同select 不抛异常
public val onReceiveCatching: SelectClause1<ChannelResult<E>>

// 迭代器,用于for
public operator fun iterator(): ChannelIterator<E>

// 取消
public fun cancel(cause: CancellationException? = null)

// 将channel标识为热flow
public fun <T> ReceiveChannel<T>.receiveAsFlow(): Flow<T>

发送端 close 或接收端 cancel 后,接收端接收会抛出 ClosedReceiveChannelException, 发送端再继续发送会抛出 ClosedSendChannelException.

Produce

直接创建的 channel 对象可以发送和接收数据,也可以用 produce 函数更方便的应对通过函数生成元素序列的场景。该函数创建一个新的协程,将值 sendchannel 来生成数据流,返回对协程的引用作为 ReceiveChannel,该对象可用于接收该协程生成的元素。创建协程的函数被定义为 CoroutineScope 的扩展,遵循结构化并发。

@ExperimentalCoroutinesApi  
public fun <E> CoroutineScope.produce(  
    context: CoroutineContext = EmptyCoroutineContext,  
    capacity: Int = 0,  
    @BuilderInference block: suspend ProducerScope<E>.() -> Unit  
): ReceiveChannel<E>
  • context 可以传入协程上下文,默认调度器为 Default
  • capacity 缓存大小
  • block 「ProducerScope」接口同时实现了CoroutineScopeSendChannel,协程内可以直接调用 send

当协程完成时,channel 就会关闭。取消 channel 也会取消协程。

如果协程抛出异常而结束,则 channel 会关闭,接受完所有现有元素后,任何进一步的接收尝试会抛出该异常。

val produceJob = Job()
// create and populate a channel with a buffer
val channel = produce<Int>(produceJob, capacity = Channel.UNLIMITED) {
    repeat(5) { send(it) }
    throw IndexOutOfBoundsException()
}
produceJob.join() // wait for `produce` to fail
check(produceJob.isCancelled == true)
// prints 0, 1, 2, 3, 4, then throws `TestException`
for (value in channel) { println(value) }

👉尝试一下

如果通过结构化并发取消该协程,在协程完成之前 channel 不会自动关闭,协程取消后也可能会发送一些元素

val parentScope = CoroutineScope(Dispatchers.Default)
val channel = parentScope.produce<Int>(capacity = Channel.UNLIMITED) {
    repeat(5) {
        send(it)
    }
    parentScope.cancel()
    // suspending after this point would fail, but sending succeeds
    send(-1)
}
for (c in channel) {
    println(c) // 0, 1, 2, 3, 4, -1  // 如果parentScope.cancel() 替换成 cancel() 则没有-1
} // throws a `CancellationException` exception after reaching -1

👉尝试一下


多个协程可以从一个 channel 接收数据,比如官方的这个例子

多个协程也可以发送到同一个 channel,比如官方的这个例子

Flow

先贴一下安卓官方对于 Flow定义,这里的「数据流」就是 Flow

数据流以协程为基础构建,可提供多个值。从概念上来讲,数据流是可通过异步方式进行计算处理的一组数据序列。所发出值的类型必须相同。例如,Flow<Int> 是发出整数值的数据流。

Flow包含三部分

  • producer 生产数据
  • Intermediaries 可选,修改数据
  • consumer 消费数据

再说一下 Flow 的优势区间,即为什么使用 Flow

普通的挂起函数只返回单个数据,不太适配需要数据流的场景。Kotlin 提供了 Sequence 处理同步数据流,但阻塞线程这个缺点太致命了。如果使用 ReceiveChannel 来表示异步数据流,那就需要处理它作为热流带来的缺点,比如不能简单地删除 ReceiveChannel 的引用,因为生产者会一直生成值,挂起协程,打开网络连接等问题。

正如这个 issue ,Kotlin 的开发者认识到了热流的不足。冷流 Flow 应运而生。Channel 讲的不是很详细,也是因为除非有确定的,必须使用 channel 的场景,一般可以优先使用 flow 及其格式子类。Flow 也难免会拿去和 RxJava 这种库去做比较,但由于我个人入行比较晚,没用过更没学过 RxJava,我这里就不比了。

创建

在使用部分适配了 Flow 的三方库的情况下,不需要我们创建 Flow,比如 Room,只需要接入而不用管它是怎么生成的。

@Query("SELECT * FROM location_table ORDER BY time")
fun getLocations(): Flow<List<Location>>

需要自己创建 Flow 的方法有很多,比如可以使用 flow builder Api ,使用 emit 发送值。

fun <T> flow(block: suspend FlowCollector<T>.() -> Unit): Flow<T>
fun simple(): Flow<Int> = flow { // flow builder
    for (i in 1..3) {
        delay(100) // pretend we are doing something useful here
        emit(i) // emit next value
    }
}

可以观察到这里提供 Flow 的函数并不是挂起的。这是因为 Flow 是声明性的。返回 Flow 的函数只是一个声明,它定义了数据将怎么产生,但它不会做别的任何事,所以它没有暂停操作。Flow 的收集方法才是挂起的。

flow { ... } 是最基础的构建器,还有一些其他的可选项:

  • flowOf 可以直接发送一组固定值的流
  • asFlow 可以将各种集合、序列甚至挂起函数转成流

其他的可选项稍后介绍完流的基础知识再说。

修改

与集合类似,Flow 也可以使用中间运算符修改流以适应下一层的要求,比如 mapfiltertransform 等。

运算符应用于上游并返回下游流。上游 Flow 指由提供方代码块及当前运算符之前调用的运算符生成的 Flow,下游同理。运算符也是冷的。它设置一系列暂不执行的链式运算,留待将来使用值时执行。

收集

使用终端操作符触发数据流,最基础的终端操作符是 collect

suspend fun Flow<*>.collect()

终端运算符要么是挂起函数,比如 collecttoListreduce 等,要么是指定作用域的 launchIn 运算符。流的执行将始终以挂起的方式执行。

异常

可以使用 try/catch 块来捕获异常。

fun simple(): Flow<String> = 
    flow {
        for (i in 1..3) {
            println("Emitting $i")
            emit(i) // emit next value
        }
    }
    .map { value ->
        check(value <= 1) { "Crashed on $value" }                 
        "string $value"
    }

fun main() = runBlocking<Unit> {
    try {
        simple().collect { value -> println(value) }
    } catch (e: Throwable) {
        println("Caught $e")
    } 
}             

// Emitting 1
// string 1
// Emitting 2
// string 2
// Caught java.lang.IllegalStateException: Crashed 2

👉尝试一下

这种方式可以正常捕获异常,但捕获之后无法向下游发出新值,且这种捕获方式对流不透明。

catch 运算符可捕获处理上游 Flow 中的数据时可能发生的异常,下游的异常仍会被抛出。

catch 运算符可以在需要时再次抛出异常或发出新值。

fun main() = runBlocking<Unit> {
    (1 .. 3).asFlow()
        .map { checkValue(it) }
        .map { "success $it" }
        .catch {
            println("catch $it")  // 可以重新将异常抛出
            throw it
        }
        .catch {
            println("未处理的异常流向下游 $it")
            emit("exception 2")   // 可以发出新值
        }
        .collect{ println("collect $it") }
}

fun checkValue(value: Int): Int {
    println("handle $value")
    return when(value) {
        2 -> throw IndexOutOfBoundsException()
        else -> value
    }
}

// handle 1
// collect success 1
// handle 2
// catch java.lang.IndexOutOfBoundsException
// 未处理的异常流向下游 java.lang.IndexOutOfBoundsException
// collect exception 2

👉尝试一下

如果异常出现在 collect 中,可以看看场景是否允许配合 onEach 将抛出异常的环节移动到 collect 之前

sampleFlow()
    .onEach { value ->
        check(value <= 1) { "Collected $value" }                 
        println(value) 
    }
    .catch { e -> println("Caught $e") }
    .collect()

不允许的话就只能使用 try/catchCoroutineExceptionHandler 了。

Flow的设计

前几 p 便是 Flow 最基础的应用。接着展开一下 Flow 的设计。Flow 设计的目标就是简单。

interface Flow<out T> {
    suspend fun collect(collector: FlowCollector<T>)
}

Flow 本质是一个接口,只包含一个 collect 函数。该函数接收一个 FlowCollector 实例。FlowCollector 接口只包含一个 emit 函数。

interface FlowCollector<in T> {
    suspend fun emit(value: T)
}

emit 实际上就是执行 collect 的参数函数。所以一个简单的流的工作流程如下所示。

flow.collect { value ->
    println(value)
}
val flow = flow {
    emit("A")
    emit("B")
}
      | ----collect-------------------------------------------> |
      |                                                  λ      |
      |    λ                                             |<---- |
      |    |<-------------------------------------emit---|      |
      |    |                                             |      |
      |    |---println---------------------------------->|      |
      |    |                                             |      |
      |    |<-------------------------------------emit---|      |
      |    |                                             |      |
      |    |---println---------------------------------->|      |
      |                                                  |----> |
      |                                                         |
      | <------------------------------------------------------ |

collect 调用时,会执行 flow 函数 lambda 中的内容。运行到 emit 时则返回到 collect 的 lambda 中,本例中打印了这个值。之后代码继续回到右侧 lambda 执行。如果没有其他值可发出,则将控制权返回给收集器。即整个过程就是发射器和收集器之间来回的函数调用。

发射器,它可以是一个异步发射器,可以在 emit 之前等待其他异步任务完成,例如网络通信,数据库 IO 等等。而收集器同样也可以是异步的,他也可以耗时执行一些异步的数据库存储操作。

因为 Flow 的 collect 以及 emit 函数均是挂起函数,当收集器不堪重负,处理数据很慢时,它可以简单的暂停发射器。发射器并不会发射更多的数据,因为此时收集器还未返回。当收集器准备好以后,则恢复发射器以继续工作。Flow 因此特性简单的支持了背压(Backpressure)- 数据消费者消费的速度跟不上生产者生产的速度时,以某种方式阻碍将输入转换为输出的进程的能力。

Flow 是异步的,也是顺序的

Flow 是用来处理异步数据流的工具,但 Flow 本质上仍是顺序的。

比如我们以 delay 模拟两边的耗时操作。发射器延迟 4 次后取消,接收器延迟 3 次。

val sampleFlow: Flow<Int> = flow {
    for (i in 1 .. 3) {
        delay(100)
        println("emitter $i 100")
        emit(i) 
        
    }
}

val time = measureTimeMillis{
	sampleFlow.collect {
		delay(100)
		println("collector $it 100")
	}
}
println("time $time")

// ...
// time 626

👉尝试一下

总耗时在 600ms 多一些,因为整体的流程是这样的:

    emitter     -- delay(100) --------------------- delay(100) ----
                                 |              ^                |
                            emit |              |           emit |
                                 v              |                v
    collector   -------------------- delay(100)-------------------- 

收集器和发射器都是连续的,按顺序工作。这里并不会发生并发,因为这里的工作流程发生在单个协程中按顺序交替执行。有些情况下这种工作方式并不是我们期望的,如果想将整个流程更快的完成,需要将其解耦,在不同的协程中运行 emittercollector

buffer() 及 conflate() 缓存

接上一节的例子,如果我们需要让 emittercollector 各自使用一个协程,进行协程间通信。在 Kotlin 中负责处理多协程之间建立通信的是 channel 。通过一个 channel 从一个协程发送元素,另一个协程中接收元素。将整个流程变成如下:

    emitter     -- delay(100) ------ delay(100)----- delay(100) ----
                                 |              |                 |
                            emit |         emit |            emit |
                                 v              v                 v
    collector   -------------------- delay(100)------ delay(100) --- 

Flow 封装好了 buffer() 达成这个实现。参考官方文档中「Conceptual implementation」一节,buffer() 的基本实现如下,

fun <T> Flow<T>.buffer(capacity: Int = DEFAULT): Flow<T> = flow {
    coroutineScope { // limit the scope of concurrent producer coroutine
        val channel = produce(capacity = capacity) {
            collect { send(it) } // send all to channel
        }
        // emit all received values
        channel.consumeEach { emit(it) }
    }
}

使用 buffer() 后,发射器产生数据后,会立刻生成下一个数据。并且收集器并行处理前一个数据。而我们可以不用管这背后的 channel 实现,不用关心关闭这个 channel 的细节,所以将上一节中的代码改造一下

val time = measureTimeMillis{
	sampleFlow.buffer().collect {
		delay(100)
		println("collector $it 100")
	}
}
println("time $time")

// ...
// time 458

👉尝试一下

总耗时会缩减到 400ms 多一些。

默认情况下,buffer 发生缓冲区溢出,即消费的速度跟不上生产的速度时,发射器将暂停等待收集器准备完毕。可以指定参数 onBufferOverflow 更改这种策略。指定为 DEOP_OLDEST 会删除缓冲区中最早的值,反之 DROP_LATEST 会删除最新的值,保持缓冲区不变。

conflate() 就是指定 onBufferOverflowDROP_OLDEST 时的快捷方式。

其他修改操作符

除了与集合中用法几乎一致的 mapfilter

transform 是更灵活的,更通用化的 mapfilter,它可以转换元素,跳过元素或多次发出元素。

(1..3).asFlow() // a flow of requests
        .transform { request ->
            if (request % 2 != 0) {
            	emit("Making request $request") 
            	emit(performRequest(request))     
            }   
        }
        .collect { response -> println(response) }

// Making request 1 
// response 1 
// Making request 3 
// response 3

👉尝试一下

take 会在达到相应的限制时取消流的执行。

fun numbers(): Flow<Int> = flow {
    try {                          
        emit(1)
        emit(2) 
        println("This line will not execute")
        emit(3)    
    } finally {
        println("Finally in numbers")
    }
}

fun main() = runBlocking<Unit> {
    numbers() 
        .take(2) // take only the first two
        .collect { value -> println(value) }
}            

👉尝试一下

他们也各有一个变种 transformWhiletakeWhile,以提供更精细的控制。

合流

可以用 zip 将两个 Flow 合并。两个 Flow 长度不一致时发送最短长度的事件。

val nums = (1..3).asFlow().onEach { delay(300) } // numbers 1..3 every 300 ms
val strs = flowOf("one", "two", "three").onEach { delay(400) } // strings every 400 ms
val startTime = System.currentTimeMillis() // remember the start time 
nums.zip(strs) { a, b -> "$a -> $b" } // compose a single string with "zip"
    .collect { value -> // collect and print 
        println("$value at ${System.currentTimeMillis() - startTime} ms from start") 
    } 

// 1 -> one at 434 ms from start 
// 2 -> two at 834 ms from start 
// 3 -> three at 1237 ms from start

👉尝试一下

第一次合流后,当需要在任何上游流发出值时重新计算时可以使用 combine

val nums = (1..3).asFlow().onEach { delay(300) } // numbers 1..3 every 300 ms
val strs = flowOf("one", "two", "three").onEach { delay(400) } // strings every 400 ms          
val startTime = System.currentTimeMillis() // remember the start time 
nums.combine(strs) { a, b -> "$a -> $b" } // compose a single string with "combine"
    .collect { value -> // collect and print 
        println("$value at ${System.currentTimeMillis() - startTime} ms from start") 
    } 

// 1 -> one at 443 ms from start 
// 2 -> one at 646 ms from start 
// 2 -> two at 845 ms from start 
// 3 -> two at 946 ms from start 
// 3 -> three at 1246 ms from start

协程与上下文 flowOn()

Flow 是基于协程构建的,自然 Flow 中的代码运行时也会有上下文。也如前文所述,Flow 的收集器和发射器是只是互相的函数调用,它们会在一个协程上下文中执行。

fun sampleFlow(): Flow<String> = flowOf("A", "B", "C").map {
    println("emitter ${currentCoroutineContext()}")
    doSomeCompute(it)
}
           
fun main() = runBlocking {
    val flow = sampleFlow()
    flow.collect {
        println("collector ${currentCoroutineContext()}")
        println(it)
    }
}

// emitter [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@2ac1fdc4, BlockingEventLoop@5f150435]
// collector [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@2ac1fdc4, BlockingEventLoop@5f150435]
// ...

👉尝试一下

当有切换协程上下文的需求时,比如收集器需要运行在 UI 线程,而发射器需要运行在其他线程时,不能使用 withContext,使用 flowOn()

使用 witchContext 改造发射器的话

// 不允许这么用
fun sampleFlow(): Flow<Data> = flow {
	withContext(Dispatchers.Default) {
		emit(someSyncCompute())
	}
}

则要么收集器会在错误的线程上尝试更新 UI,要么需要收集器中额外编写样板代码,最终指向每一处都要明确指定上下文的结果。所以 Flow 不允许在内部使用 withContext 切换调度器。

该例子中,使用 withContextsomeSyncCompute() 也不是特别适配如果 flow {} 中的代码需要特定的上下文的情况。

解决方案为使用 flowOn(),它影响所有上游的代码的执行上下文。比如官方文档中的例子:

withContext(Dispatchers.Main) {
    val singleValue = intFlow // will be executed on IO if context wasn't specified before
        .map { ... } // Will be executed in IO
        .flowOn(Dispatchers.IO)
        .filter { ... } // Will be executed in Default
        .flowOn(Dispatchers.Default)
        .single() // Will be executed in the Main
}

再说回这一节中的例子,改造起来也很简单:

fun sampleFlow(): Flow<String> = flowOf("A", "B", "C").map {
    println("emitter ${currentCoroutineContext()}")
    doSomeCompute(it)
}.flowOn(Dispatchers.IO)
           
fun main() = runBlocking {
    val flow = sampleFlow()
    flow.collect {
        println("collector ${currentCoroutineContext()}")
        println(it)
    }
}

// emitter [CoroutineId(2), "coroutine#2":ProducerCoroutine{Active}@63c5f46, Dispatchers.IO]
// collector [CoroutineId(1), "coroutine#1":ScopeCoroutine{Active}@62043840, BlockingEventLoop@5315b42e]

👉尝试一下

收集器中的代码块仍会执行在它自身的协程上下文中,不会被影响。只需要知道如果我们在 UI 线程运行收集器,那其中的代码就会执行在 UI 线程上。

launchIn() 以及其他终端操作符

着重介绍一下 launchIn(),因为它不是挂起函数。

fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job

相当于 scope.launch { flow.collect },可以消灭嵌套的大括号,下面的代码效果是一致的:

fun events(): Flow<Event>

scope.launch {
	events().collect { event ->
		updateUI(event)
	}
}

events()
	.onEach { event -> updateUI(event) }
	.launchIn(scope)

除此以外,其余的终端操作符有:

  • 转换为各种集合,如 toList 和 toSet
  • 运算符获取首个值 first ,最后一个值 last 和确保流发出单个值 single
    • single 返回流中第一个且唯一一个值,流为空或者流有多个值会抛出异常
    • first 返回流中首个值,多个值不会抛出异常,但流为空仍会抛出异常
    • singleOrNull 如果流为空或发出多个值则返回单个值和 null
    • firstOrNull 类似,如果流为空返回 null
  • 使用 reduce 和 fold 将流累积。
    • reduce 用流的第一个值作为初始值,fold可以指定一个初始值
val sum = (1..5).asFlow()
    .map { it * it } // squares of numbers from 1 to 5                           
    .reduce { a, b -> a + b } // sum them (terminal operator)
println(sum)

// 55

👉尝试一下

以及 collect 的一些变种:

// 当流发出新值,如果前一个值的操作没完成会被取消
public suspend fun <T> Flow<T>.collectLatest(action: suspend (value: T) -> Unit)

// 索引从 0 开始
inline suspend fun <T> Flow<T>.collectIndexed(crossinline action: suspend (index: Int, value: T) -> Unit)

collectLatest 的一个例子

flow {
    emit(1)
    delay(50)
    emit(2)
}.collectLatest { value ->
    println("Collecting $value")
    delay(100) // Emulate work
    println("$value collected")
}

// Collecting 1 
// Collecting 2 
// 2 collected

👉尝试一下

callbackFlow

前面举的例子的都是各种数据流,在安卓中,各种 UI 控件发出的事件也可以抽象成流。比如当用户修改 EditText 内的文字时,TextWatcher 中的 afterTextChanged 方法会持续触发,通过 callbackFlow 可以将会掉转换成流。这里的样例代码稍有简化,来自唐子玄大佬的这篇文章,这个功能也有东哥封装的版本的

fun EditText.textChangeFlow(): Flow<String> = callbackFlow {  
    val watcher = object : TextWatcher {  
        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {  
        }  
  
        override fun onTextChanged(char: CharSequence?, p1: Int, p2: Int, p3: Int) {  
        }  
  
        override fun afterTextChanged(p0: Editable?) {  
            trySend(p0?.toString().orEmpty())  
        }  
    }  
    addTextChangedListener(watcher)  
    awaitClose { removeTextChangedListener(watcher) }  
}
fun <T> callbackFlow(block: suspend ProducerScope<T>.() -> Unit): Flow<T>

和普通的创建流一样,textChangeFlow 这个函数同样不是挂起的,它返回的也是一个冷流。

可以看到发送数据用的不是 emit 而是 channeltrySend(),这是因为 callbackFlow 的工作基于 ProducerScope 提供的 SendChannel,使用 Channel 也很好理解,因为 UI 事件本质上是热的,UI 事件流的产生与收集者是否存在无关,用户触发事件就会产生。

interface ProducerScope<in E> : CoroutineScope, SendChannel<E> 

awaitClose 必须被调用。它负责保持流运行,否则当代码块运行完毕后 channel 就会被关闭。awaitClose 包裹的代码块会在收集者取消或者手动调用 SendChannel.close 时调用。

sharedFlow/stateFlow

就像上一节中出现的,针对例如用户操作,状态更新这种热的事件来源,Flow 也提供了 SharedFlowStateFlow 两个热流实现。

SharedFlow 类似于广播,它独立于收集器的存在而存在,向所有收集器共享发出的值而非像普通 flow {} 创建的冷流,按需创建同一流的新实例。它是热的。共享流的收集器也被叫做订阅者(subscriber).不管有没有订阅者它都会发出值。

SharedFlow 的订阅者,无论是 collect 还是 launchIn 开启的协程永远不会正常完成,因为它永远无法完成,所以不能用 toListlast 这种终端操作符。但它们始终是可取消的,可以用 take 等截断运算符,将其转换成完成的流。

可以使用 MutableSharedFlow() 创建,

fun <T> MutableSharedFlow(
    replay: Int = 0, 
    extraBufferCapacity: Int = 0, 
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T>
  • replay 默认为 0,保留并重新发给新订阅者的值的数量
  • extraBufferCapacity 可选,默认 0,除 replay 外,额外的缓冲值,emit 在有剩余缓冲空间时不会挂起,用于快速发射器和慢速订阅者
  • onBufferOverflow 可选,配置缓冲区溢出时的 emit 操作,默认为暂停 emit 的尝试。仅当 replay > 0 或 extraBufferCapacity > 0 时可以更改默认值以外的值。

replay 只对订阅者有意义,所以整体的缓冲区至少得这么大。但是整体缓冲区的大小会影响发射器,仅当至少有一个订阅者没准备好接受新值时,会发生缓冲区溢出。此时的动作取决于 onBUfferOverflow,比如默认的暂停 emit 操作。所以当存在快速发射器和慢速订阅者的情况时,可以指定 extraBufferCapacity 额外的缓冲区,让发射器额外发送一些新的值,这样新的订阅者就不会强制获取 replay 大小内的老的缓存的值。

整体的缓存大小为 replay 以及 extraBufferCapacity 之和。

该函数返回 MutableSharedFlow 实例,可使用 asSharedFlow() 将其表示为只读的。

也可以使用 shareIn 将普通冷流转换为 SharedFlow

fun <T> Flow<T>.shareIn(scope: CoroutineScope, started: SharingStarted, replay: Int = 0): SharedFlow<T>
  • scope 启动协程的 scope
  • started 控制开始和停止共享的策略。
    • Eagerly 共享立即开始,永不停止。上游发出超过 replay 参数值的所有数据将被立刻丢弃
    • Lazily 当第一个订阅者出现时,开始共享且永不停止。保证第一个订阅者获得所有发出的值,后续订阅者保证获取最新的 replay 数量的值。即使所有订阅者都消失,上游也会继续处于活动状态。
    • WhileSubscribed 当第一个订阅者出现时开始,默认最后一个订阅者消失时停止,默认永久保留 replay 缓存。有可选参数更改后两项特性。
  • replay 决定缓存大小

如果上游流有 buffer() 的话,比如 buffer(b).shareIn(scope, started, r),则会创建一个 replay = r 且 extraBufferCapacity = b 的 SharedFlow

使用 shareIn 以及稍后提到的 stateIn 时需要注意,不要用在返回 Flow 的函数中。每次调用这个函数就会创建一个新的 SharedFlow 实例。

class UserRepository(
    private val userLocalDataSource: UserLocalDataSource,
    private val externalScope: CoroutineScope
) {
    // DO NOT USE shareIn or stateIn in a function like this.
    // It creates a new SharedFlow/StateFlow per invocation which is not reused!
    fun getUser(): Flow<User> =
        userLocalDataSource.getUser()
            .shareIn(externalScope, WhileSubscribed())    
  
    // DO USE shareIn or stateIn in a property
    val user: Flow<User> =
        userLocalDataSource.getUser().shareIn(externalScope, WhileSubscribed())
}

处理缓冲区溢出的策略除了暂停发射器,用的比较多的就是删除最旧的事件,保留最新的事件。

StateFlow 就是这种策略的一个专门实现。它有一个初始值,只保留最后一个发出的值。它相当于以下动作的 SharedFlow

// MutableStateFlow(initialValue) is a shared flow with the following parameters:
val shared = MutableSharedFlow(
    replay = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)
shared.tryEmit(initialValue) // emit the initial value
val state = shared.distinctUntilChanged() // get StateFlow-like behavior

这里引用两段唐子玄大佬这篇博客StateFlow 的描述:

StateFlow 是一个特别的 SharedFlow,它是 Kotlin Flow 中更像 LiveData 的存在。因为:

  1. StateFlow 总是会缓存1个最新的数据,上游流产生新数据后就会覆盖旧值(LiveData 也是)。
  2. StateFlow 持有一个 value 字段,可通过stateFlow.value读取最新值(LiveData 也是)。
  3. StateFlow 是粘性的,会将缓存的最新值分发给新订阅者(LiveData 也是)。
  4. StateFlow 必须有一个初始值(LiveData 不是)。
  5. StateFlow 会过滤重复值,即新值和旧值相同时不更新。(LiveData 不是)。

对于承载数据来说,Kotlin Flow 相较于 LiveData 只能说有过之而无不及:

  1. LiveData 不能方便地支持异步化。
  2. LiveData 粘性问题的解决方案虽然很多,但用起来都很变扭。
  3. LiveData 可能发生数据丢失的情况。
  4. LiveData 的数据变换能力远远不如 Flow。
  5. LiveData 多数据源的合流能力远远不如 Flow。

可以使用 stateIn 将流转换为 StateFlow

fun <T> Flow<T>.stateIn(scope: CoroutineScope, started: SharingStarted, initialValue: T): StateFlow<T>

scopestarted 参数和 shareIn 的一样,initialValue 决定初始值。

生命周期感知

上一节中说了 LiveData 各种缺点,但是 LiveData 拥有生命周期感知能力而 StateFlow 没有,因为 Flow 是通用的 Kotlin API。为了让 Flow 拥有生命周期感知能力有几种方案。

  • androidx.lifecycle:lifecycle-livedata-ktx
    • Flow<T>.asLiveData(): LiveData
  • androidx.lifecycle:lifecycle-runtime-ktx
    • Lifecycle.repeatOnLifecycle(state)
    • `Flow.flowWithLifecycle(lifecycle, state)

如果不想动 Activity 中的代码,可以在 ViewModel 中将 Flow 转为 LiveData。

或者使用 repeatOnLifecycle 在界面中收集流。它是一个挂起函数,传递 Lifecycle.State 以当生命周期到达该状态时自动创建一个协程,在低于该状态时自动取消正在运行的协程。

ps. 注意 Lifecycle.launchWhenX 方法和 Lifecycle.whenX 方法已经废弃了。其中后者可以使用 withX 系列函数替代。

// Activity
override fun onCreate(savedInstanceState: Bundle?) {
	lifecycleScope.launch {
		repeatOnLifecycle(Lifecycle.State.STARTED) {
			viewModel.uiState.collect { uiState ->
				...
			}
		}
		// 注意,只有销毁时 repeatOnLifecycle 才会返回,这里正常不要放代码了
	}
}

// Fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
	viewLifecycleOwner.lifecycleScope.launch {
		viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
			viewModel.uiState.collect { uiState ->
				...
			}
		}
	}
}

如果只收集单个流,可以使用 flowWithLifecycle 简化代码

lifecycleScope.launch {
	viewModel.uiState
		.flowWithLifecycle(lifecycle, State.STARTED)
		.collect {...}
}

后记及参考资料

因为收尾的时候,正值离职准备跳槽。有一些遗漏,也有点乱,也没有专门的重新读一下校对一下。之后有空了,补完了 Select 的内容后再整理一下 Flow 遗漏的部分。

  1. 站内博客,很全面
  2. 东哥的博客,这篇和上一篇的评论区也值得一看
  3. 京东技术的文,协程初探,兄弟们确实没老板上进
  4. 站内另一篇博客,动图很赞,文笔也很好
  5. Kotlin 官方文档
  6. Android 官方文档
  7. 官方GitHub上的文档
  8. 官方博客汇总
  9. M站博客,结构化并发
  10. M站博客,阻塞与挂起
  11. M站博客,避免使用GlobalScope
  12. 官方codelab
  13. M站博客,viewmodelScope
  14. M站博客,首要事项
  15. M站博客,协程上下文
  16. Flow codelab
  17. Flow 经验教训
  18. Flow shareIn 和 stateIn
  19. LiveData 迁移到 Flow
  20. 站内,Flow使用系列三篇
  21. 霍丙乾大佬的 Channel,Flow,Select系列三篇
  22. 油管,Roman Elizarov 的 Flow 实现异步流的演讲
  23. Roman Elizarov 的 Flow 的文章
  24. 冷热数据源
  25. JB 的 channel 介绍视频
  26. Shreyas Patil 的博客 select
  27. Android 官方 Flow 视频
  28. Roman 对结构化并发一周年的总结
  29. Roman Flow 的简单设计
  30. Roman Flow 和协程
  31. 反应流和 Kotlin Flow
  32. 回调和 Kotlin Flow