Kotlin 协程总结

0.背景

干啥事总要有一个背景和目的,就像我写方案一样,一定有一个目标才能驱使前行。

因为换了新公司,新公司卷的一笔,平时很少有时间总结。这不快三个月了吗,抽点时间总结一下。

写这个文章的目的也是因为公司项目中有一个模块大量使用到了 Kotlin 协程。但是我对协程的理解和实际应用又比较少。整好前段时间看到扔物线推出了协程课程,也想着花一些钱去买个课。但后来想想也要不少钱,就花了十几块钱买了另外一个平台"朱涛"老师的 Kotlin 课。

认真看下来,着实让我收获到很多。特别是其中提到的各种思维模型的建立,表达式思维、空安全思维等,让我对技术的理解有了更高的维度。

本文章就将其中学习的协程部分总结出来,目的是方便学习协程的人可以快速的当做笔记来查看。课程中包括的内容很多,我这里只是将其中部分记录下来,方便回忆。

1. Android 中调试协程

arduino 复制代码
 System.setProperty("kotlinx.coroutines.debug", "on" )

2. launch 方法解释

kotlin 复制代码
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job { ... }
  1. CoroutingContext 是协程的上下文,默认是 EmptyCoroutineContext,一般使用 Dispatchers
  2. CoroutineStart 协程的启动方式,有 DEFAULT, LAZY, ATOMIC, UNDISPATCHED 几种
kotlin 复制代码
block: suspend CoroutineScope.() -> Unit

表示 block 是接收者是 CoroutineScope 的挂起函数,简单理解就是 CoroutineScope 的扩展函数。

使用 launch 需要协程的作用域,例如如下:

markdown 复制代码
GlobalScope.launch{

    
}

GlobalScope 就是协程的作用域

3. runBlocking

runBlocking 启动的协程会阻塞当前线程的执行,例如:

scss 复制代码
binding.mainBtn.setOnClickListener {
    println(11111)
    runBlocking {
        launch {
            println(1)
            println(Thread.currentThread().name)
            delay(1000)
            println(2)
        }
        launch {
            println(3)
            println(Thread.currentThread().name)
            delay(500)
            println(4)
        }
    }
    println(222222)
    GlobalScope.launch {
        println(33333)
    }
}

上述会等待 runBlocking 中的代码执行完毕以后才会执行后面的代码。

对于这一点,Kotlin 官方也强调了:runBlocking 只推荐用于连接线程与协程,并且,大部分情况下,都只应该用于编写 Demo 或是测试代码。所以,请不要在生产环境当中使用 runBlocking。

runBlocking 方法签名:

kotlin 复制代码
public actual fun <T> runBlocking(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T): T {
...
}

可以看到,runBlocking 就是一个普通的顶层函数,它并不是 CoroutineScope 的扩展函数,因此,我们调用它的时候,不需要 CoroutineScope 的对象。前面我们提到过,GlobalScope 是不建议使用的,因此,后面的案例我们将不再使用 GlobalScope。

另外注意 runBlocking 是有返回值的。

4. async 启动协程

kotlin 复制代码
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit // 不同点1
): Job {} // 不同点2

public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T // 不同点1
): Deferred<T> {} // 不同点2

观察上述不同点,返回值不同,async 可以获取值。例子如下:

kotlin 复制代码
fun main() = runBlocking {
    println("In runBlocking:${Thread.currentThread().name}")

    val deferred: Deferred<String> = async {
        println("In async:${Thread.currentThread().name}")
        delay(1000L) // 模拟耗时操作
        return@async "Task completed!"
    }

    println("After async:${Thread.currentThread().name}")

    val result = deferred.await()
    println("Result is: $result")
}
/*
输出结果:
In runBlocking:main @coroutine#1
After async:main @coroutine#1 // 注意,它比"In async"先输出
In async:main @coroutine#2
Result is: Task completed!
*/

5. 三种启动协程方式的总结

