协程是什么

Android 官方文档中的描述是:协程是一种并发设计模式,可以在 Android 平台上使用它来简化异步执行的代码。或者通俗的讲:协程就是一套轻量级线程框架,使用方便,拥有一系列的语法糖

使用

Android 中如果要使用协程,必须添加如下依赖库:

1
2
3
dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

如何开启一个协程,最简单的方式就是使用 GlobalScope.launch 函数,如下所示:

1
2
3
4
5
fun main() {
    GlobalScope.launch {
        println("hello world-top-level-coroutine")
    }
}

GlobalScope.launch (高阶)函数可以创建出一个协程的作用域,这样在 launch 函数中运行的代码就是在协程中运行的代码了。但是上述代码如果你运行一下就会发现,什么也打印不出来。

这是因为 GlobalScope.launch 每次创建的是一个顶级协程,这种协程有一个特点,就是应用程序结束了,协程也结束了。之所以无法打印出来,是因为代码还没有来得及执行。那么要如何改善呢,只需要延迟一下就可以了,如下:

1
2
3
4
5
6
fun main() {
    GlobalScope.launch {
        println("hello world")
    }
    Thread.sleep(1000)
}

运行程序后,如下所示:

1
hello world

但是这样的代码仍然还是有问题的,那就是可能 launch 函数中的代码还没有执行完,应用程序结束,就被强制给中断了

delay 函数

1
2
3
4
5
6
7
8
9
fun main() {
    GlobalScope.launch {
        println("hello world")
        delay(1500)
        println("code run in coroutine scope finished")
    }

    Thread.sleep(1000)
}

如上所示, 我在 launch 函数中增加了 delay 函数延迟 1.5s 后执行,并在最后一行也打印了一段日志。delay 函数可以指定当前的协程在指定的时间后再运行,但他和 sleep 函数不同,sleep 函数会阻塞当前的线程,而 delay 函数只会挂起当前的协程,并不会阻塞线程,也不会影响到其他协程的运行。需要注意的是 delay 函数只能作用与协程的作用域中或者挂起函数中

运行这段代码会发现,最后一行日志没有被打印,理由也基本是一样的。

runBlocking 函数

之所以上述代码日志没有被打印,是因为协程中的代码还没有来得及执行,就被强制给中断了,主程序就已经执行完了,那么有没有办法可以让它执行完毕呢 ?只需要借助 runBlocking 函数就可以了,代码如下:

1
2
3
4
5
6
7
fun main() {
   runBlocking {
       println("hello world")
       delay(1500)
       println("code run in coroutine scope finished")
   }
}

runBlocking 函数同样会创建一个协程的作用域,但是他可以保证协程作用域内的所有代码和子协程没有执行完之前一直阻塞当前线程。需要注意的是,由于 runBlocking 会阻塞当前的线程,所以一般只在测试环境中使用,在正式环境上容易引发性能问题

运行代码后:

1
2
hello world
code run in coroutine scope finished

协程的并发

虽然说上述的代码已经是在协程中运行了,好像并没有体会到什么特别之处,即使使用单个线程也能做到,这是因为上述目前所有的代码都是在同一个协程中运行的。事实情况并非如此,一旦涉及到高并发的场景,协程相对与线程的优势就会很明显了。

创建多个协程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
fun main() {
   runBlocking {
       launch {
           println("协程1")
           delay(1500)
           println("协程1执行完毕")
       }

       launch {
           println("协程2")
           delay(1500)
           println("协程2执行完毕")
       }
   }
}

这里的 launch 函数和刚才的 GlobalScope.launch 函数不同,首先 launch 函数必须要在协程作用域下才能够使用,其次他会在当前的协程作用域下创建子协程,子协程有一个特点就是如果外层作用域的协程结束了,那么子协程也就结束了。

运行代码后,打印结果如下:

1
2
3
4
协程1
协程2
协程1执行完毕
协程2执行完毕

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

suspend 关键字

随着 launch 函数里面的代码越来越复杂,这个时候可能需要抽取成一个方法了,但是这个时候就会有一个问题了,那就是 launch 函数中是有协程作用域的,抽取成方法后,就没有协程作用域了,是无法调用 delay 这样的挂起函数的,不过 kotlin 为此提供了一个挂起函数关键字,只需要将函数添加 suspend 即可。这样挂起函数就可以相互调用了

