11.7--Kotlin 课堂:使用协程编写高效的并发程序

协程属于Kotlin 中非常有特色的一项技术,因为大部分编程语言中是没有协程这个概念的。

那么什么是协程呢?它其实和线程是有点类似的,可以简单地将它理解成一种轻量级的线程。要知道,我们之前所学习的线程是非常重量级的,它需要依靠操作系统的调度才能实现不同线程之间的切换。而使用携程切可以仅在编程语言的层面就能实现不同协程之间的切换,从而大大提升了并发编程的运行效率。

举一个具体点的例子,比如我们有如下foo() 和bar() 两个方法:

fun foo(){
    println(1)
    println(2)
    println(3)
}
fun bar(){
    println(4)
    println(5)
    println(6)
}

在没有开启线程的情况下,先后调用foo() 和 bar() 这两个方法,那么理论上的输出结果一定是123456。而如果使用了协程,在协程A中去调用foo() 方法,在协程B 中去调用bar() 方法,虽然它们仍然会运行在同一个线程当中,但是在执行foo() 方法时随时都有可能被挂起转而去执行bar() 方法,执行bar() 方法时也随时都有可能被挂起转而继续执行foo() 方法,最终的输出结果也就变得不确定了。

可以看出,协程允许我们在单线程模式下模拟多线程编程的效果,代码执行时的挂起与恢复完全是由编程语言来控制的,和操作系统无关。这种特性使得高并发程序的运行效率得到了极大的提升,试想一下,开启10万个线程是完全不可想象的事吧?而开启10万个协程就是完全可行的,待会我们就会对这个功能进行验证。

现在你已经了解了协程的一些基本概念,那么接下来我们就开始学习Kotlin 中协程的用法。

 

11.7.1 协程的基本用法

Kotlin 并没有将协程纳入标准库的API 当中,而是以依赖库的形式提供的。所以如果我们想要使用协程功能,需要先在 app/build.gradle 文件当中添加如下依赖库:

dependencies {
    ...
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'

}

第二个依赖库是在Android 项目中才会用到的,本节我们编写的代码示例都是纯Kotlin 程序,所以其实用不到第二个依赖库。但是为了下次在Android 项目中使用协程时不再单独进行说明,这里就一同引入进来了。

接下来创建一个CorortinesTest.kt 文件,并定义一个main() 函数,然后开始我们的协程之旅吧。

首先我们要面临的第一个问题就是,如何开启一个协程?最简单的方式就是使用Global.launch 函数,如下所示:

fun main(){
    GlobalScope.launch {
        println("codes run in coroutines scope")
    }
}

GlobalScope.launch 函数可以创建一个协程的作用域,这样传递给launch 函数的代码块(Lambda 表达式)就是在协程中运行的了,这里我们只是在代码块中打印了一行日志。那么现在运行main() 函数,日志能成功打印出来吗?如果你尝试一下,会发现没有任何日志输出。

这是因为,GlobalScope.launch 函数每次创建的都是一个顶层协程,这种协程当应用程序运行结束时也会跟着一起结束。刚才的日志之所以无法打印出来,就是因为代码块中的代码还没来得及运行,应用程序就结束了

要解决这个问题也很简单,我们让程序延迟一段时间再结束就行了,如下所示:

fun main(){
    GlobalScope.launch {
        println("codes run in coroutines scope")
    }
    Thread.sleep(1000)
}

这里使用Thread.sleep() 方法让主线程阻塞1秒钟,现在重新运行程序,你会发现日志可以正常打印出来了,如图:

可是这种写法还是存在问题,如果代码块中的代码在1秒钟之内不能运行结束,那么就会被强制中断。观察如下代码:

fun main(){
    GlobalScope.launch {
        println("codes run in coroutines scope")
        delay(1500)
        println("codes run in coroutine scope finished")
    }
    Thread.sleep(1000)
}

我们在代码块中加入了一个delay() 函数,并在之后又打印了一行日志。delay() 函数可以让当前协程延迟指定时间后再运行,但它和Thread.sleep() 方法不同。delay() 函数是一个非阻塞式的挂起函数,它只会挂起当前的协程,并不会影响其他协程的运行。而Thread.sleep() 方法会阻塞当前的线程,这样运行在该线程下的所有协程都会被阻塞。注意,delay() 函数只能在协程的作用域或其他挂起函数中调用。

