协程,可以将它简单地理解成一种轻量级的线程。要知道,之前所学习的线程是非常重量级的,需要依靠操作系统的调度才能实现不同线程之间的切换。而使用协程却可以仅在编程语言的层面就能实现不同协程之间的切换,从而大大提升了并发编程的运行效率。
协程允许我们在单线程模式下模拟多线程编程的效果,代码执行时的挂起与恢复完全是由编程语言来控制的,和操作系统无关。这种特性使得高并发程序的运行效率得到了极大地提升。
为什么要使用协程?
- 轻量、高效
- 简单、好用
- 可以用同步的方式编写异步代码
协程的基本用法
添加依赖
首先要添加依赖库:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.1"
// 此依赖库是 Android 项目才会用到的,纯 Kotlin 程序其实用不到。
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1"
GlobalScope.launch 函数
最简单的开启一个协程的方式:使用 GlobalScope.launch 函数,它可以创建一个协程的作用域。并且创建的永远是顶层协程,这一点和线程比较像,因为线程也没有层级这一说,永远是顶层的。
fun main(){
// GlobalScope.launch 函数每次创建的都是一个顶层协程,这种协程当应用程序运行结束时也会跟着一起结束,所以,
// 代码块中的代码还没来得及运行,应用程序就结束了。解决的办法是:让程序延迟一段时间再结束。
GlobalScope.launch {
println("codes run in coroutine scope")
// delay 函数可以让当前协程延迟指定时间后再运行。
// delay 函数是一个非阻塞式的挂起函数,它只会挂起当前协程,并不会影响其它协程的运行。
// delay 函数只能在协程的作用域或其他挂起函数中调用。
// ----------------------------------------------------------------------
// 而 Thread.sleep() 会阻塞当前的线程,这样运行在该线程下的所有协程都会被阻塞。
delay(1500)
println("codes run in coroutine scope finished") // 不会运行
}
// 如果代码块中的代码不能在 1 秒钟内运行结束,那么就会被强制中断。
Thread.sleep(1000)
}
runBlocking 函数
借助 runBlocking 函数可以让应用程序在协程中所有代码都运行完了之后再结束。
注意:此函数通常只应该在测试环境下使用,在正式环境中使用容易产生一些性能上的问题。
fun main(){
// runBlocking 同样会创建一个协程的作用域,
// 但是它可以保证协程作用域的内的所有协程和子协程没有全部执行完之前一直阻塞当前线程。
runBlocking {
println("codes run in coroutine scope")
delay(1500)
println("codes run in coroutine scope finished")
}
}
launch 函数
使用 launch 函数可以创建多个协程,它必须在协程的作用域中才能调用,其次,它会在当前协程的作用域下创建子协程。子协程的特点是如果外层作用域的协程结束了,该作用域下的所有子协程也会一同结束。
fun main(){
runBlocking {
launch {
println("launch1")
delay(1000)
println("launch1 finished")
}
launch {
println("launch2")
delay(1000)
println("launch2 finished")
}
}
// 运行结果:
// launch1
// launch2
// launch1 finished
// launch2 finished
// 测试性能
test()
}
fun test(){
val start = System.currentTimeMillis()
runBlocking {
// 循环创建了 10 万个协程
repeat(100000){
launch {
println(".")
}
}
}
// 查看消耗时间
val end = System.currentTimeMillis()
println(end - start)
// 运行结果:433 毫秒
}
suspend 关键字
在 launch 函数中编写的代码是拥有协程作用域的,但是提取到一个单独的函数中就没有协程作用域了。
这时可以使用 suspend 关键字,它可以将任意函数声明成挂起函数,而挂起函数之间都是可以相互调用的。
suspend fun printDot(){
println(".")
delay(1000)
}
coroutineScope 函数
但 suspend 关键字无法提供协程作用域,也就无法调用像 launch 函数。这时可使用 coroutineScope 函数,它也是一个挂起函数,因此可以在任何其他挂起函数中调用。它的特点是会继承外部的协程作用域并创建一个子作用域。
fun main(){
runBlocking {
printDot()
}
}
suspend fun printDot() = coroutineScope{
launch {
println(".")
delay(1000)
}
}
coroutineScope 函数和 runBlocking 函数还有点类似,它可以保证其作用域内的所有代码和子协程在全部执行完之前,会一直阻塞当前协程。
fun main(){
// 创建了一个协程作用域
runBlocking {
// 创建了一个子协程作用域
coroutineScope {
// 创建了一个子协程
launch {
for (i in 1..10){
println(i)
delay(1000)
}
}
}
println("coroutineScope finished")
}
println("runBlocking finished")
// 运行结果:
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9
// 10
// coroutineScope finished
// runBlocking finished
}
但是,coroutineScope 函数只会阻塞当前协程,既不影响其它协程,也不影响任何线程,因此是不会造成任何性能上的问题的。而 runBlocking 函数由于会阻塞当前线程,而我们又恰好在主线程中调用它的话,那么就有可能会导致界面卡死的情况,所以不太推荐在实际项目中使用。
更多的作用域构造器
上面学习了几种作用域构造器,它们都可以用于创建一个新的协程作用域。不过,GlobalScope.launch 和 runBlocking 函数是可以在任意地方调用的,coroutineScope 函数可以在协程作用域或挂起函数中调用,而 launch 函数只能在协程作用域中调用。
Job 对象
其中,GlobalScope.launch 由于每次创建的都是顶层协程,一般也不太建议使用,因为它管理起来成本太高了,当 Activity 关闭时,需要逐个调用所有已创建协程的 cancel() 方法。除非非常明确就是要创建顶层函数。
/**
* 取消协程的办法
*/
fun main(){
// 不管是 GlobalScope.launch 函数还是 launch 函数,它们都会返回一个 Job 对象。
val job = GlobalScope.launch {
//处理具体逻辑
}
job.cancel()
}
实际项目中比较常用的写法如下:
fun main(){
// 创建 Job 对象并传入到 CoroutineScope() 函数中。
val job = Job()
// CoroutineScope() 函数会返回一个 CoroutineScope 对象,便可以调用它的 launch 函数来创建一个协程了。
val scope = CoroutineScope(job)
// 这样创建的协程都会被关联在 Job 对象的作用域下面,只需要调用一次 cancel(),
// 就可以将同一作用域内的所有协程全部取消,从而大大降低了协程管理的成本。
scope.launch {
// 处理具体的逻辑
}
job.cancel()
}
async 函数
当调用 launch 函数时可以创建一个新的协程,但 launch 函数只能用于执行一段逻辑,却不能获取执行的结果,因为它的返回值永远是一个 Job 对象。这时,可使用 async 函数实现。
async 函数必须在协程作用域当中调用,它会创建一个新的子协程并返回一个 Deferred 对象,并通过此对象的 await() 即可。
fun main(){
runBlocking {
val result = async {
5 + 5
}.await()
println(result)
}
}
在调用 async 函数后,代码块中的代码会立刻开始执行。当调用 await() 时,如果代码块中的代码还没执行完,那么 await() 会将当前协程阻塞住,直到可以获得 async 函数的执行结果。
fun main(){
runBlocking {
val start = System.currentTimeMillis()
val result = async {
delay(1000)
5 + 5
}.await()
val result2 = async {
delay(1000)
4 + 6
}.await()
println("result id ${result + result2}.")
val end = System.currentTimeMillis()
println("cost ${end - start} ms.")
// 运行结果:
// result id 20.
// cost 2021 ms. // 耗时 2 秒,说明确实是串行。
}
}
将上面代码改为并行关系,从而提升运行效率:
fun main(){
runBlocking {
val start = System.currentTimeMillis()
val result = async {
delay(1000)
5 + 5
}
val result2 = async {
delay(1000)
4 + 6
}
// 仅在需要用到 async 函数的执行结果时才调用 await() 获取。
println("result id ${result.await() + result2.await()}.")
val end = System.currentTimeMillis()
println("cost ${end - start} ms.")
// 运行结果:
// result id 20.
// cost 1021 ms. // 耗时 1 秒,说明确实是并行。
}
}
withContext 函数
一个比较特殊的作用域构造器,它是一个挂起函数,大体可将它理解成 async 函数的一种简化版写法。
fun main(){
runBlocking {
// val result1 = async { 5 + 5 }.await()
// 等价于上面代码,唯一不同的是,强制需要指定一个线程参数。
// ------------------------------------------------------------
// 调用 withContext 函数后会立即执行代码块中的代码,同时将当前协程阻塞住。
// 当执行结束后,会将最后一行的执行结果作为返回值。
val result = withContext(Dispatchers.Default){
5 + 5
}
println(result)
}
}
线程参数的意义在于:虽然不需要像传统编程那样开启多线程来执行并发任务,只需要在一个线程下开启多个协程即可。但是,这并不意味着就不再需要开启线程了,比如 Android 中执行耗时任务需要在子线程当中,那我们在主线程中开启的协程就无法胜任了。这时,便可以通过线程参数来为协程指定一个具体的运行线程。
线程参数主要有以下 3 种值可选:
- Dispatchers.Default:使用一种默认低并发的线程策略,当要执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率,此时就可以使用 Dispatchers.Default。
- Dispatchers.IO:使用一种较高并发的线程策略,当要执行的代码大多数时间是在阻塞和等待中,比如执行网络请求时,为了能够支持更高的并发数量,此时可使用 Dispatchers.IO。
- Dispatchers.Main:表示不会开启子线程,并且这个值只能在 Android 项目中使用,会在 Android 主线程中执行代码。
事实上,以上所学的协程作用域构造器中,除了 coroutineScope 函数之外,其它函数都是可以指定这样一个线程参数的,只不过 withContext 函数是强制要求指定的。
简化回调的写法
协程的主要用途是可以大幅度地提升并发编程的运行效率。但实际上,Kotlin 中的协程还可以对传统回调的写法进行优化,从而让代码变得更加简洁。
简化 HttpURLConnection 网络请求的回调:
/**
* 回调机制基本上是依靠匿名类来实现的,但这种写法通常比较繁琐,
* 比如下面的代码,有多少个地方发起请求,就需要编写多少次这样的匿名类实现。
*/
HttpUtil.sendHttpRequest(address, object : HttpCallbackListener{
override fun onFinish(response: String) {
// 得到服务器返回的具体内容
showResponse(response)
}
override fun onError(e: Exception) {
// 在这里对异常情况进行处理
}
})
/**
* 在过去,可能确实没什么更加简单的写法了。但在 Kotlin 中,可借助 suspendCoroutine 函数来继续简化。
* suspendCoroutine 函数必须在协程作用域或挂起函数中才能调用,它接收一个 Lambda 表达式参数,主要作用是将当* 前协程立即挂起,然后在一个普通的线程中执行 Lambda 表达式中的代码,Lambda 表达式的参数列表上会传入一个
* Continuation 参数,调用它的 resume() 或 resumeWithException() 可以让协程恢复执行,
* -----------------------------------------------------------------------------------------
* 定义一个挂起函数 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() 时,当前的协程就会被立刻挂起,然后一直等待网络请求成功或失败后,当前协程才能恢复运行。这样即使不使用回调的写法,也能获得异步网络请求的响应数据,而如果请求失败,则会直接进入 catch 语句当中。
*/
suspend fun getBaiduResponse(){
try {
val response = request("https://www.baidu.com")
// 对服务器相应的数据进行处理
}catch (e:Exception){
// 对异常情况进行处理
}
}
简化 Retrofit 网络请求的回调:
// 传统写法
val appService = ServiceCreator.create<AppService>()
appService.getAppData().enqueue(object : Callback<List<AppBean>>{
override fun onResponse(call: Call<List<AppBean>>, response: Response<List<AppBean>>) {
// 得到服务器返回的数据
}
override fun onFailure(call: Call<List<AppBean>>, t: Throwable) {
// 对异常情况进行处理
}
})
/**
* 等价于上面的写法
*/
suspend fun getAppData(){
// 对于 try catch,现在每次请求都要进行一次。
// 也可以选择不处理,然后发生异常时会一层层向上抛出,直到被某一层的函数处理了位置,
// 所以也可以在某一统一的入口函数中只进行一次 try catch,从而让代码变得更加精简。
try {
val appList = ServiceCreator.create<AppService>().getAppData().await()
}catch (e:Exception){
// 对异常情况进行处理
}
}
/**
* 定义一个挂起函数 await()
* 由于不同的 Service 接口返回的数据类型也不同,所以使用泛型的方式。
* 给它声明了一个泛型 T,并将函数定义成了 Call<T> 的扩展函数,这样,
* 所有返回值是 Call 类型的 Retrofit 网络请求接口就都可以直接调用此函数了。
*/
suspend fun <T> Call<T>.await(): T{
// 挂起当前协程,并且由于扩展函数的原因,现在拥有了 Call 对象的上下文,可直接调用 enqueue()。
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)
}
})
}
}
注:挂起函数只能在协程作用域或其他挂起函数中调用,使用起来会有局限性。但通过合理的项目架构设计,也可以轻松地将各种协程的代码应用到一个普通的项目当中。
备注
参考资料:
欢迎关注微信公众号:非也缘也