前言
上一篇关于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个任务同时执行,并且返回先执行完的。
select和Deferred配合使用
上面代码虽然可以实现功能,但是引入了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是如何选取里面跑的最快的原理我们不做深入探究,我们单从上面名字来看await和onAwait来看出一点端倪,我们平时使用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
这样我们就写出了符合业务的代码了。
select和Channel配合使用
上面我们以select和Deferred结合使用来获取最快的那个,这里我们再进阶一下,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被关闭后,导致的异常。 - 最后就是这里的
send、select都是挂起函数,所以这里的思考的时候就需要发挥一点想象力。首先第一次调用select来选择时,发现没得选,都没有返回,这时就会挂起。等待一会后,channel1就会回调第一个数据A,然后select得到数据。紧接着,又是第二次调用select,依旧会等待,又过了一会,channel2管道发送了一个数据。
所以这个过程就是俩根管道同时往一个池子中放球,而select就是旁边拿球的人,每调用一次就来取一次。
总结
本篇文章介绍了非常有实际用途的select组件,当我们在日常开发中遇到了异步选择难题,可以使用select来简化我们的代码。