Kotlin 协程之一:基础使用

本文介绍了Kotlin协程的基础使用,包括为什么使用协程(简化代码、提高可读性和减少性能损耗)、如何使用协程(工程配置、协程作用域、派发器、启动任务、挂起、异常捕获和取消)。协程通过挂起函数实现异步任务的顺序执行,避免回调地狱,同时通过delay()方法优化了线程使用,减少性能损耗。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

系列文章:
Kotlin 协程之一:基础使用
Kotlin 协程之二:原理剖析
Kotlin 协程之三:Android中的应用

一.什么是协程

kotlin在1.3版本后,提供了协程coroutine库,协程提供了一种,简化异步任务处理的方案。

二.为什么用协程

1.简化代码,增加可读性

使用协程可以用简洁直观可读性高的写法,实现多重依赖关系的异步任务的书写。

先来回想一下我们平时的异步任务怎么实现呢?

(1).通过Callback回调的方式

//get token
TokenCall().addCallback(object:Callback{
  override fun onCall(token: String) {
    //get data with token
    DataCall(token).addCallback(object :Callback{
      override fun onCall(data: String) {
        //do something with data...
      }
    })
  }
})

这种经典实现的问题就不用多说了,代码量大,可读性差,还有最严重的回调地狱。

(2)通过链式调用

fun getToken():String{}
fun getData(token:String):String{}
Single.fromCallable {
  //get token
  getToken()
}.map {token->
  //get data with token
  getData(token)
}.subscribe {data->
  //do something with data...
}

RXJava这种链式实现多重依赖的异步任务的方法,是现在我们用的最多的方案,可以有效地解决回调地狱的问题,相比于callback回调,可读性也增加了许多。

除了RXJava,java8提供的CompletableFuture、JS中提供的Promise,都是类似的解决方案。

这种写法虽然将异步任务过程打平的展现出来,但是毕竟还是在各个异步任务逻辑外面,套了一层RXJava的api,那有没有更简单的方法,只通过异步任务的逻辑代码的组合,就实现多重依赖的异步任务的处理呢?kotlin的协程为我们提供了一种方案。

(3)通过kotlin的协程

//挂起函数
suspend fun getToken() = suspendCancellableCoroutine<String>{}
suspend fun getData(token:String) = suspendCancellableCoroutine<String> {}
GlobalScope.launch {
  //get token
  val token = getToken()
  //get data with token
  val data = getData(token)
  //do something with data
}

我们从launch{}中可以看到,使用了kotlin提供的协程,可以像写普通的方法顺序调用一样,来写有依赖关系的异步任务的处理,如此一来,可读性瞬间提升,且真正的逻辑处理代码量很少很少。

2.合理使用线程,减少性能损耗

协程依赖于线程的使用,但是挂起协程不会像挂起线程一样(如Thread.sleep())阻塞线程,因此挂起协程非常轻量级,不会阻塞线程的后续执行和复用。

除此之外还优化了线程的使用方式,可以有效地做到尽量复用线程,减少线程间切换所造成的性能损耗。

比如我们在一个子线程里,需要暂停两秒,我们会怎么做呢?

(1)使用Thread.sleep()方法

Thread {
  Thread.sleep(2000)
}.start()

线程运行时,会阻塞线程2秒,在这2秒内,线程无法执行其他操作;并且sleep会使线程进入阻塞状态,导致线程状态切换,这一过程也需要很大的开销。

(2)使用协程的delay()方法

GlobalScope.launch { 
  delay(2000)
  //do something after 2s
}
  1. launch协程会运行在一个线程中,而delay方法只是将当前协程挂起,2秒后执行后面的代码;但是在这2秒内,该线程并没有因为协程的挂起而阻塞,而是相当于将该协程放入了个事件队列,在2秒后会继续执行后面的代码;所以在这2秒内,该线程可以继续用来执行其他任务(如线程池队列中有其他任务),避免了线程的阻塞状态的开销。

  2. 而且在协程默认的线程池中,处理逻辑也是更偏向于像当前线程的任务队列中添加任务,而不是开启新的工作线程;在多cpu情况下,会开启不超过cpu数的线程数,并可以从其他线程的任务队列中抢夺任务,这样就可以最大程度的复用已有的工作线程。

三.怎么使用协程

现在我们就来看看,如何使用协程处理异步任务。

1.工程配置

首先,我们要在工程里引入协程。

这里注意,kotlin相关库的版本最好都用1.3.+的版本,并且要符合gradle插件版本在3.0.0版本以上才可以使用。

//project.gradle
classpath 'com.android.tools.build:gradle:3.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.40"
//app.gradle
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0-M2'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.40"

2.协程作用域(协程运行环境)

引入了协程的开发环境,我们就可以使用了,首先,我们要知道,协程是一套管理和运行异步任务的框架,所以需要有运行的环境,也叫协程的作用域,在这个作用域里,才可以使用协程来执行异步任务;就相当于java中,我们把逻辑写在一个Runnable对象的run方法中才能在线程中运行一样。

