协程(11) | select简单使用

6,489 阅读4分钟

前言

上一篇关于Flow的文章,其实只是Flow的入门使用,想让Flow真正完全发挥出它的作用,在了解其原理后,我们再了解一些其他扩展的Flow知识才可以。

本篇文章我们来说一个还在实验性值的特性:select,这个东西在实际业务中很有用,尤其是配合async方法和Channel,这里会说为什么和Flow没关系,那是因为Flow涉及的东西太多了,不需要select,后面我们专门来说。

正文

select的直接翻译就是选择,那它到底选择的是什么呢?

我们可以思考一个业务场景,就是一个展示商品信息的页面,但是这个信息可以从网络获取,也可以从本地缓存获取(之前浏览过),为了不让页面出现空白,我们大可先显示缓存信息,等网络数据返回后,再显示网络最新信息。

我们先写出下面测试代码:

/**
 * 模拟从缓存中获取物品信息
 * @param productId 商品Id
 * @return 返回物品信息[Product]
 * */
suspend fun getCacheInfo(productId: String): Product {
    delay(100L)
    return Product(productId, 9.9)
}

/**
 * 模拟从网络中获取物品信息
 * @param productId 商品Id
 * @return 返回物品信息[Product]
 * */
suspend fun getNetworkInfo(productId: String): Product {
    delay(200L)
    return Product(productId, 9.8)
}

/**
 * 模拟更新UI
 * @param product 商品信息
 * */
fun updateUI(product: Product) {
    println("${product.productId}==${product.price}")
}

data class Product(val productId: String,val price: Double)

这里我们模拟网络请求和获取缓存信息,由于缓存较快,我们模拟了100ms,网络是200ms,按照之前的思路,我们写出如下代码:

fun main(){
    runBlocking {
        val startTime = System.currentTimeMillis()
        val productId = "11211"
        val cacheProduct = getCacheInfo(productId)
        updateUI(cacheProduct)
        val networkProduct = getNetworkInfo(productId)
        updateUI(networkProduct)
        println("total time : ${System.currentTimeMillis() - startTime}")
    }
}

这里会先显示缓存信息,再显示网络信息,打印如下:

11211==9.9
11211==9.8
total time : 317

但是这里有个问题,就是getCacheInfo是一个挂起函数,假如该函数出现了问题,或者返回时间非常长,这时用户就会等待很久,或者直接显示不出来。

这时,我们就面临选择了,也就是这2个网络请求,谁更快,我就是使用哪个。由于是在2个协程中执行的任务,想要获取哪个快,就必须加入一个flag变量,来判断是否有一个已经完成了,然后通过while循环来获取值,代码如下:

fun main(){
   runBlocking {
       val startTime = System.currentTimeMillis()
       val productId = "11211"
       var finished = false
       var product: Product? = null
       //开始2个非阻塞任务
       val cacheDeferred = async { getCacheInfo(productId) }
       val networkDeferred = async { getNetworkInfo(productId) }
       //开启2个非阻塞任务获取结果
       launch {
           product = cacheDeferred.await()
           finished = true
       }

       launch {
           product = networkDeferred.await()
           finished = true
       }
       //等待哪个先执行完
       while (!finished){
           delay(1)
       }
       //显示UI
       product?.let { updateUI(it) }
       println("total time : ${System.currentTimeMillis() - startTime}")
   }
}

上面代码直接看注释就能明白,是谁先返回,就用哪个。打印如下:

11211==9.9
total time : 129

可以发现耗时为129ms,说明确实是2个任务同时执行,并且返回先执行完的。

selectDeferred配合使用

上面代码虽然可以实现功能,但是引入了flag,并且代码非常多,这时就需要select的使用了,使用select优化完代码如下:

fun main(){
    runBlocking {
        val startTime = System.currentTimeMillis()
        val productId = "11211"
        val product = select {
            async { getCacheInfo(productId) }
                .onAwait{
                    it
                }

            async { getNetworkInfo(productId) }
                .onAwait{
                    it
                }
        }
        updateUI(product)
        println("total time : ${System.currentTimeMillis() - startTime}")
    }
}

上述用select优化完,打印如下:

11211==9.9
total time : 132

这里我们直接使用select包裹多个Deferred对象,然后在里面的对每个Deferred对象不是使用我们之前常用的await()挂起函数来获取结果,还是使用onAwait来获取结果,这里要注意。

关于这个select是如何选取里面跑的最快的原理我们不做深入探究,我们单从上面名字来看awaitonAwait来看出一点端倪,我们平时使用onXXX方法时,一般都是认为是一个回调,而这里的意思也是类似的,即多个Deferred有哪个回调了,就把值回调出去,从而找到最快的。

上面代码虽然可以完美获取运行最快的Deferred,但是业务上还是有点问题,即假如缓存返回很快、网络返回较慢,我们只会显示缓存的,无法显示最新的网络的信息,所以我们只需要稍微修改一下,判断数据是否来自缓存即可,修改如下:

/**
 * 数据类,来表示一个商品
 * @param isCache 是否是缓存数据
 * */
data class Product(val productId: String,val price: Double,val isCache: Boolean = false)

先给数据类加个属性,表示是否是缓存,这里我们给isCache设置的是val变量,这里是遵循Kotlin的不变性原则:类尽量要减少对外暴露不必要api,以减少变化可能性。同时数据类的赋值,尽量都是使用构造函数,而不要单独去修改变量值。

