10.协程(一)
1.引
在Android开发中,我们经常遇到的一个异步任务场景是:在后台执行一个复杂任务,下一个任务依赖于上一个任务的执行结果,所以必须等上一个任务完成后才能开始执行,具体的例子比如,当我们上传资源到服务器的时候,我们首先获取一个服务器的token,然后再通过这个token作为上传资源的校验,上次成功后再通知主线程更新ui
private fun requestToken() : String{
...
}
private fun requestPost(String token):String{
...
}
private fun updateUI(String post){
...
}
以上三个函数中,前两个函数是耗时函数,不能运行在主线程,而第三个函数是更新ui操作,需要运行在主线程中,后两个函数都需要依赖上一个函数的返回结果,三个任务不能并行运行,那么该如何解决这个问题呢?
1.1 回调
对于这样的问题,我们常见的做法是,先执行第一个任务,执行完之后用回调通知,然后执行第二个任务,以此类推,最后通过handler通知主线程更新ui
//开启一个线程
thread {
//执行第一个函数
requestTokenAsync{ token->
requestPostAsync{ post->//执行第二个函数
handler.post{
updateUI(post);//执行第三个函数
}
}
}
}
目前的大多数网络请求框架的做法都是使用这个样的回调方法,但随着任务数的增多,嵌套数会越来越多,使得程序变得非常难看,而且不方便处理异常。
1.2 RxJava
这种方法我们也可以使用RxJava的链式调用,这也是目前大多数人的选择
Single.fromCallable { requestToken()) }//执行第一个函数
.map { token -> requestPost(token) }//执行第二个函数
.subscribeOn(Schedulers.io())//线程切换
.observeOn(AndroidSchedulers.mainThread())//切换到主线程
.subscribe({ post ->
updateUI(post)//执行第三个函数
}, { e ->
e.printStackTrace()
})
RxJava是目前非常流行的异步处理框架,有丰富的操作符,简单的线程调度,异常处理等等,可以说满足大多数人的需求,是一个非常优秀而且强大的开源库,那么有没有更加简便的方法呢。
1.3 协程
用使用协程的代码
private suspend fun requestToken() : String{ ... } //挂起函数
private suspend fun requestPost(String token):String { ... } //挂起函数
private fun updateUI(String post) { ...}
GlobalScope.launch(Dispatchers.Main) {
val token = requestToken();
val post = requestPost(token);
updateUI(post)
}
可以看到,使用协程实现的代码非常简洁,以顺序的方式书写异步代码,不会柱塞当前的UI线程,错误处理也和平常的代码一样简单。
2. 协程
2.1 协程引入
dependencies{
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.5'
}
2.2 协程的定义
官方中文文档:kotlin 中文文档
什么是协程呢,我们先看官方的说法
协程通过将复杂性放入库来简化异步编程。程序的逻辑可以在协程中顺序地表达,而底层库会为我们解决其异步性。该库可以将用户代码的相关部分包装为回调、订阅相关事件、在不同线程(甚至不同机器)上调度执行,而代码则保持如同顺序执行一样简单。
协程的开发人员 Roman Elizarov 是这样描述协程的:协程就像非常轻量级的线程。线程是由系统调度的,线程切换或线程阻塞的开销都比较大。而协程依赖于线程,但是协程挂起时不需要阻塞线程,几乎是无代价的,协程是由开发者控制的。所以协程也像用户态的线程,非常轻量级,一个线程中可以创建任意个协程。
漫画版概念解释:漫画:什么是协程?
说实话,但看文字有点难以理解
而通常协程会跟线程(thread)进行比较,我们通过一张图开更加直观地跟线程对比
看到这里,依然觉得云里雾里,不知所云,没有说到协程的本质,于是又去翻了翻官方文档。
2.3协程的本质
import kotlinx.coroutines.*
fun main() = runBlocking {
repeat(100_000){ //启动10万个协程
launch {
delay(1000L)
print(".")
}
}
}
这个是官方例子,来说明协程优势的地方,如果我们用java的线程来表示
repeat(100_000){
thread{
Thread.sleep(1000L)
print(""."")
}
}
这都不需要运行,我们都知道会发生什么。
其实,这样对比有点不厚道,一个封装后的产物,跟原始线程比,本来就没什么可以比性,
如果要比的话,也是要跟java的Executor比:
repeat(100_000) {
val executor = Executors.newSingleThreadScheduledExecutor()
val task = Runnable {
print(".")
}
repeat(100_00) {
executor.schedule(task, 1, TimeUnit.SECONDS)
}
}
用上面那段代码跑了下,跟上面协程的例子,并没有发现实质上的性能提升。所以到目前为止,我们也下一个结论
使用Kotlin协程,在性能上并没有比我们原先的开发模式在性能上有多大的提升,因为我们多使用的各种线程切换库比如okhttp,AsyncTask等内部都实现了线程池,而不是直接使用Thread。
但是上面并没有直接证实kotlin协程是一个基于java Thread封装的一个工具包,下面我们就来通过代码验证一下
fun main(){
//在没有开启协程前,先打印一下进程名称和进程id
println(
"Main: " +
"threadName = " + Thread.currentThread().name
+ " threadId = " + Thread.currentThread().id
)
//循环20次
repeat(20) {
GlobalScope.launch {
//开启协程后,先打印一下进程名称和进程id
println(
"IO: " +
"threadName = " + Thread.currentThread().name
+ " threadId = " + Thread.currentThread().id
)
delay(1000L)
}
}
}
打印
看看打印发现了什么?开启的携程运行在不同的线程上,而且有一些线程的名字一模一样,是不是觉得跟java的线程池很像。
所以到这里,我们不难得出,kotlin协程,不是真正意义上的协程(跟其他语言比如Go的协程就是真正意义上的协程),没有太神秘的地方,本质就是基于java Thread的封装,跟线程池的性质是一样的。
但是既然跟线程池是一样的,我们为什么要学习线程呢,线程的好处在哪里,下面我们就来使用看看。
3 协程的使用
3.1协程的创建
kotlin里没有new,自然也不像java李一样new Thread,而是通过一些专供函数来创建,比如kotlin里的协程就使用GlobleScope类创建,GlobleScope提供的几个构造函数:
- launch -创建协程
- async -创建带返回值的协程,返回的是Deferred类
- withContext -不创建新的协程,而是指定协程上运行的代码块,指定线程
- runBlocking -不是GlobalScope的api,可以独立使用,区别是runBlocking里面的delay会阻塞线程,而launch创建的不会
kotlin在1.3之后要求协程必须由CoroutineScope创建,CoroutineScope不阻塞当前线程,在后台创建一个新协程,也可以指定协程调度器。
创建同一个协程
GlobalScope.launch(Dispatchers.Default) {
println("协程开始")
val token = requestToken();
val post = requestPost(token);
updateUI(post)
println("协程结束")
}
3.2 挂起函数(supend)
协程里可以执行普通的函数,也可以执行挂起函数
private suspend fun requestToken(): String {
return withContext(Dispatchers.IO) {
Thread.sleep(500);
return@withContext "token"
}
}
private suspend fun requestPost(): String {
return withContext(Dispatchers.IO) {
Thread.sleep(500);
return@withContext "post"
}
}
可以看到上面两个函数都是被suspend修饰,并且里面有调用withContext指定了线程调度器,像这样的被suspend修饰的函数,我们通常叫它为挂起函数。挂起函数处理被suspend修饰,跟普通的函数没有其他区别
让协程执行一个挂起函数的时候,协程就会被挂起,等到挂起函数执行完之后才能接着下一步执行,在这个过程中,不会阻塞线程。要启动一个协程,至少有一个挂起函数,suspend修饰符通常可以标记函数、扩展函数和lambda表达式。
//协程
GlobalScope.launch(Dispatchers.Default) {
println("协程开始")
val token = requestToken(); //挂起函数
val post = requestPost(token); //挂起函数
updateUI(post)
//处理异常
println("协程结束")
}
3.3 参数解析
launch构造函数的接收了3个参数
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
-
CoroutineContext 协程上下文,是一些元素的集合,主要包括了Job和CoroutineDispatcher元素,可以代表一个协程的场景
EmptyCoroutineContext表示一个空的协程上下文
CoroutineDispatcher,协程调度器,决定协程所在的线程或线程池,通常用来指定协程运行的线程。kotlin提供了几种标准实现- Dispatchers.Default 默认
- Dispatchers.Main 主线程
- Dispatchers.IO io线程
- Dispatchers.Unconfined 不执行线程
-
CoroutineStart 协程的启动模式
模式 功能 DEFAULT 立即执行协程体 ATOMIC 立即执行协程体,但在开始运行之前不可取消 UNDISPATCHED 立即在当前线程执行协程体,直到第一个suspend执行 LAZY 只有在需要的时候执行,相当于懒加载 -
CoroutineScope.() -> Unit 最后一个参数,就是执行体了,相当于Thread.run,可以看到的是同样是使用suspend修饰的, 说明执行体本身就是一个挂起函数。
启动模式CoroutineStart,我们通常用的最多的是DEFAULT ,LAZY
//default没什么好说的,默认模式就是这个
//lazy的用法
val job:Job=GlobalScope.launch(context = Dispatchers.Default,start=CoroutineStart.LAZY) {
println("协程开始时间:${System.currentTimeMillis()}")
}
println("主线程:${System.currentTimeMillis()}")
Thread.sleep(1000)
job.start()
//输出
主线程:1589253373955
协程开始时间:1589253375019
可以看到协程执行时间比在主线程晚了1s,这就是懒加载的作用
3.4 withContext
withContext{} 不会创建新的协程,在指定协程上运行挂起代码块,并挂起该协程直至代码块运行完成
简单点来说就是,给协程指定一个线程或者线程池,让协程在线程上面执行,知道执行结束。
3.5 async
CoroutineScope.async{}可以实现与launch 一样的效果,在后台创建一个新协程,唯一的区别是它是有返回值的,返回值类型是Deferred。
scope.launch(Dispatchers.Main) {
//async 相当于创建了一个异步任务,但是在这里还没开始执行
//需要调用await()方法才会执行这个任务
val one=async { api.listRepos1("lgh001") } //耗时任务
val two=async { api.listRepos1("lgh001") } //耗时任务
//await是一个suspend挂起函数,所以不需要使用withContext
val same=one.await()[0]==two.await()[0]
println(same)
}
上面代码 one.await()[0]==two.await()[0],直接比较,其实是因为await是一个挂起函数,协程执行挂起函数是顺序执行的,先执行one.await得到返回之后,再执行two.await,看起来像是普通的函数执行,比较。以同步的写法写异步的执行,说实话有点爽。
3.6 协程的释放
跟线程一样,如果当页面关闭的时候,协程还在执行耗时任务而不释放,就会导致内存泄漏的问题,解决方法也很简单,在关闭页面的时候释放即可
GlobalScope.launch(Dispatchers.Main) {
...
}
//使用GlobalScope创建的协程,最后调用释放即可
GlobalScope.cancel()
//当然,在开启多个协程的时候,可以用这种方式,相当于一个集合,把所有的协程都装进来,最后统一全部释放
val scope= MainScope()//创建一个scope
scope.launch(Dispatchers.Main) {
...
}
scope.cancel()//最后释放,
当然也可以使用lifecycle的方式释放,
首先需要引用
dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
}
把需要的库引用进来之后,会提供一个扩展函数 lifecycleScope,直接用这个scope来开启协程,就不需要我们手动去释放协程了,原理就是监听lifecycle,统一释放
lifecycleScope.launch(Dispatchers.Main) {
...
}
4.协程的优势
在我们日常开发中,有一个场景是,在同一个页面中,需要请求两个api,而这两个api没有强关联,请求完成后需要进行合并,如果我们使用经典的回调方式:
//创建两个请求
val observable1 = Observable.just("1")
val observable2 = Observable.just("2")
//使用zip操作符合并两个请求
Observable.zip<String, String, String>(observable1, observable2,
io.reactivex.functions.BiFunction { t1, t2 ->
t1 + t2
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<String> {
override fun onComplete() {}
override fun onSubscribe(d: Disposable) {}
override fun onNext(t: String) {
println(t)
}
override fun onError(e: Throwable) {}
})
上面代码即使使用了rxjava,依然会觉得非常麻烦,如果是更加复杂的需求,可能会需要使用更加复杂的操作符,或者多个操作符相互操作才能达到效果
但是,如果使用kotlin协程
GlobalScope.launch {
//使用async发起两个异步请求
val res1=async { reqeust1() }
val res2=async { reqeust1() }
//得到结果之后合并
val res3=res1.await()+res2.await()
println(res3)
}
看到这里,我们再来看看协程这个名字,英文名叫Coroutine,中文全称叫"协同程序",是不是对协程有了全新的理解
5.总结
1.协程就是对java Thread的封装,可以帮我们写出更加复杂的并发代码
2.协程依赖于线程而存在,协程必须运行在线程上,一个线程可以有多个协程,协程也可以运行在不同的线程中
3.kotlin协程可以极大地简化异步编程,以顺序执行的书写方式,写异步执行的代码。