(1)全局环境

GlobalScope.launch {}

GlobalScope代表协程的全局作用域,在该作用域启动的协程为顶层协程,没有父任务(下面会讲),且该scope没有Job对象(管理任务),所以无法对整个scope执行cancel()操作,所以如果没有手动取消每个任务,会造成这些任务一直运行(覆水难收),可能会导致内存泄露等问题。

(2)局部环境

CoroutineScope(Dispatchers.Main).launch {}

通常我们会通过创建CoroutineScope,来实现一个自己的协程作用域,并且可以指定派发器(下面会讲),在我们需要取消该scope下所有任务时(比如Activity退出时),调用scope.cancel()方法,就可以取消该scope下所有正在进行的任务,这才是我们所期望的。

3.协程派发器(任务执行环境)

有了运行环境执行异步任务,还需要有派发器将不同的任务派发到不同的线程执行,这也是我们经常遇到的,比如网络请求在工作线程,结果回来后的UI展示,需要在主线程进行。kotlin给我们提供了一些默认的Dispatcher:

  1. Dispatchers.IO:工作线程池,依赖于Dispatchers.Default,支持最大并行任务数。

  2. Dispatchers.Main:主线程,这个在不同平台定义不一样,所以需要引入相关的依赖,比如Android平台,需要使用包含MainLooper的handler来向主线程派发。

    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0-M2'
    
  3. Dispatchers.Default:默认线程池,核心线程和最大线程数依赖cpu数量。

  4. Dispatchers.Unconfined:无指定派发线程,会根据运行时的上线文环境决定。

通常我们用的就是1和2,在创建scope时,可以指定派发器,如CoroutineScope(Dispatchers.Main),就是指定该scope启动的协程,都在主线程执行。

4.启动协程任务

现在有了协程运行环境和任务执行环境,接下来要做的就是启动一个协程了!

(1)对于一个scope对象,通常使用launch和async方法创建协程。

CoroutineScope(Dispatchers.IO).launch {  }
CoroutineScope(Dispatchers.IO).async {  }

而两者的最大不同是,async会创建一个Deferred的协程,可以用来等待该协程执行完毕再进行后续操作。

CoroutineScope(Dispatchers.IO).launch {
  val job = async {  }
  val data = job.await()
  //do something with data
}

上述代码,在launch的协程执行到await()方法时,会将协程挂起(而不是线程挂起,不会阻塞线程),等待async异步任务执行完成后,会返回结果到data,从而进行后续逻辑。

当然,如果在调用await()方法时,async协程已经执行完毕拥有了结果,那么不会挂起协程,而是直接返回结果到data变量里。

(2)内部协程

CoroutineScope(Dispatchers.IO).launch {
  launch { 
    async { 
      //do something
    }
  }
}

在一个协程内部,也可以创建协程,创建的协程与外部的协程为父子关系,这有助于联动的cancel、异常处理等(下面会说)。

(3)改变协程任务执行环境

加入我们在scope的一个协程内部,想创建一个运行在主线程的协程任务怎么办呢?这个也是必要的,比如我们在IO线程执行异步请求,数据回来后在主线程进行展示。

CoroutineScope(Dispatchers.IO).launch {
  val dataJob = async {
    //request data
  }
  async(Dispatchers.Main) {
    //prepare
    val data = dataJob.await()
    //display with data
  }
}

这是一个很经典的demo,我们创建了一个async协程执行网络请求,然后在主线程中创建一个协程任务,做一些准备工作,然后等待数据回来,在进行展示,在等待期间,主线程不会阻塞。

而假如我们需要在主线程展示数据后,继续发起一个请求也是可以的,demo中的最后一个async,会在withContext协程执行完后执行,也就是说,withContext协程会挂起外部launch协程,知道自己内部协程全部返回后,才会恢复外部的launch协程,执行后面的async协程,这一点也很有用。

5.协程挂起

协程可以顺序完成异步任务,那么在等待上一个协程任务完成时,当前的协程需要被挂起(不阻塞线程),这个协程挂起,就是kotlin协程实现的精髓。

CoroutineScope(Dispatchers.IO).launch {
  val dataJob = async {
    //request data
  }
  withContext(Dispatchers.Main) {
    //prepare
    val data = dataJob.await()
    //display with data
  }
  //do some post async job after display data
  async{ }
}

如上代码,我们需要先请求网络数据,再在主线程进行数据展示,展示后,我们需要再发送其他的请求。通常我们可以在得到data后,再使用一个async协程,并指定IO线程执行任务即可,那样的话又是很多嵌套,失去了协程的意义,而协程的挂起为我们提供了解决办法。

withContext()方法,除了可以指定他启动的协程任务的执行环境外,最重要的是,他会挂起当前协程,即外部的launch的协程,直到withContext启动的协程任务完成之后(包括其所有内部协程),才会重新恢复外部的launch协程,执行下面的async语句。协程的挂起也为我们提供了组合多个异步任务的可能性。