另外,我们还学到了三种启动协程的方式,分别是 launch、runBlocking、async。

  1. launch,是典型的"Fire-and-forget"场景,它不会阻塞当前程序的执行流程,使用这种方式的时候,我们无法直接获取协程的执行结果。它有点像是生活中的射箭。

  2. runBlocking,我们可以获取协程的执行结果,但这种方式会阻塞代码的执行流程,因为它一般用于测试用途,生产环境当中是不推荐使用的。

  3. async,则是很多编程语言当中普遍存在的协程模式。它像是结合了 launch 和 runBlocking 两者的优点。它既不会阻塞当前的执行流程,还可以直接获取协程的执行结果。它有点像是生活中的钓鱼。

6. 协程生命周期

协程是有生命周期的,可以取消,例如:

kotlin 复制代码
runBlocking {
    val job = launch {
        logX("start")
        delay(1000)
        logX("end")
    }
    job.log()
    job.cancel()
    job.log()
    delay(1500)
}

fun Job.log(){
    logX("isActive = $isActive isCancelled =  $isCancelled isCompleted = $isCompleted ".trimIndent())
}

fun logX(any:Any?){
    println("""
    ================================
        $any
        Thread:${Thread.currentThread().name}
    ================================""".trimIndent())
}

上述代码可以通过 Job.cancel() 来取消掉。

再看下面一个例子:

scss 复制代码
runBlocking {
    val job = launch(start = CoroutineStart.LAZY) {
        logX("start")
        delay(1000)
        logX("end")
    }
    delay(500)
    job.log()
    job.start()
    job.log()
    delay(500)
    job.cancel()
    delay(500)
    job.log()
    delay(2999)
    logX("end===")
}

这个例子中 start 设置为 LAZY,lanunch 以后并不会立刻执行,而是在调用 job.start() 之后才会执行。

6.1. 生命周期图

协程的生命周期大概如下:

LAZY 的初始状态是 NEW,非 LAZY 的初始状态就直接是 Active。

6.2. 监听协程完成

协程结束以后会调用这个方法:

go 复制代码
job.invokeOnCompletion {
    println("结束了")
}

6.3. 结构化并发

简单来说,"结构化并发"就是:带有结构和层级的并发。

协程之间是有父子关系的.

7. 协程上下文 Context

CoroutineContext 很容易理解,它只是个上下文而已,实际开发中它最常见的用处就是切换线程池。

我们在调用 launch 的时候,都没有传 context 这个参数,因此它会使用默认值 EmptyCoroutineContext,顾名思义,这就是一个空的上下文对象。而如果我们想要指定 launch 工作的线程池的话,就需要自己传 context 这个参数了。

7.1. 内置 Dispatchers

  1. Dispatchers.Main,它只在 UI 编程平台才有意义,在 Android、Swing 之类的平台上,一般只有 Main 线程才能用于 UI 绘制。这个 Dispatcher 在普通的 JVM 工程当中,是无法直接使用的
  2. Dispatchers.Unconfined,代表无所谓,当前协程可能运行在任意线程之上
  3. Dispatchers.Default,它是用于 CPU 密集型任务的线程池。一般来说,它内部的线程个数是与机器 CPU 核心数量保持一致的,不过它有一个最小限制 2
  4. Dispatchers.IO,它是用于 IO 密集型任务的线程池。它内部的线程数量一般会更多一些(比如 64 个),具体线程的数量我们可以通过参数来配置:kotlinx.coroutines.io.parallelism。

7.2. 自定义 Dispatchers

scss 复制代码
val mySingleDispatcher = Executors.newSingleThreadExecutor {
    Thread(it).also { it.isDaemon = true }
}.asCoroutineDispatcher()

suspend fun getUserInfo(): String {
    logX("Before IO Context.")
    withContext(mySingleDispatcher) {
        logX("In IO Context.")
        delay(1000L)
        logX("In IO Context22222.")
    }
    logX("After IO Context.")
    return "BoyCoder"
}

这里的 asCoroutineDispatcher 实际上是实现 ExecutorCoroutineDispatcherImpl 的实现。

kotlin 复制代码
public fun ExecutorService.asCoroutineDispatcher(): ExecutorCoroutineDispatcher =
    ExecutorCoroutineDispatcherImpl(this)

7.3. withContext

使用 withContext 来切换上下文:

scss 复制代码
binding.mainBtn.setOnClickListener {
   runBlocking {
       val user = getUserInfo()
       logX(user)
   }
}