这里我们让协程挂起1.5秒,但是主线程却只阻塞了1秒,最终会是什么结果呢?重新运行程序,你会发现代码块中新增的一条日志并没有打印出来,因为它还没能来得及运行,应用程序就已经结束了。

那么有没有什么办法能让应用程序在协程中所有代码都运行完了之后再结束呢?当然也是有的,借助runBlocking 函数接可以实现这个功能:

fun main(){
    runBlocking {
        println("codes run in coroutines scope")
        delay(1500)
        println("codes run in coroutine scope finished")
    }
}

runBlocking 函数同样会创建一个协程的作用域,但是它可以保证在协程作用域内的所有代码和子协程没有全部执行完之前一直阻塞当前线程。需要注意的是,runBlocking 函数通常只应该在测试环境下使用,在正式环境下使用容易产生一些性能上的问题。

现在重新运行程序,结果如图:

可以看到,两条日志都能够正常打印出来了。

虽说现在我们已经能够让代码在协程中运行了,可是好像并没有体会到什么特别的好处。

这是因为目前所有的代码都是运行在同一个协程当中的,而一旦涉及高并发的应用场景,协程相比于线程的优势就能提现出来了。

那么如何才能创建多个协程呢?很简单,使用launch 函数就可以了,如下所示:

fun main(){
    runBlocking {
        launch {
            println("launch1")
            delay(1000)
            println("launch1 finish")
        }
        launch {
            println("launch2")
            delay(1000)
            println("launch2 finish")
        }
    }
}

注意这里的launch 函数和我们刚才使用的GlobalScope.launch 函数不同。首先它必须在协程的作用域中才能调用,其次它会在当前协程的作用域下创建子协程。子协程的特点是如果外层作用域的协程结束了,该作用域下的所有子协程也会一同结束。相比而言,GlobalScope.launch函数创建的永远是顶层协程,这一点和线程比较像,因为线程也没有顶层这一说,永远都是顶层的

这里我们调用了两次launch 函数,也就是创建了两个子协程。重新运行程序,结果如图:

可以看到,两个子协程的日志是交替打印的,说明它们确实像多线程那样并发运行的。然而这两个子协程实际却运行在同一个线程当中,只是由编程语言来决定如何在多个协程之间进行调度,让谁运行,让谁挂起。调度的过程完全不需要操作系统参与,这也就使得协程的并发效率出奇得高。

那么具体会有多高呢?我们来做下实验就知道了,代码如下所示:

这里使用repeat 函数循环创建了10万个协程,不过在协程当中并没有进行什么有意义的操作,只是象征性地打印了一个点,然后记录一下整个操作的运行耗时。现在重新运行一下程序,结果如图:

可以看到,这里仅仅耗时了612 毫秒,这足以证明协程有多么高效。试想一下,如果开启的是10万个线程,程序或许已经出现OOM 异常了。

不过,随着launch 函数中的逻辑越来越复杂,可能你需要将部分代码提取到一个单独的函数中。这时候就产生了一个问题:我们在launch 函数中编写的代码是拥有协程作用域的,但是提取到一个单独的函数中就没有协程作用域了,那么我们该如何调用像delay() 这样的挂起函数呢?

为此Kotlin 提供了一个suspend 关键字,使用它可以将任意函数声明成挂起函数,而挂起函数中间都是可以相互调用的,如下所示:

suspend fun printDot(){
    println(".")
    delay(1000)
}

这样就可以在printDot() 函数中调用delay() 函数了。 

但是,suspend 关键字只能将一个函数声明成挂起函数,是无法给它提供协程作用域的。比如你现在尝试在printDot() 函数中调用launch 函数,一定是无法调用成功的 ,因为launch 函数要求必须在协程作用域当中才能调用。

这个问题可以借助coroutineScoped 函数来解决。coroutineScoped 函数也是一个挂起函数,因此可以在任何其他挂起函数中调用。它的特点是会继承外部的协程作用域并创建一个子作用域,借助这个特性,我们就可以给任意挂起函数提供协程作用域了。示例写法如下:

suspend fun printDot() = coroutineScope {
    launch {
        println(".")
        delay(1000)
    }
}

可以看到,现在我们可以在printDot() 这个挂起函数中调用launch 函数了。