1
2
3
4
5
suspend fun println(){ // 声明成挂起函数
 			 println("协程1")
       delay(1500)  // delay 是一个挂起函数
       println("协程1执行完毕")
}

coroutineScope 函数

这里的 suspend 只是将函数声明成了挂起函数而已,而这个函数仍然是没有协程作用域的,如果这个时候,我们想再开启一个协程任务,就无法开启了。因为该函数没有协程作用域,解决方法,就是使用一个 coroutineScope (挂起)函数。这个函数有一个特点就是会继承外部的协程作用域并创建一个子协程,这样就可以给任意挂起函数提供协程作用域了。如下:

1
2
3
4
5
6
7
suspend fun println() = coroutineScope {
    launch {
        println("协程1")
        delay(1500)
        println("协程1执行完毕")
    }
}

需要注意的是 coroutineScope 函数和 runBlocking 函数有点类似,他可以保证其作用域内的所有代码和子协程在全部执行完之前,会一直阻塞当前协程,看如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
fun main() {
    runBlocking {
        coroutineScope {
            launch {
                repeat(10) {
                    println(it.toString())
                    delay(1000)
                }
            }
            // coroutineScope 会保证其作用域内的代码都执行完毕,才会执行下一行,场景:该作用域下,可以执行多个异步方法
        }
        println("coroutineScope is finish")
    }
    println("runBlocking is finish")
}

这里先是使用了 runBlocking 创建了一个协程作用域,然后又使用 coroutineScope 创建了一个子协程作用域,然后又用 launch 函数创建了一个子线程,如果按照上述并发的示例中,会不会是日志交替打印呢,运行代码后如下所示,可以发现数字一行一行的打印,最有才打印 coroutineScope 的日志,所以可以发现 coroutineScope 确实是将当前协程给阻塞了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
1
2
3
4
5
6
7
8
9
10
coroutineScope is finish
runBlocking is finish

看上去 coroutineScope 和 runBlocking 函数作用非常相似,需要注意的是,coroutineScope 会阻塞当前协程,但不影响其他协程,也不影响线程,所以是不会引发线程问题的。但是 runBlocking 是会阻塞当前线程的。如果你恰好在主线程调用的话,可能会导致主线程卡死,实际项目中基本不推荐使用 runBlocking 函数,这里也就是 main 函数测试的时候用了下而已

更多的作用域构建器

在前面中我们使用了 Global.launch runBlocking launch coroutineScope 这几种作用域构建器,他们都可以创建一个协程作用域,区别就是 Global.launch 和 runBlocking 可以在任意地方调用的,coroutineScope 需要再挂起函数中调用,launch 需要在协程作用域中调用。

如何取消协程

由于 runBlocking 会阻塞主线程,所以不建议使用,Global.launch 会创建一个顶级协程,其实也不太推荐使用。为什么不推荐使用呢 ?主要是因为管理起来会有些麻烦,假如说你开启了一个协程正在请求网络,由于请求网络是耗时的,假如这个时候你关掉 Activity 了,这个时候,就不应该回调网络相关的数据了,而是要取消这个协程。

1
2
3
4
5
6
fun main() {
    val job = GlobalScope.launch {
    }

    job.cancel()
}

取消的话,只需要如上述代码所示调用 job.cancel() 就可以了。到了这里,如果我们每次创建都是顶级协程,创建的协程太多,那结束掉 Activity 的话,每个都需要调用一下。就会有点麻烦了。

实际项目中,推荐如下的方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    val job = Job()
    val scope = CoroutineScope(job)
    
    scope.launch {

    }
    
    job.cancel()
}

这里创建了一个 job 对象,然后创建了一个 CoroutineScope 函数,这个函数会返回一个 CoroutineScope 对象,这里将我们创建好的 job 对象传入其中。 这样的话所有使用 CoroutineScope 创建的协程,都会被关联在 job 对象中,如果 Activity 结束,就只需要调用一次 cancel 方法就可以了

协程获取执行的结果

协程给开发者带来极大便利的一个特性就是可以直接获取到执行的结果,上述示例中返回的都是 job 对象,那如果要获取到执行结果要怎么办呢 ?只需要使用 anync 函数就可以。

