Kotlin协程(二)之基本使用及Channel/select/Flow的进阶窥探

2,961 阅读12分钟

前言

在上一篇文章我们已经了解了Kotlin语言特性中关于协程的概念和要素的运用,但在我们实际应用中,我们不会直接使用类似Continuation来创建一个协程并启动,因为Kotlin官方给出了框架级别的支持,这样大大提高了我们的开发效率。

一、协程框架概述

语言级别支持 VS 框架级别支持

语言级别支持: 主要提供了Kotlin标准库的API以及对协程提供语义上的支持,其中包括协程上下文拦截器以及挂起函数

框架级别支持:提供了更方便的上层业务开发支持,主要是提供了Job调度器作用域ChannelFlow以及Select等特性。

协程框架:kotlinx.coroutines

协程框架的引入

//协程基础库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0"
//协程Android库,提供Android UI调度
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0"

Kotlin协程的启动模式

协程框架kotlinx.coroutines通过GlobalScope.launch方法创建和启动协程:

GlobalScope.launch(start = CoroutineStart.DEFAULT){

}

start = CoroutineStart.DEFAULT代表的是通过什么方式启动协程,而Kotlin协程的启动模式主要分为以下四种:

  • DEFAULT模式: 创建协程后立即开始调度协程体,调度前若取消则直接取消(这里要注意的是当前线程通过lunch发起一个协程后,马上就返回了,当前线程继续执行自己后面的逻辑,即协程开始,发起协程的线程也继续)

  • ATOMIC: 立即开始调度,且在第一个挂起点前不能被取消

  • LAZY: 只有在需要的(start/join/await)时开始调度,类似于使用createCoroutine方法仅仅创建了一个协程,然后再需要调用的地方才启动。

  • UNDISPATCHED: 立即在当前线程执行协程体,直到遇到第一个挂起点(后面执行的流程取决于调度器),这里的意思是当前线程的调度流程是会卡住的,要等到第一个挂起点才会回来当前线程的执行流程

Kotlin协程的调度器

协程调度器的本质内涵其实就是加了封装的协程拦截器,它将协程运行在指定的线程上,也可将其分派到线程池中,或者让她无限制的运行,调度器主要有以下四种(针对Java VM平台运行的功能解析):

  • Default: 指定协程运行在非主线程,一般用于CPU密集型计算型任务,类似于一些数组排序,数据解析之类
  • Main: 指定协程运行在主线程或者在Android中进行UI线程.一般用于处理UI绘制或者一些轻量级任务
  • Unconfined: 未指定运行的线程,直接执行
  • IO: 指定协程运行在非主线程上,一般用于网络IO任务或者本地文件数据读取,如网络请求、文件读写等

协程框架中channel、Flow、以及Select特性

  • Channel: 热数据流,并发安全的通信机制,其中热数据流的意思是不主动触发,数据依旧返回来
  • Flow: 冷数据流,协程的响应式API,其中冷数据流的意思是在你需要的时候,去触发了才会有数据回来,类似于RxJava中,只有我们去订阅了才会有数据,就是有驱动。
  • Select: 可对多个挂起事件进行等待

二、协程框架的基本用法

协程框架常用的创建协程的方法主要方式是:

  • CoroutineScope.launch()
  • CoroutineScope.async()

CoroutineScope实际上是一个接口,可使用实现了CoroutineScope的一个单例GlobalScope来调用launch()或者async()方法创建一个协程作用域,而作用域内的代码块就是协程

GlobalScope.launch

GlobalScope.launch{
    println("协程当前线程:${Thread.currentThread().name}")
}

打印结果:协程当前线程:DefaultDispatcher-worker-1

当不指定协程运行在什么线程上的时候,它使用的默认的调度器:Default,拦截器是DefaultDispatcher,其打印的线程名字也是:DefaultDispatcher-worker-1,这个时候运行在非主线程上,android开发中是不可以做UI更新操作。当然,我们也可以在协程中切换线程:

GlobalScope.launch{
    println("协程当前线程:${Thread.currentThread().name}")

    withContext(Dispatchers.Main){
        //协程中切换线程,这里切换到主线程
    }
}

GlobalScope.launch(Dispatchers.Main)

使用Dispatchers.Main可以指定启动的协程运行在主线程上:

GlobalScope.launch(Dispatchers.Main){
    Log.e("协程当前线程:", Thread.currentThread().name)

    //在Android开发中,此处可更新ui
}