suspend fun getUserInfo(): String {
    logX("Before IO Context.")
    withContext(Dispatchers.IO) {
        logX("In IO Context.")
        delay(1000L)
        logX("In IO Context22222.")
    }
    logX("After IO Context.")
    return "BoyCoder"
}

withContext 会在其他线程中执行任务,并且挂起知道任务执行完成。

7.4. CoroutineContext

在 Kotlin 协程当中,但凡是重要的概念,都或多或少跟 CoroutineContext 有关系:Job、Dispatcher、CoroutineExceptionHandler、CoroutineScope,甚至挂起函数,它们都跟 CoroutineContext 有着密切的联系。甚至,它们之中的 Job、Dispatcher、CoroutineExceptionHandler 本身,就是 Context。

CoroutineScope 也遵从结构化并发:

跟下面这个例子类似:

CoroutineContext 接口设计:

kotlin 复制代码
// 代码段13

public interface CoroutineContext {

    public operator fun <E : Element> get(key: Key<E>): E?

    public operator fun plus(context: CoroutineContext): CoroutineContext {}

    public fun minusKey(key: Key<*>): CoroutineContext

    public fun <R> fold(initial: R, operation: (R, Element) -> R): R

    public interface Key<E : Element>
}

可以将其当做 Map 接口来理解。

8. Java 线程转 Kotlin 协程

让 Java 函数支持 Kotlin 挂起函数:

比如说,项目中用到的 SDK 是开源的,或者 SDK 是公司其他部门开发的,我们无法改动 SDK。

在这里,我们需要用到 Kotlin 官方提供的一个顶层函数:suspendCoroutine{},它的函数签名是这样的:

kotlin 复制代码
public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T {
    // 省略细节
}

从它的函数签名,我们可以发现,它是一个挂起函数,也是一个高阶函数,参数类型是"(Continuation) -> Unit",,它其实就等价于挂起函数类型!所以,我们可以使用 suspendCoroutine{} 来实现 await() 方法:

kotlin 复制代码
/*
注意这里                   
   ↓                                */
suspend fun <T: Any> KtCall<T>.await(): T = suspendCoroutine{
    continuation ->
    //   ↑
    // 注意这里 
}

public interface Continuation<in T> {
    public val context: CoroutineContext
    // 关键在于这个方法
    public fun resumeWith(result: Result<T>)
}



完整例子
suspend fun <T: Any> KtCall<T>.await(): T =
    suspendCoroutine { continuation ->
        call(object : Callback<T> {
            override fun onSuccess(data: T) {
                //注意这里
                continuation.resumeWith(Result.success(data))
            }

            override fun onFail(throwable: Throwable) {
                //注意这里
                continuation.resumeWith(Result.failure(throwable))
            }
        })
    }

接下来可以调用 continuation.resumeWith() 方法进行后续的操作,这样就可以让扩展方法 await 支持挂起了。

不过这里要注意 suspendCoroutine 是不支持取消的,必须使用 suspendCancellableCoroutine 才可以。

示例如下:

kotlin 复制代码
suspend fun <T : Any> KtCall<T>.await(): T =
//            变化1
//              ↓
    suspendCancellableCoroutine { continuation ->
        val call = call(object : Callback<T> {
            override fun onSuccess(data: T) {
                println("Request success!")
                continuation.resume(data)
            }

            override fun onFail(throwable: Throwable) {
                println("Request fail!:$throwable")
                continuation.resumeWithException(throwable)
            }
        })

//            变化2
//              ↓
        continuation.invokeOnCancellation {
            println("Call cancelled!") //在这里进行取消
            call.cancel()
        }
    }

当我们使用 suspendCancellableCoroutine{} 的时候,可以往 continuation 对象上面设置一个监听:invokeOnCancellation{},它代表当前的协程被取消了,这时候,我们只需要将 OkHttp 的 call 取消即可

9. Channel

Channel 可以跨越不同的协程进行通信

ruby 复制代码
// 代码段1

fun main() = runBlocking {
    // 1,创建管道
    val channel = Channel<Int>()

    launch {
        // 2,在一个单独的协程当中发送管道消息
        (1..3).forEach {
            channel.send(it) // 挂起函数
            logX("Send: $it")
        }
    }

    launch {
        // 3,在一个单独的协程当中接收管道消息
        for (i in channel) {  // 挂起函数
            logX("Receive: $i")
        }
    }

    logX("end")
}