anync 函数必须要在协程作用域中才能够调用,他会创建一个新的子协程并返回一个 deferred 对象,如果我们想要获取到函数代码块中的执行结果,只需要调用 deferred 对象的 await 方法即可。代码如下:

1
2
3
4
5
6
7
8
fun main() {
    runBlocking { 
        val result = async { 
            9+9
        }.await()
        println(result) // 打印的是 18
    }
}

在调用 async 的时候,代码块中的代码会立即执行,当调用 await 方法的时候,如果代码块中的代码还没有执行完,会将当前的协程阻塞,知道可以获得 async 函数的执行结果

为了证实这一点,看如下代码

串行任务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() {
    runBlocking {
        val start = System.currentTimeMillis()
        val result = async {
            delay(1000)
            9+9

        }.await()

        val result2 = async {
            delay(1000)
            9+10

        }.await()


        println("$result  ----  $result2")
        val end = System.currentTimeMillis()
        println("cost - ${end - start}")
    }
}

这里连续使用了两个 anync 函数来执行任务,并在代码块中调用 delay 方法进行延时,按照刚才的理论,await 方法会在 async 方法执行完之前一直阻塞当前协程,为了验证,这里记录了下方法执行的耗时,运行结果如下:

1
2
18  ----  19
cost - 2019

可以看到整段代码运行耗时是 2019 毫秒,说明两个 async 函数确实是一种串行的关系,前一个执行完,后一个才能执行,这种方式明显是非常低效的,因为两个 async 是可以同时执行的,只需要不在调用 async 函数的时候立刻调用 await 即可。修改后的代码如下:

并行任务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
fun main() {
    runBlocking {
        val start = System.currentTimeMillis()
        val result = async {
            delay(1000)
            9+9
        }

        val result2 = async {
            delay(1000)
            9+10
        }

        println("${result.await()}  ----  ${result2.await()}")
        val end = System.currentTimeMillis()
        println("cost - ${end - start}")
    }
}

打印如下

1
2
18  ----  19
cost - 1015

可以看到运行耗时为 1015 效率提升是非常明显的。

withContext 函数

withContext 也是一个挂起函数,它可以认为是 anync 的一种简化版写法,写法如下:

1
2
3
4
5
6
7
8
fun main() {
    runBlocking {
        val result = withContext(Dispatchers.Default) {
            9+9
        }
        println(result)
    }
}

当调用 withContext 挂起函数后,会阻塞当前的协程,当代码块中的所有代码执行完成后,会将最后一行的执行结果返回回去, 基本上就相当于 val result = async{ 9 +9 }.await() 的写法。唯一不同的是,withContext 需要指定一个线程参数。这个参数的作用就是指定当前的协程作用域在哪一个线程中执行, 如上面的很多代码中,我们都没有指定线程参数,需要注意的是,并不是在子线程中执行的,如果在主线程中创建的,那就是主线程中的协程。

协程中的线程参数

  • Dispatchers.Default 默认低并发的线程策略
  • Dispatchers.IO 高并发线程策略
  • Dispatchers.Main 主线程中执行

suspendCoroutine 协程简化回调写法

这里先看下我们以前写的网络请求方式是怎么写的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main(args: Array<String>) {
        placeService.searchPlaces("midFang").enqueue(object : Callback<PlaceResponse?> {
            override fun onResponse(call: Call<PlaceResponse?>, response: Response<PlaceResponse?>) {
                // update UI
            }

            override fun onFailure(call: Call<PlaceResponse?>, t: Throwable) {
                
            }
        })
}

以前我们这是这样通过这样回调接口的方式,在 onResponse 方法中拿到网络请求的结果再去更新 UI,这么一看好像也没有什么问题,因为以前我们一直也是这么做的,其实如果是单个接口的话,好像这样也可以接受,但是问题来了,假如这个时候,你请求完一个接口,还需要根据上一个接口的返回值请求下一个接口,或者是有更多的接口有这种类似的依赖关系,那么你的代码看起来就会嵌套很多层,不是很可观了。

我们再看看 Kotlin 可以怎么实现,其实用 suspendCoroutine 函数就可以实现。suspendCoroutine 函数必须要在挂起函数或者是协程作用域中调用,它接受一个 Lambda 表达式参数,主要作用就是将当前的协程挂起,然后在一个普通的线程中执行执行 Lambda 表达式中的代码,Lambda 表达式的参数列表会传入一个 Continuation 参数,调用它的 resume 或者 resumeWithException 方法可以让协程恢复运行。