另外,coroutineScope 函数和 runBlocking 函数还有点类似,它可以保证其作用域内的所有代码和子协程在全部执行完成之前,会一直阻塞当前协程(作用域)。我们来看如下示例代码:

fun main(){
   runBlocking {
       coroutineScope {
           launch {
               for (i in 1..10) {
                   println(i)
                   delay(1000)
               }
           }
       }
       println("coroutineScope finished")
   }
   println("runBlocking finished")
}

 

这里先使用runBlocking 函数创建了一个协程作用域,然后又使用coroutineScope 函数创建了一个子协程作用域。在coroutineScope 的作用域中,我们调用launch 函数创建了一个子协程,并通过for 循环一次打印数字1到10,每次打印间隔一秒钟。在棒子在runBlocking 和 corotineScope 函数的结尾,分别又打印了一行日志。现在重新运行一下程序,结果如图:

你会看到,控制台会以1 秒钟的间隔一次输出数字1到 10 ,然后才引出coroutineScope 函数结尾的数字,最后打印 runBlocking 函数结尾的日志。

由此可见,coroutineScope 函数确实是将当前的协程(作用域)阻塞住了,只有当它作用域内的所有代码和子协程都执行完毕之后,corotineScope 函数之后的代码才能得到运行。

虽然看上去coroutineScope 函数和 runBlocking 函数的作用域是非常类似的,但是coroutineScope 函数只会阻塞当前协程(作用域),既不影响其他协程作用用,也不影响任何线程,因此是不会造成任何性能上的问题的。而runBlocking 函数由于会阻塞当前线程,如果你恰好又在主线程当中调用它的话,那么就有可能会导致界面卡死的情况,所以不太推荐在实际项目中使用。

好了,现在我们就将协程的基本用法都学习完了,你也算是已经成功入门。那么接下来就上我们开始学习协程更多知识吧。

 

11.7.2 更多的作用域构建器

在上一小节中,我们学习了GlobalScope.launch 、runBlocking 、coroutineScope 这几种作用域构建器,它们都可以用于创建一个新的协程作用域。不过 GlobalScope.launch 和 runBlocking 函数是可以在任意地方调用的,coroutineScope 函数可以在协程作用域或挂起函数中调用,而launch 函数只能在协程作用域中调用。

前面已经说了,runBlocking 由于会阻塞线程,因此只建议在测试环境下使用。而GlobalScope.launch 由于每次创建的都是顶层协程,一般也不太建议使用,除非你非常非常明确就是要创建顶层协程。

为什么说不太建议使用顶层协程呢?主要还是因为它管理起来成本太高。举个例子,比如我们在某个Activity 中使用协程发起了一条网络请求,由于网络请求是耗时,用户服务器还没来得及响应的情况下就关闭了当前Activity ,此时按理说应该取消这条网络请求,或者至少不应该进行回调,因为Activity 已经不存在了,回调了也没有意义。

那么协程要怎样取消呢?不管是GlobalScope.launch 函数还是 launch 函数,它们都会返回一个Job 对象,只需要调用Job 对象的cancel() 方法就可以取消协程了,如下所示:

fun main(){
    val job = GlobalScope.launch { 
        // 处理具体的逻辑
    }
    job.cancel()
}

但是如果我们每次创建的都是顶层协程,那么当Activity关闭时,就需要逐个调用所有已创建协程的cancel() 方法,试想一下,这样的代码是不是根本无法维护?

因此,GlobalScope.launch 这种协程作用域构建器,在实际项目中也不是太常用。下面我来演示一下实际项目中比较常用的写法:

fun main(){
    val job = Job()
    val scope = CoroutineScope(job)
    scope.launch {
        // 处理具体的逻辑
    }
    job.cancel()
}

可以看到,我们先创建了一个Job 对象,然后把它传入CoroutineScope() 函数当中,注意这里的CoroutineScope() 是个函数,虽然它的命名更像是一个类。CoroutineScope() 函数会返回一个CoroutineScope 对象,这种语法结构的设计更像是我们创建了一个CoroutineScope 的实例,可能也是Kotlin 有意为之的。有了CoroutineScope 对象之后,就可以随时调用它的launch 函数来创建一个协程了。

现在所有调用CoroutineScope 的 launch 函数所创建的协程,都会被关联在Job 对象的作用域下面。这样只需要调用一次cancel() 方法,就可以将同一作用域内的所有协程全部取消,从而大大降低了协程管理的成本。