那如何修改这个isCache变量呢?建议使用copy的方式,比如优化后的代码:

fun main(){
    runBlocking {
        val startTime = System.currentTimeMillis()
        val productId = "11211"
        val cacheDeferred = async { getCacheInfo(productId) }
        val networkDeferred = async { getNetworkInfo(productId) }
        val product = select {
            cacheDeferred.onAwait{
                    //这里有变化
                    it.copy(isCache = true)
                }

            networkDeferred.onAwait{
                    it.copy(isCache = false)
                }
        }
        updateUI(product)
        println("total time : ${System.currentTimeMillis() - startTime}")
        //如果当前是缓存信息,则再去获取网络信息
        if (product.isCache){
            val latest = networkDeferred.await()
            updateUI(latest)
            println("all total time : ${System.currentTimeMillis() - startTime}")
        }
    }
}

这里我们在缓存和网络中获取数据时,给加了判断,这里使用copy来减少数据变化性,同时在后面进行判断,是否是缓存信息,如果是的话,再去获取网络信息,打印如下:

11211==9.9
total time : 134
11211==9.8
all total time : 235

这样我们就写出了符合业务的代码了。

selectChannel配合使用

上面我们以selectDeferred结合使用来获取最快的那个,这里我们再进阶一下,select还可以和Channel一起使用。

我们想一下,Channel是一个密闭的管道,用于协程间的通信,这时假如有多个管道,就类似于上面例子中每个协程会返回多个值,这时我们可以选择合适的值。

至于问为什么Flow不支持用select,只能说Flow本身涉及的东西非常多,官方库已经为其设计了类似的API了,这个我们后面遇到了细说。

我们以一个简单例子来看看如何使用:

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    //开启一个协程,往channel1中发送数据,这里发送完 ABC需要450ms,
    val channel1 = produce {
        delay(50L)
        send("A")
        delay(150)
        send("B")
        delay(250)
        send("C")
        //延迟1000ms是为了这个Channel不那么快close
        //因为produce高阶函数开启协程,当执行完时,会自动close
        delay(1000)
    }
    //开启一个协程,往channel2中发送数据,发送完abc需要500ms
    val channel2 = produce {
        delay(100L)
        send("a")
        delay(200L)
        send("b")
        delay(200L)
        send("c")
        delay(1000)
    }

    //选择Channel,接收2个Channel
    suspend fun selectChannel(channel1: ReceiveChannel<String>, channel2: ReceiveChannel<String>): String =
        select {
            //这里同样使用类onXXX的API
            channel1.onReceiveCatching {
                it.getOrNull() ?: "channel1 is closed!"
            }
            channel2.onReceiveCatching {
                it.getOrNull() ?: "channel2 is closed!"
            }
        }

    //连续选择6次
    repeat(6) {
        val result = selectChannel(channel1, channel2)
        println(result)
    }

    //最后再把协程取消,因为前面设置的有1000ms延迟
    channel1.cancel()
    channel2.cancel()

    println("Time cost: ${System.currentTimeMillis() - startTime}")
}

上面代码的运行结果如下:

A
a
B
b
C
c
Time cost: 553

首先我们分析一下结果,耗时是553,这说明select在选择时,2个Channel交替给出数据,这也就是并发的体现。其次就是上面代码的理解,有一些注释需要仔细看看,现在来简单说明一下:

  • produce函数的返回值,以及为什么要在最后delay(1000)。我们看一下函数定义:

        public fun <E> CoroutineScope.produce(
            context: CoroutineContext = EmptyCoroutineContext,
            capacity: Int = 0,
            @BuilderInference block: suspend ProducerScope<E>.() -> Unit
        ): ReceiveChannel<E> =
            produce(context
                , capacity
                , BufferOverflow.SUSPEND
                , CoroutineStart.DEFAULT
                , onCompletion = null
                , block = block)
    

这个函数的返回值类型是ReceiveChannel,根据Channel章节的学习,我们知道这个是用来接收数据的。而block高阶函数类型的接收者是ProducerScope,定义如下:

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

    public val channel: SendChannel<E>
}

这是一个接口,接口中有一个默认实现的channel,所以我们可以在block代码中调用send方法来发送数据。

同时该函数会启动一个协程,然后把数据发送到返回值的ReceiveChannel中,当发送完毕后,会调用close方法,这也就是为什么我们要在后面delay(1000)的原因,是防止我们取数据的时候,这个channel已经关闭了。

  • selectChannel()方法中中,我们使用select包裹了2个channel,注意这里我们使用的方法回调是onReceiveCatching,当然它也有onReceive方法,之所以这样是因为可以防止channel被关闭后,导致的异常。
  • 最后就是这里的sendselect都是挂起函数,所以这里的思考的时候就需要发挥一点想象力。首先第一次调用select来选择时,发现没得选,都没有返回,这时就会挂起。等待一会后,channel1就会回调第一个数据A,然后select得到数据。紧接着,又是第二次调用select,依旧会等待,又过了一会,channel2管道发送了一个数据。

所以这个过程就是俩根管道同时往一个池子中放球,而select就是旁边拿球的人,每调用一次就来取一次。

总结

本篇文章介绍了非常有实际用途的select组件,当我们在日常开发中遇到了异步选择难题,可以使用select来简化我们的代码。