看下如下代码的具体实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
suspend fun main(args: Array<String>) {
     val suspendSearchPlaces = suspendSearchPlaces("midFang")
     // 更新 UI
}

suspend fun suspendSearchPlaces(query: String): String {
        return suspendCoroutine {
            placeService.searchPlaces("midFang").enqueue(object : Callback<PlaceResponse?> {
                override fun onResponse(call: Call<PlaceResponse?>, response: Response<PlaceResponse?>) {
                    it.resume("")
                }

                override fun onFailure(call: Call<PlaceResponse?>, t: Throwable) {
                    it.resumeWithException(t)
                }
            })
        }
}

可以看到上述代码中,我们在 suspendSearchPlaces 方法中使用 suspendCoroutine 函数实现了匿名类接口中将结果直接返回,然后我们就可以直接拿到请求结果去更新 UI 了,是不是方便了很多。

类似的网络请求的代码有非常多,我们可以再看一个拓展,我们每调用一个接口都可以直接拿到请求的结果值,直接去更新 UI,这样就少写了很多回调的代码,假设我现在有这几个接口如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface PlaceService {
    @GET("v2/place/searchPlaces")
    fun searchPlaces(@Query("query") query: String): Call<PlaceResponse>

    @GET("v2/place/getCode")
    fun getCode(@Query("query") query: String): Call<PlaceResponse>
    
    
    @GET("v2/place/location")
    fun location(@Query("query") query: String): Call<PlaceResponse>
}

封装我们的请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private suspend fun <T> Call<T>.await(): T {
        return suspendCoroutine { continuation ->
            enqueue(object : Callback<T> {
                override fun onFailure(call: Call<T>, t: Throwable) {
                    continuation.resumeWithException(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"))
                    }
                }
            })
        }
    }

原理还是和上面是一样的,就不再重复解释了。这样的话,我们请求网络接口就会方便了很多,直接拿到结果更新 UI 就可以了。代码量也少了很多,至于异常是如何处理的,这里先不展开描述,代码如下

1
2
3
4
5
6
7
suspend fun main() {
      val places = placeService.searchPlaces("midFang").await()
      val code = placeService.getCode("midFang").await()
      val location = placeService.location("midFang").await()
      // 拿到请求结果,直接更新 UI
      println("$places   $code   $location ")
}

协程的状态以及控制 API

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
 * 协程状态
 */
isActive: Boolean    // 是否存活
isCancelled: Boolean // 是否取消
isCompleted: Boolean // 是否完成
children: Sequence<Job> // 所有子协程

/**
 * 协程控制
 */
cancel()             // 取消协程
join()               // 等待协程执行完毕
cancelAndJoin()      // 两者结合,取消并等待协程完成
cancelChildren()     // 取消所有子协程,可传参数 CancellationException 作为取消原因
attachChild(child: ChildJob) // 附加一个子协程到当前协程上

总结

  • GlobalScope.launch 是一个顶级协程,可以在任意地方调用,项目中用到的比较少,因为比较难管理,比如取消协程
  • launch 函数会启动一个子协程,必须要在协程作用域下才能够使用,其次他会在当前的协程作用域下创建子协程,子协程有一个特点就是如果外层作用域的协程结束了,那么子协程也就结束了
  • delay 是一个挂起函数,指定当前的协程在指定的时间后再运行,可以在挂起函数中调用或者是协程作用域中调用
  • suspend 修饰的方法为挂起函数
  • runBlocking 可以在任意地方调用,但是用到的比较少,因为会阻塞当前线程,可能会引发性能问题
  • coroutineScope 是一个挂起函数,会继承外部的协程作用域并创建一个子协程,会阻塞当前协程,保证作用域内的所有代码和子协程代码都能够执行完毕,不会阻塞线程
  • withContext 是一个挂起函数,会阻塞当前的协程,可以认为是 anync 的一种简化版写法,需要强制指定线程参数
  • 协程的取消是 job.cancel 方法
  • suspendCoroutine 必须要在挂起函数或者是协程作用域中调用,会将当前的协程挂起,接受一个 Lambda 表达式,通过 resume 或 resumeWithException 可以让协程恢复运行,可以拿到返回值