不过相比之下,CoroutineScope() 函数更适合用于实际项目当中,如果只是在main() 函数中编写一些学习测试的代码,还是使用runBlocking 函数最为方便。

协程的内容确实比较多,下面我们还要继续学习。你已经知道了调用launch 函数可以创建一个新的协程,但是launch 函数只能用于执行一段逻辑,却不能获取执行的结果,因为它的返回值永远是个Job 对象。那么有没有什么办法能够创建一个协程并获取它的执行结果呢?当然有,使用async 函数就可以实现。

async 函数必须在协程作用域当中才能调用,它会创建一个新的子协程并返回一个Deferred 对象,如果我们想要获取async 函数代码块的执行结果,只需要调用Deferred 对象的await() 方法即可,代码如下所示:

fun main(){
   runBlocking {
       val result = async {
           5+5
       }.await()
       println(result)
   }
}

这里我们在async 函数的代码块中进行了一个简单的数学运算,然后调用await() 方法获取运算结果,最终将结果打印出来。重新运行一下代码,结果如图:

不过async 函数的奥秘还不止于此。实际上,在调用了async 函数之后,代码块中的代码就会立即开始执行。当调用await() 方法时,如果代码块中的代码还没执行完,那么await() 方法会将当前协程作用域阻塞住,直到可以获得async 函数执行的结果。

为了证实这一点,我们编写如下代码进行验证:

fun main(){
   runBlocking {
       val start = System.currentTimeMillis()
       val result1 = async {
           delay(1000)
           5+5
       }.await()
       val result2 = async {
           delay(1000)
           4+6
       }.await()
       println("result is ${result1+result2}.")
       val end = System.currentTimeMillis()
       println("cost ${end - start} ms.")
   }
}

这里连续使用了两个async 函数来执行任务,并在代码块中调用delay() 方法进行1秒的延迟。按照刚才的理论,await() 方法在async 函数代码块中的代码执行完毕之前后一直讲当前协程阻塞住,那么为了编译验证,我们记录下了代码的运行耗时。现在重新运行程序,结果如图:

可以看到,整段代码的运行耗时是2025 毫秒,说明这里的两个async 函数确实是一种串行的关系,前一个执行完了后一个才能执行。

但是这种写法明显是非常低效的,因为两个async 函数完全可以同时执行从而提高运行效率,现在对上诉代码使用如下的写法进行修改:

fun main(){
   runBlocking {
       val start = System.currentTimeMillis()
       val deferred1 = async {
           delay(1000)
           5+5
       }
       val deferred2 = async {
           delay(1000)
           4+6
       }
       
       println("result is ${deferred1.await()+deferred2.await()}.")
       val end = System.currentTimeMillis()
       println("cost ${end - start} ms.")
   }
}

现在我们不在每次调用async 函数之后就立刻使用await() 方法获取结果了,而是仅在需要用的async 函数的执行结果时才调用await() 方法进行获取,这样两个async 函数就变成一种并行关系了。重新运行程序,结果如图:

可以看到,现在整段代码的运行耗时变成了1027 毫秒,运行效率的提升显而易见。最后,我们再来学习一个比较特殊的作用域构建器:withContext() 函数。 withContext() 函数是一个挂起函数,大体可以将它理解成async 函数的一种简化版写法,示例写法如下:

fun main(){
    runBlocking {
        val result = withContext(Dispatchers.Default){
            5+5
        }
        println(result)
    }
}

我来解释一下这段代码。调用withContext() 函数之后,会立即执行代码块中的代码,同时将当前协程阻塞住。当代码块中的代码全部执行完之后,会将最后一行的执行结果作为withContext() 函数的返回值返回,因此基本上相当于val result = async{5+5}.async() 的写法。唯一不同的是,withContext() 函数强制要求我们指定一个线程参数,关于这个参数我准备好好讲一讲。

你已经知道,协程时一种轻量级线程的概念,因此很多传统编程情况下需要开启多线程执行的并发任务,现在只需要在一个线程下开启多个协程来执行就可以了。但是这并不意味着我们就永远不需要开启线程了,比如说Android 中要求网络请求必须在子线程中进行,即使你开启了协程去执行网络请求,假如它是主线程当中的协程,那么程序依然会出错。这个时候我们就应该通过多线程参数给协程指定一个具体的运行线程。