/*
================================
end
Thread:main @coroutine#1
================================
================================
Receive: 1
Thread:main @coroutine#3
================================
================================
Send: 1
Thread:main @coroutine#2
================================
================================
Send: 2
Thread:main @coroutine#2
================================
================================
Receive: 2
Thread:main @coroutine#3
================================
================================
Receive: 3
Thread:main @coroutine#3
================================
================================
Send: 3
Thread:main @coroutine#2
================================
// 4,程序不会退出
*/

通过运行结果,我们还可以发现一个细节,那就是程序在输出完所有的结果以后,并不会退出。主线程不会结束,整个程序还会处于运行状态。

kotlin 复制代码
// 代码段2

fun main() = runBlocking {
    val channel = Channel<Int>()

    launch {
        (1..3).forEach {
            channel.send(it)
            logX("Send: $it")
        }

        channel.close() // 变化在这里
    }

    launch {
        for (i in channel) {
            logX("Receive: $i")
        }
    }

    logX("end")
}

对于 Receive 还有一些其他的方法:

scss 复制代码
lifecycleScope.launch {
    while(!channel.isClosedForReceive){
        logX("1---> Receive ${channel.receive()}") //channel.receiveCatching()
    }
    println("=========")
}
lifecycleScope.launch {
    for (y in channel) {
        logX("2---> Receive $y")
    }
}
lifecycleScope.launch {
    channel.consumeEach {
        logX("3---> Receive ${channel.receive()}")
    }
}

10. Flow

10.1. catch 异常

前面我已经介绍过,Flow 主要有三个部分:上游、中间操作、下游。那么,Flow 当中的异常,也可以根据这个标准来进行分类,也就是异常发生的位置。对于发生在上游、中间操作这两个阶段的异常,我们可以直接使用 catch 这个操作符来进行捕获和进一步处理。如下所示:

scss 复制代码
// 代码段8
fun main() = runBlocking {
    val flow = flow {
        emit(1)
        emit(2)
        throw IllegalStateException()
        emit(3)
    }

    flow.map { it * 2 }
        .catch { println("catch: $it") } // 注意这里
        .collect {
            println(it)
        }
}
/*
输出结果:
2
4
catch: java.lang.IllegalStateException
*/

catch 的作用域,仅限于 catch 的上游。换句话说,发生在 catch 上游的异常,才会被捕获,发生在 catch 下游的异常,则不会被捕获.

例如:

scss 复制代码
// 代码段9
fun main() = runBlocking {
    val flow = flow {
        emit(1)
        emit(2)
        emit(3)
    }

    flow.map { it * 2 }
        .catch { println("catch: $it") }
        .filter { it / 0 > 1}  // 故意制造异常
        .collect {
            println(it)
        }
}

/*
输出结果
Exception in thread "main" ArithmeticException: / by zero
*/

这种只能在 collect 方法中对其捕获了。

kotlin 复制代码
// 代码段10

fun main() = runBlocking {
    flowOf(4, 5, 6)
        .onCompletion { println("onCompletion second: $it") }
        .collect {
            try {
                println("collect: $it")
                throw IllegalStateException()
            } catch (e: Exception) {
                println("Catch $e")
            }
        }
}

10.2. 切换 Context:flowOn、launchIn

ruby 复制代码
// 代码段11
fun main() = runBlocking {
    val flow = flow {
        logX("Start")
        emit(1)
        logX("Emit: 1")
        emit(2)
        logX("Emit: 2")
        emit(3)
        logX("Emit: 3")
    }

    flow.filter {
            logX("Filter: $it")
            it > 2
        }
        .flowOn(Dispatchers.IO)  // 注意这里
        .collect {
            logX("Collect $it")
        }
}