@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

GlobalScope是一个单例,其源码如上面所示,我们可以看到GlobalScope是没有再关联其他对象和组件的,那在Android应用中如果我们自己不去处理GlobalScope创建的协程,那这些协程只会在app进程销毁的时候才会跟着销毁,很显然这种做法是不太安全的。这里官方推荐的推荐使用的是Kotlin协程在ktx上的两个扩展库,lifecycle扩展对应的是lifecycleScope,另外一个viewModel扩展对应的是viewModelScope

lifecycleScope和viewModelScope

implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'

通过lifecycleScope创建的协程作用域具有感知生命周期的特性,它通过与Lifecycle绑定实现跟随Lifecycle销毁而销毁,在Activity/Fragment使用此协程作用域时,界面销毁该协程也被销毁了,避免了协程泄漏的问题。而viewModelScope同样具有类似的属性,它创建的协程会在ViewModel销毁时自动取消,避免造成协程泄漏引起的内存问题。

协程的执行

private fun testLifecycleScope(){
    Log.d(TAG,"testLifecycleScope start")
    lifecycleScope.launch {
        delay(2000)
        textView.text = "LifecycleScope "
        Log.d(TAG,Thread.currentThread().name)
        Log.e(TAG,"协程结束")
    }

    Log.d(TAG,"testLifecycleScope end")
}

执行结果:
com.qisan.kotlinstu.MainActivity: testLifecycleScope start
com.qisan.kotlinstu.MainActivity: testLifecycleScope end
com.qisan.kotlinstu.MainActivity: main
com.qisan.kotlinstu.MainActivity: 协程结束

这里可以看到,协程内的阻塞不会影响协程外面的执行,另外可以看到lifecycleScope.launch虽然是异步,但是它可以做UI更新操作,这和直接的线程操作是完全不一样的。

CoroutineScope.async()

async方法主要作用是获取返回值和并发运行挂起函数。

private suspend fun getContent1():String{

    delay(1000)
    return "Kotlin"
}

private suspend fun getContent2():String{

    delay(1000)
    return "协程"
}

我们实现上面两个挂起函数,然后在lifecycleScope.launch的作用域去执行它,看看它运行和耗时情况:

private fun testLifecycleScope(){
    lifecycleScope.launch {

        val startTime = System.currentTimeMillis()
        val content_1 = getContent1()
        val content_2 = getContent2()

        Log.d(TAG,"$content_1 $content_2,程序耗时:${System.currentTimeMillis() - startTime}")
    }
}

打印结果:
LifecycleScope: Kotlin 协程,程序耗时:2007

由程序耗时的结果我们可以看出来,getContent1()getContent2()两个方法是顺序执行的。那现在我们把getContent1()getContent2()两个方法的顺序调转一下,并且还是希望打印出:Kotlin 协程,那这个时候就需要用到async方法了:

private fun testLifecycleScope(){
    lifecycleScope.launch {

        val startTime = System.currentTimeMillis()
        val content_2 = lifecycleScope.async { getContent2() }
        val content_1 = lifecycleScope.async { getContent1() }

        Log.d(TAG,"${content_1.await()} ${content_2.await()},程序耗时:${System.currentTimeMillis() - startTime}")
    }
}

打印结果:
LifecycleScope: Kotlin 协程,程序耗时:1002

这里我们可以看到协程体执行的耗时只有1002毫秒,很明显getContent1()getContent2()两个方法两个方法是同时执行了。这里要注意点是async是要跟await()挂起函数相结合使用的。这里要延伸说一下lifecycleScope.async {}的返回值是Deferred类型,而Deferred 接口继承自 Job 接口:

Deferred

public interface Deferred<out T> : Job {
    public suspend fun await(): T

    public val onAwait: SelectClause1<T>

    @ExperimentalCoroutinesApi
    public fun getCompleted(): T

    @ExperimentalCoroutinesApi
    public fun getCompletionExceptionOrNull(): Throwable?
}

由于Deferred继承Job接口,所以Job相关的操作在Deferred上也可以用。

协程的取消

使用lifecycleScopeviewModelScope去创建协程作用域的时候都会跟调用者的生命周期绑定,一般情况下不用开发者手动去取消协程。我们关注一下使用MainScope()创建协程的情况,MainScope()是一个顶层函数,即它没有receiver,看一下CoroutineScope的源码:

@Suppress("FunctionName")
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

ContextScope(SupervisorJob() + Dispatchers.Main)参数里面代表的就是协程的作用域,那么它返回的就是一个上下文是SupervisorJob() + Dispatchers.Main的作用域了,因为作用在主线程,所以在android开发中很多时候用作Activity/Fragment中,当Activity销毁时调用cancel方法来取消协程。下面我们使用MainScope()创建协程作用域,并使用作用域去取消所有协程:

val mainScope = MainScope()
mainScope.launch {
    Log.d(TAG,"第一个协程")
}
mainScope.launch {
    delay(1000)
    Log.d(TAG,"第二个协程")
}

handler.postDelayed({ mainScope.cancel() },500)

打印结果:
MainScope: 第一个协程

第一个协程没有延时,所以它执行了,第二个协程延时1000ms才打印,这个时候已经被取消了。所以mainScope.cancel()取消一个协程作用域将同时取消此作用域下的所有子协程。这里要注意的是:一个已取消了的协程作用域内是不可再创建新协程的。 我们再来看看cancel()方法的源码:

public fun CoroutineScope.cancel(cause: CancellationException? = null) {
    val job = coroutineContext[Job] ?: error("Scope cannot be cancelled because it does not have a job: $this")
    job.cancel(cause)
}

可以看到当调用cancel()方法的时候是通过抛出一个异常类:CancellationException来处理取消协程的。当然,我们上面的调用是没有传的,所以它创建一个默认的CancellationException实例。

如果我们要单个取消要怎么处理呢?我们可以获取mainScope.launch{}的实例Job,通过 Job调用cancel()方法取消当前的协程。我们先看一下launch()方法实现:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

通过源码可以看到launch()返回的就是一个Job,那这样就很好办了:

val mainScope = MainScope()
val job1 = mainScope.launch {
    Log.d(TAG,"第一个协程")
}

val job2 = mainScope.launch {
    delay(1000)
    Log.d(TAG,"第二个协程")
}
job1.cancel()
打印结果:
MainScope: 第二个协程

通过打印的结果看到,第一个协程是被取消,第二个依旧是执行的,所以当一个协程被取消了不会影响到它同层级的协程。

上面mainScope.launch是在Activity调用的,它默认的调度器Dispatchers.Main),如果我们把调度器改成Dispatchers.Default会变成什么样呢?

val mainScope = MainScope()
val job1 = mainScope.launch(Dispatchers.Default) {
    Log.d(TAG,"第一个协程")
}
val job2 = mainScope.launch {
    delay(1000)
    Log.d(TAG,"第二个协程")
}
job1.cancel()

打印结果:
MainScope: 第一个协程
MainScope: 第二个协程

很可爱,job1取消后,协程体还是执行了打印。这是因为Dispatchers.Default方式的调度会等待协程任务处理完才会取消。当我们调用cancel()方法的时候,协程会进入到cancelling的状态,这个时候协程任务依旧会在进行。那么怎么让协程可以被取消呢?我们可以在在处理协程体内部逻辑的时候检查协程是否已取消。

ensureActive()方法协作取消协程

public fun Job.ensureActive(): Unit {
    if (!isActive) throw getCancellationException()
}

ensureActive()方法非常便捷,它会在Job处于不活跃时立即抛出异常,如果正常就继续往下执行。

val mainScope = MainScope()
val job1 = mainScope.launch(Dispatchers.Default) {
    delay(50)
    ensureActive()
    Log.d(TAG,"第一个协程")
}
val job2 = mainScope.launch {
    delay(1000)
    Log.d(TAG,"第二个协程")
}
job1.cancel()
打印结果:
MainScope: 第二个协程

这个时候我们对job1取消就起到效果了。当然,我们也可以再协程作用域内直接使用isActive判断协程是否已取消。

yield()方法协作取消协程

yield()方法的可以用来检查Job是否完成,如果Job完成,则通过抛出CancellationException异常来退出协程。直接看一下代码:

val job1 = mainScope.launch(Dispatchers.Default) {
    delay(50)
    yield()
    Log.d(TAG,"第一个协程")
}

val job2 = mainScope.launch {
    delay(1000)
    Log.d(TAG,"第二个协程")
}

job1.cancel()

打印结果
MainScope: 第二个协程