那withContext这个API的协程挂起和恢复是固定的,我们想自己写一个可以么?当然可以,这就用到kotlin协程为我们提供的API和关键字来声明方法即可。

CoroutineScope(Dispatchers.IO).launch {
  //prepare
  val data = getData()
  //display with data
}
//挂起当前协程
private suspend fun getData() = suspendCoroutine<String> {
  Call().addCallback(object : Callback {
    override fun onCall(res: String) {
      //恢复协程
      it.resume(res)
    }
  })
}

使用suspend关键字,表明方法可以被挂起,称为挂起函数suspendCoroutine方法,会挂起当前的协程,并把挂起的协程返回到代码块的参数中,代码块执行我们自己的逻辑,如上,当数据回来后,调用协程的resume方法,传入结果,就可以恢复协程,并在getData()处拿到结果。

这就是协程的挂起,上面这个demo有没有发现,可以用来将我们现有的请求callback模式,转换为协程模式?这样就可以按协程的方式写请求了!

6.异常捕获

现在我们成功通过协程执行了一段代码,对于执行代码,必不可少就是对可能的异常进行捕获和处理。

kotlin的协程,也有一套自己的捕获异常机制。

//1.根协程为launch
CoroutineScope(Dispatchers.IO).launch {
  async{ launch { throw IllegalStateException("this is an error") } }
}
//2.根协程为async
CoroutineScope(Dispatchers.IO).async {
  async{ launch { throw IllegalStateException("this is an error") } }
}
//3.捕获async{}.await()异常
CoroutineScope(Dispatchers.IO).async {
  try {
    val job = async { throw IllegalStateException("this is an error") }
    val data = job.await()
    //do something with data
  } catch (e: Exception) {
    log(e.message)
  }
}

先来简单描述下协程的异常机制:

  1. 在协程内部通过try-catch捕获的异常,由我们自己处理(和java异常一样)。

  2. 未捕获的异常,协程本身默认不处理,而是一层一层的交由父协程,直到根协程进行处理。

  3. 根协程处理异常时,会使用注册的CoroutineExceptionHandler对象进行处理;Android的协程依赖包,会引入并自动注册一个该对象,处理行为与java处理一致(直接交由UncaughtExceptionHandler)。

  4. 有些协程类型重写了处理异常方法,默认不处理异常,比如async式协程,这类协程作为根协程的话,最终会导致异常丢失,继续执行后续逻辑。

  5. 内部协程出现异常,会逐层cancel掉父协程。

据此机制,我们看下上面三个demo的异常处理情况:

  1. 根协程为launch式协程时,会使用Android提供的handler进行异常抛出,最终表现就是应用崩溃。

  2. 根协程为async式协程时,不会处理异常,最终表现就是没异常的抛出(但继续执行下去其实很危险)。

  3. async式协程的await()方法,在返回异常时,会进行抛出,所以我们可以通过try-catch这个await()方法,来捕获async式协程产生的异常。

  4. 这里需要注意的是,如果根协程为launch式协程,即使我们使用(3)描述的办法,依然没有办法阻止崩溃,因为launch协程会处理异常并抛出。

综上所述,对于我们想捕获的异常,最靠谱的办法还是在协程内部自己捕获异常进行处理,避免因为未捕获而直接崩溃。

7.取消协程

现在我们已经可以完整的运行一个协程任务了,还有一个问题,就是如何取消协程呢?这个也很重要,比如Android中的网络请求等资源数据的加载,需要在页面关闭时中断,从而减少性能流量的损耗,以及避免一些内存泄露的问题。

//1.通过协程cancel()
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
  launch {
    while (true) {
      log("inner-launch")
    }
  }
  while (true) {
    log("outer-launch")
  }
}
scope.launch {
  delay(1000)
  scope.cancel()
}
//2.通过CorouinteScope.cancel)(
val scope = CoroutineScope(Dispatchers.IO)
val outerJob = scope.launch {
  launch {
    while (true) {
      log("inner-launch")
    }
  }
  while (true) {
    log("outer-launch")
  }
}
scope.launch {
  delay(1000)
  outerJob.cancel()
}

kotlin协程的取消规则是这样的:

  1. 父协程调用cancel(),会取消自己以及所有子(内部)协程。

  2. 子协程调用cancel(),默认不会取消父协程。

  3. 可以通过调用CoroutineScope的cancel()方法,取消掉该scope产生的所有协程。

据此,以上两个demo的行为是这样的:

  1. outer和inner的协程全部被取消。

  2. outer和inner以及scope开启的所有协程被取消。

但是,运行上面的demo我们会发现,log会一直输出东西,这是为什么呢?因为协程的cancel()原理是改变了协程对象的内部状态,但并没有终止逻辑代码的调用,也就是说协程状态和代码运行是两个部分,具体的原理我们在下面会说。那我们应该怎么办呢?

答案很简单,既然改变了协程的状态,那么我们用协程状态字段来判断协程是否被取消了即可,将判断条件代码改成如下:

while (isActive) {
  log("outer-launch")
}

isActive是协程的一个状态字段。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值