/*
输出结果
================================
Start
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
================================
Filter: 1
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
================================
Emit: 1
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
================================
Filter: 2
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
================================
Emit: 2
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
================================
Filter: 3
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
================================
Emit: 3
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
================================
Collect 3
Thread:main @coroutine#1
================================

flowOn 操作符也是和它的位置强相关的。它的作用域跟前面的 catch 类似:flowOn 仅限于它的上游。

在上面的代码中,flowOn 的上游,就是 flow{}、filter{} 当中的代码,所以,它们的代码全都运行在 DefaultDispatcher 这个线程池当中。只有 collect{} 当中的代码是运行在 main 线程当中的。

上述代码使用 flowOn 用来控制上有的线程,那么如何控制 collect 中的线程呢?这里有两种方式:

  1. 使用 withContext
  2. 使用 luanchIn

示例如下:

kotlin 复制代码
// 代码段15
val scope = CoroutineScope(mySingleDispatcher)
flow.flowOn(Dispatchers.IO)
    .filter {
        logX("Filter: $it")
        it > 2
    }
    .onEach {
        logX("onEach $it")
    }
    .launchIn(scope)

/*
输出结果:
onEach{}将运行在MySingleThread
filter{}运行在MySingleThread
flow{}运行在DefaultDispatcher
*/


// 看一下 launchIn() 方法
public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch {
    collect() // tail-call
}

所以上述代码就等于:

scss 复制代码
// 代码段17
fun main() = runBlocking {
    val scope = CoroutineScope(mySingleDispatcher)
    val flow = flow {
        logX("Start")
        emit(1)
        logX("Emit: 1")
        emit(2)
        logX("Emit: 2")
        emit(3)
        logX("Emit: 3")
    }
        .flowOn(Dispatchers.IO)
        .filter {
            logX("Filter: $it")
            it > 2
        }
        .onEach {
            logX("onEach $it")
        }
        
    scope.launch { // 注意这里
        flow.collect()
    }
    
    delay(100L)
}

10.3. 综合例子

通过 Flow 的这些 API 可以构建非常灵活的使用,例如:

scss 复制代码
// 代码段22

fun main() = runBlocking {
    fun loadData() = flow {
        repeat(3) {
            delay(100L)
            emit(it)
            logX("emit $it")
        }
    }
    fun updateUI(it: Int) {}
    fun showLoading() { println("Show loading") }
    fun hideLoading() { println("Hide loading") }

    val uiScope = CoroutineScope(mySingleDispatcher)

    loadData()
        .onStart { showLoading() }          // 显示加载弹窗
        .map { it * 2 }
        .flowOn(Dispatchers.IO)
        .catch { throwable ->
            println(throwable)
            hideLoading()                   // 隐藏加载弹窗
            emit(-1)                   // 发生异常以后,指定默认值
        }
        .onEach { updateUI(it) }            // 更新UI界面 
        .onCompletion { hideLoading() }     // 隐藏加载弹窗
        .launchIn(uiScope)

    delay(10000L)
}

上述的 onStart, flowOn, catch, onCompletion, launchIn 等方法的使用。

11. select

通常用于在选择几个并发任务中最先执行的那一个:select,就是选择"更快的结果"。

例子如下:

kotlin 复制代码
// 代码段5
fun main() = runBlocking {
    suspend fun getCacheInfo(productId: String): Product? {
        delay(100L)
        return Product(productId, 9.9)
    }

    suspend fun getNetworkInfo(productId: String): Product? {
        delay(200L)
        return Product(productId, 9.8)
    }

    fun updateUI(product: Product) {
        println("${product.productId}==${product.price}")
    }

    val startTime = System.currentTimeMillis()
    val productId = "xxxId"

    // 1,缓存和网络,并发执行
    val cacheDeferred = async { getCacheInfo(productId) }
    val latestDeferred = async { getNetworkInfo(productId) }

    // 2,在缓存和网络中间,选择最快的结果
    val product = select<Product?> {
        cacheDeferred.onAwait {
                it?.copy(isCache = true)
            }

        latestDeferred.onAwait {
                it?.copy(isCache = false)
            }
    }

    // 3,更新UI	
    if (product != null) {
        updateUI(product)
        println("Time cost: ${System.currentTimeMillis() - startTime}")
    }

    // 4,如果当前结果是缓存,那么再取最新的网络服务结果
    if (product != null && product.isCache) {
        val latest = latestDeferred.await()?: return@runBlocking
        updateUI(latest)
        println("Time cost: ${System.currentTimeMillis() - startTime}")
    }
}

/*
输出结果:
xxxId==9.9
Time cost: 120
xxxId==9.8
Time cost: 220
*/

12. 协程中的并发处理

与 Java 中类似,在协程中也面临中并发的场景。那么在协程中都有那些方式来处理并发呢?