yield()方法的主要功能不仅仅是用来检查Job是否完成,它还有挂起当前任务,释放此线程给其他任务去获取执行权,具体的探究这里就先不赘述了,后面再深入研究。

join()方法

public suspend fun join()

严格来说,join()是一个顶层函数哈,并且是一个suspend函数,所以它只能在协程的作用域内调用。join()函数的作用是暂停所在的 Coroutine直到这个Coroutine执行完再顺序执行其他逻辑代码,所以join 函数一般出现在另外一个Coroutine(就是作用域),看下面d代码:

val mainScope = MainScope()
mainScope.launch {

    val job2 = mainScope.launch {
        delay(1000)
        Log.d(TAG,"第二个协程")
    }
    job2.join()
    val job1 = mainScope.launch(Dispatchers.Default) {
        Log.d(TAG,"第一个协程")
    }
}
打印结果:
MainScope: 第二个协程
MainScope: 第一个协程

这里要注意的是调用join()函数的job只有在其所有子级的任务完成时,这个挂起等待的任务才能算完成。另外,当调用job.join()时,job所处的作用域(Coroutine)被取消了或者已经完成,这时候join()将会抛出CancellationException异常。

三、Channel

Channel的概念

  • 非阻塞的通信基础设施,在协程之间完成消息传递
  • 通信通道类似于阻塞队列(BlockingQueue)+挂起函数. (BlockingQueue的主要是两个方法是:E take()put(E e)E take(): 取走BlockingQueue里排在首位的对象,若BlockingQueue为空, 阻塞进入等待状态直到BlockingQueue有新的数据被加入;put(E e): 把对象 e 加到BlockingQueue里, 如果BlockQueue没有空间,则调用此方法的线程被阻塞,直到BlockingQueue里面有空间再继续)

Channel的分类

  • RENDEZVOUS: send调用后挂起直到receive到达
  • UNLIMITED: 无限容量,send调用后消息存在Channel并直接返回
  • CONFLATED: 保留最新,即Channel里面只放最后一个message,那receive也只能获得最近一次send的值(应用的情景如更新下载进度)
  • BUFFERED: 默认容量,可通过程序参数设置默认大小,默认为64
  • FIXED: 固定容量,通过参数执行缓存大小,超过缓存大小send就挂起

Channel的使用

RENDEZVOUS方式

直接上代码:

suspend fun testChannel(){
    val channel = Channel<Int>(Channel.RENDEZVOUS)

    val jobProducer = GlobalScope.launch {
        for(i in 1..2){
            println("sending $i")
            channel.send(i) //如果没有receive,那么send就会挂起
            println("sent $i")
        }
        channel.close()
    }

    val jobConsumer = GlobalScope.launch {
        while (!channel.isClosedForReceive){
            println("receiving")
            val value = channel.receiveCatching().getOrNull() //如果没有channel在send,receive也会挂起
            println("received $value")
        }
    }
    jobProducer.join()
    jobConsumer.join()
}
打印结果:
sending 1
receiving
sent 1
sending 2
received 1
receiving
received 2
receiving
sent 2
received null

这个send和receive的顺序看起来是不一致,这是因为他们运行在两个线程,但是能确保的是只有receive接受到值后send才能执行。

UNLIMITED方式

UNLIMITED方式是不管有没有receive在消费消息,只管发消息,send完就直接返回,但这里还是要注意不能无限往Channel通道塞消息,这样内存消耗非常大,类似于Android 开发中的RemoteView刷新添加action一样,其它使用基本跟上面的RENDEZVOUS方式一样,这里就不再举例了。

CONFLATED方式

CONFLATED方式是保留最后的消息在Channel通道,我们把上面例子的Channel的类型改成CONFLATED,这个时候jobConsumer应该只会接受到2:

val channel = Channel<Int>(Channel.CONFLATED)
val jobProducer = GlobalScope.launch {
    for(i in 1..2){
        println("sending $i")
        channel.send(i)
        println("sent $i")
    }
    channel.close()
}

val jobConsumer = GlobalScope.launch {
    while (!channel.isClosedForReceive){
        println("receiving")
        val value = channel.receiveCatching().getOrNull()
        println("received $value")
    }
}

jobProducer.join()
jobConsumer.join()

打印结果:
sending 1
sent 1
sending 2
sent 2
receiving
received 2

看运行结果,send是都send出去了,但received的value只有2。

BUFFERED方式和FIXED方式