线程的参数主要有以下3种值可选:Dispatchers.Default 、 Dispatchers.IO 和Dispatchers.Main。

Dispatchers.Default 表示会使用一种默认低并发的线程策略,当你要执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率,此时就可以使用Dispatchers.Default;

Dispatchers.IO 表示会使用一种高并发的线程策略,当你要执行的代码大多数时间是在阻塞和等待中,比如说执行网络请求时,为了能够支持更高的并发数量,此时就可以使用Dispatchers.IO;

Dispatchers.Main 则表示不会开启子线程,而是在Android 主线程中执行代码,但是这个值只能在Android 项目中使用,纯Kotlin 程序使用这种类型的线程参数会出现错误。

事实上,在我们刚才所学的协程作用域构建器中,除了coroutineScope 函数之外,其他所有的函数都是可以指定这样一个线程参数,只不过withContext() 函数是强制要求指定的,而其他函数则是可选的。

到目前为止,你已经掌握了协程中最常用的一些用法,并且了解了协程的主要用途就是可以大幅度地提升并发编程的运行效率。但实际上,Kotlin 中的协程还可以对传统回调的写法进行优化,从而让代码变得更加简洁,那么接下来我们就开始学习这部分的内容。

11.7.3 使用协程简化回调的写法

在11.5 节,我们学习了编程语言的回调机制,并使用这个机制实现了获取异步网络请求数据响应的功能。不知道啊你有没有发现,回调机制基本上是依靠匿名类来实现的,但是匿名类的写法通常比较烦琐,比如如下代码:


    HttpUtil.sendHttpRequest(address,object :HttpCallbackListener{
        override fun onFinish(response: String) {
            // 得到服务器返回的具体内容
        }

        override fun onError(e: Exception) {
            // 在这里对异常情况进行处理
        }
    })

在多少个地方发起网络请求,就需要写多少次这样的匿名类实现。这不禁引起了我们的思考,有没有更加简单一点的写法呢?

在过去,可能确实没有什么更加简单的写法了。不过现在,Kotlin 的协程使我们这种设想成为了可能,只需要借助 suspendCoroutine 函数就能将传统回调机制的写法大幅简化,下面我们就来具体学习一下。

suspendCoroutine 函数必须在协程作用域或挂起函数中才能调用,它接收一个Lambda 表达式参数,(suspendCoroutine 函数)主要作用是将当前协程立即挂起,然后在一个普通的线程中执行Lambda 表达式中的代码。Lambda 表达式的参数列表上会传入一个Continuation 参数,调用它的resume() 方法或resumeWithException() 方法可以让协程恢复执行。

了解了 suspendCoroutine 函数的作用之后,接下来我们就可以借助这个函数来对传统的回调写法进行优化。首先定义一个request() 函数,代码如下所示:

suspend fun request(address:String) : String{
    return suspendCoroutine {continuation ->
        HttpUtil.sendHttpRequest(address,object :HttpCallbackListener{
            override fun onFinish(response: String) {
                continuation.resume(response)
            }

            override fun onError(e: Exception) {
                continuation.resumeWithException(e)
            }
        })
    }
}

可以看到,request() 函数是一个挂起函数,并且接收一个address 参数。在request() 函数的内部,我们调用了刚刚介绍的suspendCoroutine 函数,这样当前协程就会被立刻挂起,而Lambda 表达式中的代码则会在普通线程中执行。接着我们在Lambda 表达式中调用HttpUtil.sendHttpRequst() 方法发情网络请求,并通过传统回调的方式监听请求结果。如果请求成功就调用Continuation的 resume() 方法恢复被挂起的协程,并传入服务器响应的数据,该值会成为suspendCoroutine 函数的返回值。如果请求失败,就调用Continuation 的 resumeWithException() 恢复被挂起的协程,并传入具体的异常原因。

你可能会说,这里不是仍然使用了传统回调的写法吗?代码怎么就变得更加简化了?这是因为,不管之后我们要发起多少次网络请求,都不需要再进行回调实现了。比如说获取百度首页的响应数据,就可以这样写:

suspend fun getBaiduResponse(){
    try {
        val response = request("https://www.baidu.com/")
        // 对服务器响应的数据进行处理
    }catch (e:Exception){
        // 对异常情况进行处理
    }
}