首先可以使用 Java 中的方式 synchronized{},例如:

scss 复制代码
// 代码段3

fun main() = runBlocking {
    var i = 0
    val lock = Any() // 变化在这里

    val jobs = mutableListOf<Job>()

    repeat(10){
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                // 变化在这里
                synchronized(lock) { //不支持挂起函数
                    i++
                }
            }
        }
        jobs.add(job)
    }

    jobs.joinAll()

    println("i = $i")
}

/*
输出结果
i = 10000
*/

不过 synchronized 中并不支持挂起函数。

12.1. 通过 Mutex 来添加并发代码块

scss 复制代码
// 代码段7

fun main() = runBlocking {
    val mutex = Mutex()

    var i = 0
    val jobs = mutableListOf<Job>()

    repeat(10) {
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                // 变化在这里
                mutex.lock()
                i++
                mutex.unlock()
            }
        }
        jobs.add(job)
    }

    jobs.joinAll()

    println("i = $i")
}

不过,对于 mutex.lock() 和 unlock() 之间的代码要处理异常情况,通常使用:mutex.withLock 方法。

kotlin 复制代码
// 代码段10
fun main() = runBlocking {
    val mutex = Mutex()

    var i = 0
    val jobs = mutableListOf<Job>()

    repeat(10) {
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                // 变化在这里
                mutex.withLock {
                    i++
                }
            }
        }
        jobs.add(job)
    }

    jobs.joinAll()

    println("i = $i")
}

// withLock的定义
public suspend inline fun <T> Mutex.withLock(owner: Any? = null, action: () -> T): T {
    lock(owner)
    try {
        return action()
    } finally {
        unlock(owner)
    }
}

12.2. Actor 管道来处理并发

Actor 实际上就是一个 Channel,可以在多个线程中往同一个管道中添加数据,在管道接收的地方做计算,这样也能实现同步的效果。

kotlin 复制代码
// 代码段11

sealed class Msg
object AddMsg : Msg()

class ResultMsg(
    val result: CompletableDeferred<Int>
) : Msg()

fun main() = runBlocking {

    suspend fun addActor() = actor<Msg> {
        var counter = 0
        for (msg in channel) {
            when (msg) {
                is AddMsg -> counter++
                is ResultMsg -> msg.result.complete(counter)
            }
        }
    }

    val actor = addActor()
    val jobs = mutableListOf<Job>()

    repeat(10) {
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                actor.send(AddMsg)
            }
        }
        jobs.add(job)
    }

    jobs.joinAll()

    val deferred = CompletableDeferred<Int>()
    actor.send(ResultMsg(deferred))

    val result = deferred.await()
    actor.close()

    println("i = ${result}")
}

13. try-catch

在协程中对于异常的处理要特别注意,例如 cancel() 不成功、捕获异常位置不对等。

13.1. cancel 不起作用

案例一:cancel 不被响应

scss 复制代码
val singleThreadScope = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val scope: CoroutineScope = CoroutineScope(singleThreadScope)
binding.flowTestBtn.setOnClickListener {
    scope.launch {
        val job = launch(Dispatchers.Default) {
            var i = 0
            while (true) {
                Thread.sleep(500)
                i++
                println("current: $i")
            }
        }
        delay(2000)
        job.cancel()
        job.join()
        println("===end===")
    }
}

上述方式其启动了一个协程,然后 delay 2000 后 cancel() 协程,可以发现并不能取消,还是会一直 print。原因在于:

协程是互相协作的程序。因此,对于协程任务的取消,也是需要互相协作的。协程外部取消,协程内部需要做出响应才行。

正确用法如下:

scss 复制代码
binding.flowTestBtn.setOnClickListener {
    scope.launch {
        val job = launch(Dispatchers.Default) {
            var i = 0
            while (isActive) { //内部添加了一个判断条件就可以cancel成功了
                Thread.sleep(500)
                i++
                println("current: $i")
            }
        }
        delay(2000)
        job.cancel()
        job.join()
        println("===end===")
    }
}

案例二:结构被破坏

scss 复制代码
// 代码段3

val fixedDispatcher = Executors.newFixedThreadPool(2) {
    Thread(it, "MyFixedThread").apply { isDaemon = false }
}.asCoroutineDispatcher()