这两种方式主要是指定Channel通道的大小,可以存放多少消息,其他的特性和RENDEZVOUS方式类似。

Channel的关闭

  • 调用close关闭Channel
  • 关闭后调用send抛异常,isClosedForSend返回true
  • 关闭后调用receive可接收缓存的数据
  • 缓存数据消费完后receive抛异常,isClosedForReceive返回true

Channel的迭代

因为Channel属于BlockingQueue,只是它支持挂起函数,所以一个集合类当然可以做for循环的迭代了。在上面的jobConsumer也可以使用迭代了接收消息,这里有两种方式:

for循环迭代

for (i in channel){
    println("received $i")
}

使用Iterator迭代

val iterator = channel.iterator()
while (iterator.hasNext()){
    val e = iterator.next()
    println("receive $e")
}

这里的hasNext等于代替了!channel.isClosedForReceive的判断:

  • 当有缓存的数据返回时,hasNext返回true
  • 当未关闭且缓存为空时挂起
  • 当正常关闭且缓存为空时返回false

Channel的协程Builder

协程框架中提供了CoroutineScope.produceCoroutineScope.actor方法来构造发送消息的生产者与接收消费者。

  • CoroutineScope.produce: 启动一个生产消费者协程,返回ReceiveChannel,其他协程就可以使用这个Channel来接收数据了
  • CoroutineScope.actor: 启动一个消费者协程,返回一个SendChannel,其他生产者协程可以用来发送消息
  • 以上两种Builder方式启动的协程结束后自动关闭对应的Channel

CoroutineScope.produce使用:

suspend fun producer(){
    
    //启动一个生产者协程,发送消息,返回接收消息的通道Channel
    val receiveChannel = GlobalScope.produce<Int>(capacity = Channel.UNLIMITED) {
        for (i in 1..2){
            println("sending $i")
            send(i)
            println("sent $i")
        }
    }
    //接收
    val jobConsumer = GlobalScope.launch {
        for (i in receiveChannel){
            println("received $i")
        }
    }
    jobConsumer.join()
}

打印结果:
sending 1
sent 1
sending 2
sent 2
received 1
received 2

CoroutineScope.actor使用:

suspend fun consumer(){
    //启动一个消费者协程,接收,返回发送的通道Channel
    val sendChannel = GlobalScope.actor<Int>(capacity = Channel.UNLIMITED) {
        for (i in this){
            println("received $i")
        }
    }

    val jobProducer = GlobalScope.launch {
        for (i in 1..2){
            println("sending $i")
            sendChannel.send(i)
            println("sent $i")
        }
    }

    jobProducer.join()
}

打印结果:
sending 1
sent 1
sending 2
sent 2
received 1
received 2

BrodcastChannel

  • Channel的元素只能被一个消费者消费
  • BrodcastChannel的元素会分发给所有的订阅者
  • BrodcastChannel不支持RENDEZVOUS方式,因为BrodcastChannel是一对多的模式

创建BrodcastChannel的方式:

//通过一般channel强转
val channel = Channel<Int>(Channel.BUFFERED)
val broadcastChannel = channel.broadcast()

//直接通过BroadcastChannel构造
val broadcastChannel = BroadcastChannel<Int>(Channel.BUFFERED)

//通过Channel协程的builder模式构造
val broadcastChannel = GlobalScope.broadcast<Int> {
}

一般情况我们会通过CoroutineScope.broadcast的方式创建一个BrodcastChannel,这样方便订阅者接受,看下面代码:

val broadcastChannel = GlobalScope.broadcast<Int> {
    for (i in 1..2){
        send(i)
    }
}

val jobConsumer_1 = GlobalScope.launch {
    val receiveChannel_1 = broadcastChannel.openSubscription()
    for (i in receiveChannel_1){
        println("receiveChannel_1 value:$i")
    }
}

val jobConsumer_2 = GlobalScope.launch {
    val receiveChannel_2 = broadcastChannel.openSubscription()
    for (i in receiveChannel_2){
        println("receiveChannel_2 value:$i")
    }
}

jobConsumer_1.join()
jobConsumer_2.join()

打印结果:
receiveChannel_1 value:1
receiveChannel_2 value:1
receiveChannel_1 value:2
receiveChannel_2 value:2

可以看到每一个消费者订阅后都拿到了生产者发送的消息。

Select-多路复用