怎么样,有没有觉得代码变得清爽了很多呢?由于getBaiduResponse() 是一个挂起函数,因此当它调用了requst() 函数时,当前的协程就会立刻被挂起,然后一直等待网络请求成功或失败后,当前协程才能恢复运行。这样即使不使用回调的写法,我们也能够获取异步网络请求的响应数据,而如果失败,则会直接进入catch 语句中。

不过这里你可能又会产生新的疑惑,getBaiduResponse() 函数被声明成了挂起函数,这样它也只能在协程作用域或被其他挂起函数调用了,使用起来是不是非常有局限性?确实如此,因为suspendCoroutine 函数本身就是要结合协程一起使用的。不过通过合理的项目架构设计,我们可以轻松地将各种协程的代码应用到一个普通的项目当中,在第15 章的项目实战环节你将学到这部分知识。

实际上,suspendCoroutine 函数几乎可以用于简化任何回调的写法,比如之前使用Retrofit 来发起网络请求需要这样写:

    val appService = ServiceCreator.create<AppService>()
    appService.getAppData().enqueue(object : Callback<List<App>> {
        override fun onFailure(call: Call<List<App>>, t: Throwable) {
            // 在这里对异常情况进行处理
        }

        override fun onResponse(call: Call<List<App>>, response: Response<List<App>>) {
            // 得到服务器返回的数据
        }
    })

有没有觉得这里的回调写法也是相当烦琐的?不用担心,使用suspendCoroutine 函数,我们马上就能对上诉写法进行大幅度的简化。

由于不同的Service 接口返回的数据类型也不同,所以这次我们不能像刚才那样针对具体的类型进行编程了,而是要使用泛型的方式。定义一个await() 函数,代码如下所示:

suspend fun<T> Call<T>.await():T{
    return suspendCoroutine {
        enqueue(object :Callback<T>{
            override fun onFailure(call: Call<T>, t: Throwable) {
                it.resumeWithException(t)
            }

            override fun onResponse(call: Call<T>, response: Response<T>) {
                val body = response.body()
                if (body != null){
                    it.resume(body)
                }else{
                    it.resumeWithException(RuntimeException(" response body is null"))
                }
            }

        })
    }
}

这段代码相比刚才的request() 函数又复杂了一点。首先await() 函数仍然是一个挂起函数,然后我们给它声明了一个泛型T,并将await() 函数定义成了Call<T> 的扩展函数,这样所有返回值是Call 类型的Retrofit 网络请求接口就都可以直接调用await() 函数了。

接着,await() 函数中使用了suspendCoroutine 函数来挂起当前协程,并由于扩展函数的原因,我们现在拥有了Call 对象的上下文,那么这里就可以直接调用enqueue() 方法让Retrofit 发起网络请求。接下来,使用同样的方式对 Retrofit 响应的数据或网络请求失败的情况进行处理就可以了。另外还有一点需要注意,在onResponse() 回调当中,我们调用body() 方法解析出来的对象是可能为空的。如果为空的话,这里的做法是手动抛出一个异常,你也可以根据自己的逻辑进行更加合适的处理。

有了await() 函数之后,我们调用所有Retrofit 的Service 接口都会变得极其简单,比如刚才回调的功能就可以使用如下写法进行实现:

suspend fun getAppData(){
    try {
        val appList = ServiceCreator.create<AppService>().getAppData().await()
        // 对服务器响应的数据进行处理
    }catch (e:Exception){
        // 对异常情况进行处理
    }
}

没有了冗长的匿名类实现,只需要简单调用一下await() 函数就可以让Retrofit 发起网络请求,并直接获得服务器响应的数据,有没有觉得代码变得极其简单?当然你可能会觉得,每次发起网络请求都要进行一次try catch 处理比较麻烦,其实这里我们也可以选择不处理,在不处理的情况下,如果发生了异常就会一层层向上抛出,一直到某一层的函数处理了为止。因此,我们也可以在某一个统一的入口函数中只进行一次try catch ,从而让代码变得更加精简。

关于Kotlin 的协程,你已经掌握了足够多的知识理论,下一步就是将它应用到实际的Android 项目当中了。不用着急,我们将会在第15 章中学习这部分内容,现在先回顾一下本章所学的所有知识吧。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值