fun main() = runBlocking {
    // 父协程
    val parentJob = launch(fixedDispatcher) {

        // 1,注意这里
        launch(Job()) { // 子协程1 这里的 Job()
            var i = 0
            while (isActive) {
                Thread.sleep(500L)
                i ++
                println("First i = $i")
            }
        }

        launch { // 子协程2
            var i = 0
            while (isActive) {
                Thread.sleep(500L)
                i ++
                println("Second i = $i")
            }
        }
    }

    delay(2000L)

    parentJob.cancel()
    parentJob.join()

    println("End")
}

/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
Second i = 3
First i = 3
First i = 4
Second i = 4
End
First i = 5
First i = 6
// 子协程1永远不会停下来
*/

注意上述子协程 1 使用了 Job() 作为其上下文,实际上这破坏了:协程的父子关系

案例三:未正确处理 CancellationExeception

其实,对于 Kotlin 提供的挂起函数,它们是可以自动响应协程的取消的,比如说,当我们把 Thread.sleep(500) 改为 delay(500) 以后,我们就不需要在 while 循环当中判断 isActive 了。

实际上,对于 delay() 函数来说,它可以自动检测当前的协程是否已经被取消,如果已经被取消的话,它会抛出一个 CancellationException,从而终止当前的协程。

kotlin 复制代码
// 代码段6

fun main() = runBlocking {

    val parentJob = launch(Dispatchers.Default) {
        launch {
            var i = 0
            while (true) {
                // 1
                try {
                    delay(500L)
                } catch (e: CancellationException) {
                    println("Catch CancellationException")
                    // 2
                    throw e
                }
                i ++
                println("First i = $i")
            }
        }

        launch {
            var i = 0
            while (true) {
                delay(500L)
                i ++
                println("Second i = $i")
            }
        }
    }

    delay(2000L)

    parentJob.cancel()
    parentJob.join()

    println("End")
}

/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
Second i = 4
Catch CancellationException
End
*/

请看注释 1,在用 try-catch 包裹了 delay() 以后,我们就可以在输出结果中,看到"Catch CancellationException",这就说明 delay() 确实可以自动响应协程的取消,并且产生 CancellationException 异常。不过,以上代码中,最重要的其实是注释 2:"throw e"。当我们捕获到 CancellationException 以后,还要把它重新抛出去。而如果我们删去这行代码的话,子协程将同样无法被取消。

所以:捕获了 CancellationException 以后,要考虑是否应该重新抛出来。

13.2. try-catch 不起作用

注意下面这段代码中的 try-catch 不起作用:

kotlin 复制代码
// 代码段8

fun main() = runBlocking {
    try {
        launch {
            delay(100L)
            1 / 0 // 故意制造异常
        }
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }

    delay(500L)
    println("End")
}

/*
输出结果:
崩溃
Exception in thread "main" ArithmeticException: / by zero
*/

不起作用的原因是 try-catch 与其中的 launch 部分代码并不是同步的, try-catch 先运行完毕,launch 后运行,相当于失去了作用范围。

所以:不用用 try-catch 直接包裹 launch 和 async 代码块。

13.3. SupervisorJob

尝试下面的代码会发现还是会崩掉的:

scss 复制代码
binding.mainBtn.setOnClickListener {
    lifecycleScope.launch {
        val deferred = async {
            delay(100)
            1/0
        }
        delay(500)
        println("end")
    }
}

不管是调不调用 deferred.await() 方法,又或者是否给 await() 方法是否添加 try-catch 都会崩,但是如果使用 SupervisorJob:

scss 复制代码
binding.mainBtn.setOnClickListener {
    lifecycleScope.launch {
        val deferred = async (context = SupervisorJob()){ //注意这里
            delay(100)
            1/0
        }
        delay(500)
        //deferred.await()
        println("end")
    }
}

只要不调用 deferred.await() 就不会崩。所以就可以下面的方式来捕获 async 异常:

kotlin 复制代码
// 代码段15

fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())
    // 变化在这里
    val deferred = scope.async {
        delay(100L)
        1 / 0
    }

    try {
        deferred.await()
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }

    delay(500L)
    println("End")
}

/*
输出结果
Catch: java.lang.ArithmeticException: / by zero
End
*/