Select的概念

  • Select是一个IO多路复用的概念,而多路复用就是用一条信道同时传输多路信号
  • 协程的Select用于挂起函数的多路复用

多个Channel的复用

多个Channel复用的情况就是在多个Channel中有异步任务在执行,谁先返回来发送消息就执行谁,这种情况下我们一般只能去顺序读取,一个一个的去等,效率是非常低的。Select的好处就是可以优先读取处理最快发送过来Channel消息。下面模拟一下多个channel复用情况:

val channelList = listOf(Channel<Int>(),Channel<Int>(),Channel<Int>())
GlobalScope.launch {
    delay(200)
    channelList[0].send(200)
}

GlobalScope.launch {
    delay(100)
    channelList[1].send(100)
}

GlobalScope.launch {
    delay(400)
    channelList[2].send(400)
}

val result = select<Int> {
    channelList.forEach{ channel ->
        channel.onReceive{
            it
        }
    }
}
println(result)

打印结果:
100

我们可以看到顺序下来,send(100)Channel是第二个通道,但它是最先返回来的,所以Select会接收到最快的Channel消息。

await的多个复用

await的多个复用适合我们用在网络的请求的时候,我们可以模拟一下分别从缓存中和网络中请求数据的情况,谁先返回就用谁,上代码:

//模拟缓存中拿数据,缓存中拿肯定快,延时100ms
fun CoroutineScope.getDataFromCache() = async {
    delay(100)
    "getDataFromCache"
}

//模拟网络拿数据,延时500s返回
fun CoroutineScope.getDataFromNet() = async {
    delay(500)
    "getDataFromNet"
}
//执行代码
suspend fun main() {
   
    GlobalScope.launch {
        val dataFromCache = getDataFromCache()
        val dataFromNet = getDataFromNet()
        //使用select来等待
        val result = select<String>{
            dataFromCache.onAwait{
                it
            }
            dataFromNet.onAwait{
                it
            }
        }
        println(result)
    }.join()
}
打印结果:
getDataFromCache

这里要注意的是select是优先去最快到的,如果多个同时到达,那select会根据Channel的顺序优先取第一个。也因为如此,官方还给出了另外一个Api:selectUnbiasedUnbiased的意思是没有偏见的,如果多个消息同时到达,selectUnbiased会随机选一个。

四、Flow

Flow的概念

Flow是一种异步的数据流,会按顺序发出值并完成,它是Kotlin协程的响应式API,是与响应式编程相结合的产物。

Flow的内部是按照顺序执行的,这跟序列生成器sequence基本是一致的。而FlowSequence的最大区别就在于Flow不会阻塞主线程,因为Flow完全是使用协程构建的,通过使用协程的suspendresume机制,可以将生产方flow的执行与使用方collect同步,而Sequence会阻塞主线程。

Flow的创建方式

  • flow builder: 直接调用flow<T>{},在给定的一个挂起函数创建一个冷数据流
  • flowOf(vararg elements: T): 使用可变数组快速创建flow
  • asFlow(): 将其他数据转换成普通的flow,例如ListFlow的转换
  • channelFlow(): 支持缓冲通道,线程安全,允许不同的CorotineContext发送事件

创建Flow

val mFlow = flow<Int> {
    (1..2).forEach{
        emit(it)
        delay(200)
    }
}

emit函数是提供元素给消费者,而且Flow的代码块内部也可以调用其他挂起函数。

Flow也可指定它运行时使用的调度器:

//intFlow的构造逻辑会在IO线程上执行
mFlow.flowOn(Dispatchers.IO)

消费Flow

消费mFlow只要调用collect函数,该函数也是一个挂起函数:

mFlow.flowOn(Dispatchers.IO).collect {
    println(it)
}

打印:
1
2

比较链式顺序的调用,我们这样写就好了:

val mFlow = flow<Int> {
    (1..2).forEach{
        emit(it)
        delay(200)
    }
}.flowOn(Dispatchers.IO).collect {
    println(it)
}

和RxJava的线程切换对比

RxJava使用的是observeOnsubscribeOn来切换线程,而Flow则相对来说更简单,只需使用flowOnflowOn 与 subscribeOn是对应的,而collect 所在协程的调度器则与 observeOn 指定的调度器对应。

冷数据流

冷数据流其实类似于懒汉式的设计模式,就是Flow被创建之后,不消费则不生产,多次消费则多次生产,生产和消费相对应的。这一点和Channel刚好是正对应:Channel 的发送端并不依赖于接收端。

