Hello,各位朋友,小笨鸟我回来了!
近期学习了Kotlin协程相关的知识,感觉这块技术在项目中的可应用性很大,对项目的开发效率和维护成本有较大的提升。于是就考虑深入研究下相关概念和使用方式,并引入到具体项目实践中。
本文参考了官方资料和网上相关博主的文章,加上自己的一些认知,编写而成,当然我本人也是初识协程,疏漏及不准确之处希望不吝指出,非常感谢。
协程介绍
协程与线程
大家都听说过线程,也都知道协程是类似于线程的东西,可能都会认为协程是新出来的东西,但是其实协程出来的更早,只是没有发展成为主流而已。
协程最早诞生于1958年,被应用于汇编语言中(距今已有60多年了),对它的完整定义发表于1963年:协程是一种通过代码执行的恢复与暂停来实现协作式的多任务的程序组件。 而与此相比,线程的出现则要晚一些,伴随着操作系统的出现,线程大概在1967年被提出。线程作为由操作系统调度最小执行组件,主要用于实现抢占式的多任务。
那么,看起来协程和线程都是处理多任务的工作,那么协作式和抢占式多任务处理有啥区别呢?
- 协作式多任务:协作式多任务要求每一个运行中的任务,在合适时机自动放弃自己的运行权利,告知操作系统可让下一个任务运行。这就要求任务之间相互熟悉,才能实现协作。不然一个任务出问题就会影响全局。
- 抢占式多任务:抢占式环境下,操作系统完全决定进程调度方案,操作系统可以剥夺耗时长的进程的时间片,提供给其它进程。线程就是这种设计理念下的产物,这种设计理念下,系统更稳定更高效。
上个世纪七八九十年代,是计算机疯狂朝着小型化和个人化的方向演进的时代,计算机非常依赖操作系统来提供用户交互和压榨CPU的最大性能,而操作系统怎么来压榨计算机性能的呢?靠多线程。操作系统跟随个人计算机的普及之后,编程语言自然也开始依赖操作系统提供的接口来驾驭计算机了,线程成了几乎所有编程语言跳不过的一个重要概念,并一直延续至今。
协程是基于编程语言层面的一种概念,它并没有统一定义的接口,因此在不同的语言中实现后的效果是不同的,这也会对开发者造成极大的困扰,不利于它的推广。不像线程都是基于操作系统的统一接口,使用体验基本一致。因此,协程在时代的潮流中就被线程慢慢拉下了。
所以总结一下,协程是基于协作式多任务处理设计理念提出的程序组件,需要任务相互熟悉,这与当时的计算机行业发展是不相符的。并且接口不明确,使用成本高,所以没流行起来。
为什么要引入协程
协程提出的很早,但是直到最近几年才在某些语言上(如Lua,python,kotlin)等上大量应用。既然上面我们已经说到了抢占式多任务处理才更稳定更高效,那么为什么协程的概念又被拉出来关注了呢? 主要还是因为线程有着它的使用痛点:线程之间交互难度比较大。而协程在多任务协作上就如鱼得水了,所以越来越受重视了。
我们下面还是以kotlin协程coroutines来介绍。首先我们还是来看段代码,这也是我们一开始想写成的dream code:
val token = fetchToken() // 网络请求
val user = fetchUserData(token) // 网络请求
textView.text = user.name
当然,老司机们都知道网络请求要是在主线程去请求,就会报NetworkOnMainThreadException。那么为了解决这个问题,我们往往会写成这样,也就是回调式写法
fetchToken { token ->
fetchUserData(token) { user ->
textView.text = user.name
}
}
但是回调式写法一向被人诟病,缺点有且不仅有:
- 容易造成“回调地狱”
- 容易造成内存泄露
- 链路复杂,阅读和维护成本高
- 相互独立的调用中组合返回结果非常困难
当然,大家会说,这个问题好解决了,人生苦短,我用Rxjava。当然,Rxjava也是一种很好很强大的解决方式,前提是你能理解他的设计理念和那么多的operator,学习成本还是太高了。 而kotlin协程就能很好的解决这些问题,让你写出真正的dream code。
coroutineScope.launch(Dispatchers.Main) {
val token = fetchToken() // 网络请求
val user = fetchUserData(token) // 网络请求
textView.text = user.name
}
suspend fetchToken(): String = withContext(Dispatchers.IO) {
// 网络请求....
}
suspend fetchUserData(): String = withContext(Dispatchers.IO) {
// 网络请求....
}
kotlin协程
上面讲到了协程跟线程是不一样的设计理念,对于协作式的任务的配合非常高效。但是kotlin协程到底是个啥?除了写代码更爽还有什么优势? 官网上说:「本质上,协程是轻量级的线程」。也给出了推荐我们使用的几个理由:
1.协程很轻量。我们可以在单个线程上运行多个协程,因为协程支持暂停,不会使正在运行协程的线程阻塞。官网用了这样一个例子来说明协程比线程轻量。
2.内存泄露更少:协程会指定作用域,可以在一个作用域内执行针对所有该作用域中的协程的操作。比如取消,全作用域错误处理等。这种操作也叫“结构化并发”。
3.内置取消支持:取消功能会自动通过正在运行的协程层次结构传播
4.jetpack集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供用于结构化并发。
工程实践
引入
实现协程的库是kotlin.coroutines
,源码可以在 github 上查看。
由于kotlin是一门支持多平台的语言,所以coroutines也是支持多平台的,包括:
- Kotlin/JS
- Kotlin/Native 包括PC和Android
我们需要使用coroutines的android版本。要使用协程,Kotlin 的版本必须在1.3以上。
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:xx.xx.xx" // 协程核心库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:xx.xx.xx" // 协程当前平台对应的平台库
就这样,我们很简单就能开始使用协程了。
使用
我们先看一个简单而又完整的使用协程的例子
fun updateName() {
Log.d(TAG, "a") // 主线程中运行
coroutineScope.launch(Dispatchers.Main) { // 主线程中运行
Log.d(TAG, "b") // 主线程中运行
val token = withContext(Dispatchers.IO) {
// 网络请求.... // IO线程中运行
}
val user = fetchUserData() // IO线程执行完后又切到了主线程
textView.text = user.name // 主线程
Log.d(TAG, "c")
}
Log.d(TAG, "d")
}
suspend fun fetchUserData(): String = withContext(Dispatchers.IO) {
// 网络请求....
}
在这个例子中,coroutineScope.launch就创建了一个协程,并在协程中进行了两次切换到IO线程中进行网络请求的操作,最终把结果展示了出来。同时我们也会发现,由于协程是不阻塞线程的,所以创建完协程后的操作会继续执行,而单个协程中的执行顺序是固定的从上到下。并且打印出来的log会是 a d b c
协程创建
让我们从最常用的创建协程的函数launch来看看协程创建:
1.launch
launch会创建一个不会阻塞当前线程、没有返回结果的Coroutine, 但会返回一个Job对象,可以用于控制这个Coroutine的执行和取消。
val scope = CoroutineScope(Dispatchers.Main)
var job = scope.launch(Dispatchers.Main, CoroutineStart.DEFAULT) {
var content = fetchData()
Log.d("Coroutine",content)
}
job.cancel()
job.start()
协程创建需要四样东西:
- 协程作用域
CoroutineScope
:通过launch或者async启动一个协程需要指定CoroutineScope,当要取消协程的时候只需要调用CoroutineScope.cancel()
,kotlin 会帮我们自动取消在这个作用域里面启动的所有协程。不建议用GlobalScope(应用全局生命周期),建议配合后面的viewModelScope使用。 - 上下文
CoroutineContext
:上下文是协程的配置参数,可以指定协程的名称,协程运行所在线程,异常处理等等。常用的有- Dispatchers.Default:线程池,适合CPU密集型
- Dispatchers.Main:UI线程
- Dispatchers.IO:线程池,适合IO密集型
- 启动模式
CoroutineStart
:协程体的执行方式,常用的有- CoroutineStart.DEFAULT:立即执行协程体
- CoroutineStart.LAZY:只在有需要的情况下执行
- 协程体:是一段可以放在协程上去运行的代码,就好比Thread.run当中的代码
2.async
async也是很常用的创建协程的方式,和launch相似也不堵塞当前线程,会返回一个Deffered,常用于需要启动异步线程处理并等待处理结果返回的场景。Deffered.await方法只能在协程中调用。
coroutineScope.launch(Dispatchers.Main) {
// async 函数启动新的协程
val avatar: Deferred = async { api.getAvatar(user) } // 获取用户头像
val logo: Deferred = async { api.getCompanyLogo(user) } // 获取公司logo
// 获取返回值
show(avatar.await(), logo.await()) // 更新 UI
}
3.runBlocking
创建新的协程运行在当前线程上,所以会堵塞当前线程,直到协程体结束。主要是用于启动一个协程任务。不建议多用。
协程挂起
如何理解协程的挂起?我们可以理解为挂起就是一个稍后会被自动切回来的线程调度操作。 让我们来看看这个例子
coroutineScope.launch(Dispatchers.Main) {
val token = fetchToken() // 网络请求
val user = fetchUserData(token) // 网络请求
textView.text = user.name
}
suspend fun fetchToken(): String = withContext(Dispatchers.IO) {
// 网络请求....
}
我们先介绍两个挂起必需品:
- suspend:用来标记方法是一个挂起的方法,挂起方法只能在协程、或者另外的挂起函数中调用。本身不执行任何挂起操作。
- withContext:真正执行挂起操作。把闭包中的代码切换到指定的线程中执行,并在闭包中的代码执行完后,自动把线程切回来。
这段代码的运行过程图为:
看到这里,我们可能就会了解: 1.协程挂起的是withContext的闭包的代码 2.协程从当前线程挂起的意思是,这个协程从正在执行它的线程上脱离 3.协程挂起以后,两边后续会怎么样?
- 原来的线程继续运行自己的工作,如果是主线程,则进行界面刷新等后续工作
- 挂起的协程会在新的线程里运行完闭包中的代码,然后自动切回到挂起前的代码
所以,协程的挂起本质上就是线程的切换。协程就是kotlin官方提供给我们的一套线程操作的框架。有点类似于线程池。所以我们上面看到的官方拿100000个线程跟100000个协程作比较的例子,还是有点耍赖哦。
与retrofit联动
Retrofit 2.6.0版本内置了对Kotlin Coroutines的支持,可以直接进行使用。在那之下的版本需要新增一个adapter。详情参考 Retrofit 2.6.0 ! 更快捷的协程体验 !
升级到2.6.0版本以后,可以这样直接使用
@GET("xxx")
suspend fun getBaidu(): Response
然后就可以直接使用
suspend fun getBaidu(): Response {
return retrofit.getBaidu()
}
与Lifecycle联动
Lifecycle KTX 为每个 Lifecycle 对象定义一个 LifecycleScope。在此Scope内启动的协程会在 Lifecycle 被销毁时取消。可以通过 lifecycle.coroutineScope 或 lifecycleOwner.lifecycleScope 属性访问 Lifecycle 的 CoroutineScope。
引入
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0"
使用
lifecycleOwner.lifecycleScope.launch {
val name = fetchName()
textView.text = name
}
与jetpack联动
Android官方提供了丰富的支持coroutines的jetpack扩展库。
1.LiveData
LiveData KTX 可提供一个 liveData 构建器函数,该函数可以调用 suspend 函数,并将结果作为 LiveData 对象传送出来。
引入
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
使用
val user: LiveData<User> = liveData {
val data = database.loadUser() // loadUser is a suspend function.
emit(data)
}
2.ViewModel
ViewModel KTX 库提供了一个 viewModelScope() 函数,可以更轻松地从 ViewModel 启动协程。这个CoroutineScope会绑定至 Dispatchers.Main,并且会在清除 ViewModel 后自动取消。
引入
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
使用
class MainViewModel : ViewModel() {
// Make a network request without blocking the UI thread
private fun makeNetworkRequest() {
// launch a coroutine in viewModelScope
viewModelScope.launch {
remoteApi.slowFetch()
...
}
}
// No need to override onCleared()
}
3.Room
Room 扩展程序增加了对数据库事务的协程支持
引入(Room 升级到2.1 版本以上)
implementation "androidx.room:room-ktx:2.2.5"
使用
@Query("SELECT * FROM Users")
suspend fun getUsers(): List<User>
异常处理
上面我们说到了网络请求和数据库操作等,只要是请求就会有失败的概率,在协程里面有两种处理方式。
1.单协程处理
GlobalScope.launch(Dispatchers.Main) {
try {
userNameView.text = getUserCoroutine().name
} catch (e: Exception) {
userNameView.text = "Get User Error: $e"
}
}
2.作用域统一处理
viewModelScope.launch(CoroutineExceptionHandler { coroutineContext, throwable ->
// exception错误处理
}) {
// 协程代码
}
默认的coroutineScope中,只要其中的任何一个子协程或者父协程出了异常,域中的所有协程都会收到异常。如果希望子协程互不影响,可以使用supervisorScope。
关于异常更详细的信息可以看异常处理
小问题
1.引入coroutine和相关扩展,包体积会增大多少?
200K+
2.感觉没学过瘾,还能在哪学?
更详细的内容可以参考Kotlin官网和Android官网
3.是否需要项目支持androidx?
协程库不依赖androidx,但是几个jetpack的扩展需要androidx
4.协程看起来很高效,可以替代线程吗?
协程的高效在于它的切换不需要进入内核态,都是在用户态进行处理的,所以比基于线程的调度要快和高效。如果是多次进行线程切换的场景,用协程会比较合适。但是毕竟协程也只是基于线程封装的框架,所以线程还是不能缺少的。
参考
juejin.cn/post/684490… kotlinlang.org/docs/refere… www.bennyhuo.com/2019/04/08/… kaixue.io/kotlin-coro… www.youtube.com/watch?v=BOH… developer.android.com/kotlin/ktx?… developer.android.com/kotlin/coro… juejin.cn/post/684490…
我是Android笨鸟之旅,笨鸟也要有向上飞的心,我在这里陪你一起慢慢变强。期待你的关注