SupervisorJob 与 Job 最大的区别就在于,当它的子 Job 发生异常的时候,其他的子 Job 不会受到牵连。我这么说你可能会有点懵,下面我做了一个动图,来演示普通 Job 与 SupervisorJob 之间的差异。

所以:要使用 SupervisorJob 控制异常传播的范围。

13.4. CoroutineExceptionHandler

对于 CoroutineExceptionHandler,我们其实在第 17 讲里也简单地提到过。它是 CoroutineContext 的元素之一,我们在创建协程的时候,可以指定对应的 CoroutineExceptionHandler。

看下面这个例子:

scss 复制代码
// 代码段18

fun main() = runBlocking {
    val myExceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Catch exception: $throwable")
    }

    // 注意这里
    val scope = CoroutineScope(coroutineContext + Job() + myExceptionHandler)

    scope.launch {
        async {
            delay(100L)
        }

        launch {
            delay(100L)

            launch {
                delay(100L)
                1 / 0 // 故意制造异常
            }
        }

        delay(100L)
    }

    delay(1000L)
    println("End")
}

/*
Catch exception: ArithmeticException: / by zero
End
*/

只要出异常都会在 CoroutineExceptionHandler 这里进行捕获,相当于 Android 中的 ExceptionHandle 全局捕获。

CoroutineExceptionHandler 只在顶层的协程当中才会起作用。也就是说,当子协程当中出现异常以后,它们都会统一上报给顶层的父协程,然后顶层的父协程才会去调用 CoroutineExceptionHandler,来处理对应的异常。

14. callback 转 Flow

Callback 转 Flow,用法跟 Callback 转挂起函数是差不多的。如果你去分析代码段 1 当中的代码模式,会发现 Callback 转挂起函数,主要有三个步骤。第一步:使用 suspendCancellableCoroutine 执行 Callback 代码,等待 Callback 回调;第二步:将 Callback 回调结果传出去,onSuccess 的情况就传结果,onFail 的情况就传异常;第三步:响应协程取消事件 invokeOnCancellation{}。所以使用 callbackFlow,也是这样三个步骤。如果你看过 Google 官方写的文档,你可能会写出这样的代码:

kotlin 复制代码
// 代码段7

fun <T : Any> KtCall<T>.asFlow(): Flow<T> = callbackFlow {
    val call = call(object : Callback<T> {
        override fun onSuccess(data: T) {
            // 1,变化在这里
            trySendBlocking(data)
                .onSuccess { close() }
                .onFailure { close(it) }
        }

        override fun onFail(throwable: Throwable) {
            close(throwable)
        }

    })

    awaitClose {
        call.cancel()
    }
}

/*
输出结果
输出正常
程序等待一会后自动终止
*/

上述这个是标准做法:

  1. 使用 trySendBlocking() 而不是 offer() 和 trySend() ,这样即便 Channel 中满了,也可以等待 Channel 有空余的空间
  2. 在 onSuccess 和 onFailure 中要 close 或者 cancel() 掉,不然 Channel 一直开着会占用内存。
相关推荐
studyForMokey2 小时前
kotlin 函数类型接口lambda写法
android·开发语言·kotlin
梁同学与Android6 小时前
Android --- 新电脑安装Android Studio 使用 Android 内置模拟器电脑直接卡死,鼠标和键盘都操作不了
android·ide·android studio
山雨楼8 小时前
ExoPlayer架构详解与源码分析(14)——ProgressiveMediaPeriod
android·架构·音视频·源码·exoplayer·media3
IsaacBan10 小时前
XJBX-6-Android启动App进程
android
DoubleYellowIce10 小时前
Android Studio阅读frameworks源码的正确姿势
android·android studio
分享者花花10 小时前
最佳 iPhone 解锁软件工具,可免费下载用于电脑操作的
android·windows·macos·ios·pdf·word·iphone
小菜琳15 小时前
Android显式启动activity和隐式启动activity分别都是怎么启动?请举例说明二者使用时的注意事项。
android
许进进15 小时前
FlutterWeb渲染模式及提速
android·flutter·web
helson赵子健16 小时前
Rust 在 Android 中的应用
android·架构·rust
2401_8523867116 小时前
苹果ios安卓apk应用APP文件怎么修改手机APP显示的名称
android·智能手机