异常处理

Flow 的异常处理可直接调用catch函数,也可以使用传统的try...catch来捕获异常:

val mFlow = flow<Int> {
    (1..2).forEach{
        emit(it)
        throw RuntimeException()
    }
}.catch {
    t ->
    println("RuntimeException: $t")
}.collect {
    println(it)
}

打印结果:
1
RuntimeException: java.lang.RuntimeException

数据流完成时的操作

看下面代码:

flow<Int> {
    (1..2).forEach{
        emit(it)
        throw RuntimeException()
    }
}.catch {
    t ->
    println("RuntimeException: $t")
}.onCompletion {
    t ->
    println("finally")
}.collect { 
    println(it)
}

onCompletion是类似 try ...catch...finally中的finally,无论前面是否存在异常,它在数据流完成时最终都会被调用,参数t则是前面未捕获的异常。

末端操作符

collect就是一个最基本的末端操作符,除此之外,其他常见的末端操作符还分为两大类:

  • 集合类型转换操作,包括 toListtoSet
  • 聚合操作,包括将Flow规约到单值的 reducefold 等操作,以及获得单个元素的操作包括 singlesingleOrNullfirst 等

transform

transform操作符,可任意多次调用emit,并发射任何值:

(1..3).asFlow()
    .transform {
        //多次调用
        emit(it)
        delay(200)
        //发射String的值
        emit("value $it")
    }.collect { println(it) }
    
打印结果:
1
value 1
2
value 2
3
value 3

take

take操作符只取指定前几个emit发射过来的值:

(1..3).asFlow()
    .take(2)
    .collect {
        println(it)
    }

打印结果:
1
2

reduce

reduce操作符可以对集合进行计算,具体就是当前两个元素操作获得值再跟下一个元素按逻辑执行,知道最后一个元素,并得到最终值。

val sum = (1..5).asFlow()
    .reduce { a, b ->
        a + b
    }

println(sum)

打印结果:
15

得到最红的结果是15,其实就是1加5。

fold

fold操作符类似于Kotlin集合的fold函数,fold也需要设置初始值。

val sum = (1..5).asFlow()
    .fold(0) { a, b -> a + b }

println(sum)

打印结果:
15

zip

zip操作符可以将两个个flow进行合并。

val flowA = (1..3).asFlow()
val flowB = flowOf("A", "B", "c")
flowA.zip(flowB){ a,b->
    "$a -> $b" //这里的值就代表每一个对应合并的值
}.collect{
    println(it)
}

打印结果:
1 -> A
2 -> B
3 -> c

combine

combine操作符也是合并的操作,但跟zip有一些不一样的就是combine合并时,flowA最新的发射出的值只会与flowB最新发射出的值合并,而不是根据顺序一一对应。

val flowA = (1..3).asFlow().onEach { delay(100) }
val flowB = flowOf("A", "B", "C").onEach { delay(200) }
flowA.combine(flowB) { a, b ->
    "$a -> $b"
}.collect {
    println(it)
}

打印结果:
1 -> A
2 -> A
3 -> A
3 -> B
3 -> C

flattenMerge

flattenMerge操作符是将flow单个执行完。

val flowA = (1..3).asFlow()
val flowB = flowOf("A", "B", "C")

flowOf(flowA, flowB)
    .flattenMerge()
    .collect {
        println(it)
    }
    
打印结果:
A
1
2
3
B
C

如果我们要顺序执行完,只要换成flattenConcat方法就行。

操作符就介绍到这里,还有其他操作符就不一一介绍了。

flow消费和触发的分离

除了通过collect消费Flow的元素外,onEach也可以被用来消费Flow的元素。用onEach消费Flow元素的好处就是不需要与末端操作符放到一起,collect函数可以放到其他任意位置调用,这样Flow的消费和触发就分离了

suspend fun main() {
    getFlow().collect()
}

fun getFlow() = flow<Int> {
    (1..3).forEach {
        emit(it)
        delay(200)
    }
}.onEach {
    println(it)
}

Flow的取消

Flow没有提供取消操作的方法。那我们是不是就不能取消Flow呢?当然不是~因为Flow的消费依赖于collect这样的末端操作符,而它们又必须在协程当中调用,因此Flow的取消主要依赖于末端操作符所在的协程的状态。

