写在前面
本文上接Kotlin进阶学习4,上次的文章学习了泛型的进阶知识,真是十分难理解的知识呢。这次(最后)来学习一下Kotlin中极具特色的协程。
协程
介绍
什么是协程呢?它其实和线程有些类似,可以将它理解成一种轻量级的线程。要知道线程是十分重量级的,它需要依赖操作系统的调度的才能实现不同线程之间的切换。而使用协程却可以仅在编程语言的层面就能实现不同协程的切换,从而大大提升了并发编程的运行效率。简单来说,协程允许我们在单线程模式模拟多线程编程的效果,代码的挂起和恢复都是由编程语言控制的,和操作系统无关。
基本使用——GlobalScope.launch
Kotlin并没有把协程纳入标准库中,因此我们需要导入相应的依赖库:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0"
接下来我们新建一个CoroutinesTest.kt文件,定义一个main()函数,开始学习协程。
首先的问题就是,如何开启一个协程?最简单的方式就是使用GlobalScope.launch函数:
fun main(){
GlobalScope.launch{
println("codes run in coroutine scope")
}
}
GlobalScope.launch函数可以创建一个协程的作用域,这样传递给launch函数的代码块就是在协程中运行的了。但如果这时候你运行一下,发现没有任何东西打印出来。这是为什么呢?因为每次GlobalScope.launch函数创建的都是一个顶层协程,这种协程当应用程序运行结束的时候也会一起结束。因此我们的日志还没来得及打印呢,程序运行就结束了。
这个东西的解决也很简单,我们让程序睡一会就好了:
fun main(){
GlobalScope.launch{
println("codes run in coroutine scope")
}
Thread.sleep(1000)
}
这样子,我们的日志就会打印出来了:
但这样还是有问题啊,如果我们的代码块中的代码不能在1秒钟内运行结束,就会被强制中断。比如:
fun main(){
GlobalScope.launch{
println("codes run in coroutine scope")
delay(1500)
println("codes run in coroutine scope finished")
}
Thread.sleep(1000)
}
delay()函数可以让协程延迟指定时间后执行,但和Thread.sleep()方法不一样,它是一个非阻塞式的挂起函数,只会挂起当前协程,而不会影响其他协程。但Thread.sleep()方法会阻塞当前线程,这样该线程下的所有协程序都会阻塞。delay()函数只能使用在协程的作用域或者其他挂起函数中。
这里,我们让协程挂起了1.5秒,而主线程却只阻塞了1秒,运行一下发现,第二个日志信息没有打印出来。因为还没来得及运行,程序就结束了。
那么有没有什么办法让程序在协程中的所有代码都运行完了之后再结束呢?当然可以了,使用runBlocking函数即可。
基本使用——runBlocking
我们直接上代码:
fun main(){
runBlocking{
println("codes run in coroutine scope")
delay(1500)
println("codes run in coroutine scope finished")
}
}
runBlocking函数容易会创建一个协程的作用域,但它可以保证在协程作用域内的所有代码和子协程没有全部执行完之前一直阻塞当前线程。需要注意的是,runBlocking应该只在测试环境使用,生产环境使用会造成一定的性能问题。
我们运行代码:
可以看到,两条日志都已经打印出来了。
可我们虽然让代码运行在协程了,但好像没啥好处啊。这是因为当前的代码都只在一个协程中运行,当碰到一些需要高并发的场景时,协程相比于线程的优势就体现出来了。
那么如何开启多个协程呢?使用launch函数就可以了:
fun main() {
runBlocking {
launch {
println("launch1")
delay(1000)
println("launch1 finished")
}
launch {
println("launch2")
delay(1000)
println("launch2 finished")
}
}
}
这里的launch函数和我们之前使用的Global.launch函数不一样,首先它必须在协程的作用域中才能使用,其次他会在当前协程下创建子协程,子协程的特点是如果外层作用域的协程结束了,该作用域下的所有子协程都会结束。运行看看结果:
可以看到,两个协程交替打印日志,说明他们确实像多线程那样是并发运行的。但他们却只运行在同一个线程中,只是由编程语言来决定如何在多个协程之间进行调度,这会使得协程的运行效率奇高。
基本使用——suspend
但随着launch函数中的逻辑越来越复杂,我们可能会需要把一部分代码放到另一个单独的函数。但那一个单独的函数并没有在协程作用域,怎么使用delay()这样的挂起函数呢?
为此Kotlin提供了一个suspend关键字,使用它可以将任意函数声明成挂起函数,而挂起函数是可以互相调用的:
suspend fun printDot(){
println(".")
delay(1000)
}
这样就可以在该函数中调用delay()函数了。
但是,suspend关键字只能将一个函数声明为挂起函数,却无法提供给它协程作用域的,比如你想在printDot()函数调用launch函数,肯定会失败的。
这个问题可以借助coroutineScope函数解决:
基本使用——corouitneScope
coroutineScope也是一个挂起函数。因此可以在任何其他挂起函数中使用。它的特点是会继承外部的协程作用域并创建一个子作用域:
suspend fun printDot() = coroutineScope {
launch {
println(".")
delay(1000)
}
}
这样,我们就可以在printDot()函数里使用launch函数了。另外,coroutineScope函数还跟runBlocking函数有点像,可以保证其作用域中的所有代码和子协程在执行完之前,会一直阻塞当前协程。
需要注意的是,虽然coroutineScope函数与runBlocking很类似,但coroutineScope函数只会阻塞当前协程,既不影响其他协程,也不影响任何线程,因此是不会造成任何性能上的问题的。
协程的取消
上面我们学习了几种开启协程的方法,但并没有学习取消协程的方法。不管是GlobalScope.launch函数还是launch函数,他们都会返回一个Job对象,调用Job对象的cancel方法就可以取消协程了:
val job = GlobalScope.launch{
// 具体逻辑
}
job.cancel()
但如果我们创建顶层协程,当Activity关闭时,就需要逐个调用所有已创建协程的cancel()方法,这样的代码肯定是无法维护的。因此,像GlobalScope.launch这种作用域构建器,在实际项目中也是不怎么用的。以下是一种项目中常见的写法:
val job = Job()
val scope = CoroutineScope(job)
scope.launch{
// 具体逻辑
}
job.cancel()
我们首先创建了一个Job对象,然后传入了CoroutineScope()函数,就可以随便调用它的launch函数创建协程了。这样想要关闭的话,直接使用job.cancel()就可以关闭所有协程了。
更多用法——async
上面的学习,我们已经知道了launch函数可以创建一个新协程,但launch函数并不能获取执行的结果。因为他的返回值永远是一个Job对象,那么有没有什么办法可以创建新协程并且获取结果呢?可以使用async函数。
async函数必须在协程作用域使用,它会创建一个新的子协程,并返回一个Deferred对象,我们想获取async函数代码块的执行结果,只需要调用Deferred对象的await()函数即可。
fun main(){
runBlocking{
val result = async{
5 + 5
}.await()
println(result)
}
}
运行可以看到,我们获得了结果。
但async函数还不止于此,事实上,在调用了async函数后,代码块中的代码就会立即开始执行,当调用await()方法时,如果代码块中代码还没执行完,那么await()方法会将当前协程阻塞住,直到可以获得async函数的结果。
更多用法——withContext
withContext()是一个挂起函数,大致可以理解为async函数的一种简化版写法:
fun main(){
runBlocking{
val result = withContext(Dispatchers.Default){
5 + 5
}
println(result)
}
}
调用withContext()函数后,会立即执行代码块中的代码,同时将当前线程阻塞住,当代码块中的代码执行完后,会将最后一行的执行结果作为返回值返回。不同的是,withContext()函数强制我们传入一个线程参数。
我们已经了解到,协程是一种轻量级的线程,但这并不意味着我们不再需要线程了。Android中要求网路请求必须在子线程执行,即便开启了协程,如果是在主线程中的协程,那么程序依然会出错。这时候我们就需要为协程指定一个具体的运行线程。
线程参数主要有三种值可选:Dispatchers.Default,Dispatchers.IO和Dispatchers.Main,Default表示一种默认的低并发的线程策略,当你执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率,此时就可以利用Dispatchers.Default。Dispatchers.IO表示会使用一种较高并发的线程策略,当你要执行的代码大多数时候处在阻塞或等待时,比如说网路请求,就可以使用这个线程。Dispatchers.Main表示不会开启子线程,而是在Android主线程中执行。当然这个值只能在Android项目中使用,纯Kotlin项目会报错。
实际上,我们刚才使用的函数,除了coroutineScope函数外,都可以指定线程参数的,不过withContext()函数是强制要求的。
简化回调写法
我们早就学过使用Retrofit进行网络请求了。但回调机制大部分都是依靠匿名类实现的,用起来比较繁琐。使用Kotlin的协程机制,使用suspendCoroutine函数就能大幅度简化写法。
suspendCoroutine函数必须在协程作用域或挂起函数才可以调用,接收一个Lambda表达式参数,主要作用是将当前协程立即挂起,然后在一个普通的线程中执行Lambda表达式中的代码。在Lambda参数列表上会传入一个Continuation参数,调用它的resume()或者resumeWithException()就可以让协程恢复了。
我们先来定义一个await()函数:
suspend fun <T> Call<T>.await():T {
return suspendCoroutine {
continuation -> enqueue(object :Callback<T>{
override fun onResponse(call: Call<T>, response: Response<T>) {
val body = response.body()
if(body != null){
// 请求成功,继续
continuation.resume(body)
}else{
// 请求成功但无结果,继续
continuation.resumeWithException(RuntimeException("response body is null"))
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
// 请求失败,返回错误信息
continuation.resumeWithException(t)
}
})
}
}
这段代码看起来很复杂,解释一下:首先await()函数是一个挂起函数,然后给他声明了一个泛型T,并将await()函数定义成了Call
接着,await()函数中使用了suspendCoroutine函数来挂起当前协程,并且由于扩展函数的原因,我们现在有了Call对象的上下文,可以直接使用enqueue()方法让Retrofit函数发起请求。之后我们在其中写逻辑代码即可。这样,我们要实现一个Retrofit请求会变得极为简单:
suspend fun getAppData(){
try{
val appList = ServiceCreator.create<AppService>().getAppData().await()
// 处理数据
}catch(e:Exception){
// 处理异常
}
}
这里的ServiceCreator.create
object ServiceCreator {
private const val BASE_URL = "填入URL"
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
fun <T> create(serviceClass: Class<T>) :T = retrofit.create(serviceClass)
inline fun <reified T> create():T = create(T::class.java)
}
这样下来,我们发起网络请求就十分简单了。
总结
总的来说,我们的Kotlin学习也告一段落了。在这次的协程学习中,我们学习了很多相关的知识,并最后用它简化了我们的代码。最后,希望看到这篇文章的你我路子越走越远吧。