val job = GlobalScope.launch {
    val mFlow = flow {
        (1..3).forEach {
            delay(500)
            emit(it)
        }
    }

    mFlow.collect { println(it) }
}

delay(1500)
job.cancelAndJoin()

打印结果:
1
2

从运行结果看,要取消Flow只需要取消它所在的协程即可。

channelFlow函数创建Flow

hannelFlow函数创建Flow对比其他方式最大的一点就是它在生成元素时切换调度器:

channelFlow {
  send(1)
  withContext(Dispatchers.IO) {
    send(2)
  }
}

Flow的背压

背压问题是响应式编程的痛点,其主要在生产者的生产速率高于消费者的处理速率时出现。对于解决背压问题,我们可以考虑增加缓存来保证数据的不丢失。

Flow添加缓存

flow<Int> {
    (1..100).forEach {
        emit(it)
    }
}.buffer()

单纯地给Flow添加缓存是个指标不治本的办法,因为数据积压的隐患依然是存在的。问题根本还是生产和消费速率的不匹配,除直接优化消费者的性能以外,也可以采取一些取舍的手段。第一种是 conflate。与 Channel 的 Conflate 模式一致,新数据会覆盖老数据。观察下面代码:

flow<Int> {
    (1..100).forEach {
        emit(it)
    }
}.conflate().collect {
    delay(100)
    println("collect value $it")
}

打印结果:
collect value 1
collect value 100

发送元素是延时的,所以很快就发完了,但接收到的元素只有两个。

另外一种方式:collectLates

collectLates方式只处理最新数据,但和conflate不同的是它并不会直接用新数据覆盖老数据,而是每一个数据都会去处理,只是前一个数据还没被处理完后一个就来了的话,处理前一个数据的逻辑就会被取消。

flow<Int> {
    //方便展示结果,只发送5个数据
    (1..5).forEach {
        emit(it)
    }
}.collectLatest{
    println("Collecting $it")
    delay(100)
    println("$it collected")
}

运行结果:
Collecting 1
Collecting 2
Collecting 3
Collecting 4
Collecting 5
5 collected

这里可以看懂Collecting 输出了1~5的所有结果,而但collected只有5,这是后面的数据到达时,处理上一个数据的操作正好被delay挂起了。除了collectLatest 之外还有 mapLatestflatMapLatest都具有这个作用。

Flow的变换

Flow 的元素变换

使用mapFlow的元素进行变换,跟listmap转换基本一致:

flow<Int> {
    (1..3).forEach {
        emit(it)
    }
}.map {
    it*2
}.collect(){
    print("$it,")
}
运行结果:
2,4,6,

Flow 的嵌套和拼接

flow {
    (1..3).forEach { emit(it) }
}.map {
    flow {
        (1..it).forEach { emit(it) }
    }
}

这里得到的是一个数据类型为FlowFlow,有3个Flow,其内部分别发送的1、2、3个元素,我们再使用flattenConcat把这几个Flow拼接合并起来:

flow {
    (1..3).forEach { emit(it) }
}.map {
    flow {
        (1..it).forEach { emit(it) }
    }
}.flattenConcat()
    .collect { println(it) }
    
运行结果:
1
1
2
1
2
3

Flow实现多路复用

Flow实现对await的多路复用

还是用回select中模拟本地和网络获取数据的情况:

//模拟从缓存中拿数据
fun CoroutineScope.getDataFromCache() = async {
    delay(100)
    "getDataFromCache"
}

//模拟网络拿数据,延时500s返回
fun CoroutineScope.getDataFromNet() = async {
    delay(500)
    "getDataFromNet"
}

suspend fun main() {
    
    GlobalScope.launch {
        listOf(::getDataFromCache, ::getDataFromNet)
            .map { function ->
                function.call()
            }.map { deferred ->
                flow { emit(deferred.await()) }
            }.merge()
            .collect {
                println(it)
            }
    }.join()
}

运行结果:
getDataFromCache
getDataFromNet

这里和select不同的是,Flow会把两个数据都收集起来。

小结

至此,本文关于协程的基本使用和部分进阶的学习就到此结束了,不知不觉写了这么多。当然,协程的知识点,尤其是难点也还有很多,后续要继续深入学习探索。本文主要是记录学习协程的过程,如果大家翻阅到有帮助,那就真的是好。接下来会边继续深入学习Kotlin和在实际项目应用,有时间也会分享更多Kotlin学习的经历!