一文掌握Kotlin 协程使用

3,055 阅读13分钟

为什么要写这篇文章

在学习和应用Kotlin协程过程中后有一些感受:

  • 应用型的文章往往只是对照官网案例照猫画虎,几乎所有的文章都是先从 背景优势demo 基本应用案例,简单列举一下 launchasyncDispatchers 使用方式,而缺少实战案例以及具体应用适用场景的分析,导致看了很多协程文章后,仍然不会在项目中使用和解决实际碰到的问题.
  • 原理型的文章通常会通过反编译协程源码来讲解状态机原理CPS 转换协程与线程性能对比 等协程实现的核心概念和原理,从而深陷于复杂的源码之中。
  • 在工作的项目中发现了许多协程 错用滥用 地方.

为什么无法掌握协程

大多数文章会从如下几个方面进行讲解 :

  • 协程的概念和作用:解释什么是协程以及它解决的问题。
  • 状态机原理:阐述协程基于状态机的运行机制。
  • 挂起与恢复:详细说明协程如何挂起和恢复执行。
  • 调度器:介绍不同类型的调度器及其工作原理和适用场景。
  • 协程的构建和启动:包括如何创建和启动协程。
  • 上下文:讲解协程上下文的作用和管理。
  • 与线程的关系:剖析协程与传统线程的联系和区别。
  • 异常处理:说明协程中异常的传播和处理方式。
  • 协程的优势和适用场景举例:通过具体案例展示协程的优点和适合应用的情况。

但是通常情况下我们了解到以上知识点以后,仍然无法熟练应用协程解决项目中实际的问题。我仔细思考了一下,许多开发者不能很好的掌握和使用协程,主要原因如下 :

  • 不清楚或者记不起来协程能够解决或简化项目中所遭遇的问题
  • 缺乏对协程结构化并发设计思想思考
  • 缺乏平滑的学习路线,一开始就直接学习 状态机原理与线程关系和效率对比协程优势等理论知识,收益很小,容易被劝退 。

如何掌握协程 & 协程应用场景

通常需要重点关注如下知识点,就可以熟练掌握协程

  1. 作用域(生命周期): 约束作用时间和范围
  2. 切换与通讯 : 主协程 、子协程 互相切换与通讯
  3. 异常处理 :良好的异常处理机制

协程的应用场景是“以同步方式编写异步代码,解决回调(Callback)嵌套地狱问题”,然而通常我们难以很好地理解、消化和吸收这句话。这句话可以解释为以下两点:

  1. 解决多任务(同步/异步)执行时序问题
  2. 解决 Callback 回调嵌套问题

CoroutineScope 作用域 & Job

CoroutineScope

CoroutineScope 是所有协程开始运行的 "容器", 它的主要作用是控制着协程运行的生命周期,包括协程的创建、启动协程、取消、销毁。CoroutineScope 的取消也表示着在此作用域内开启的协程将会被全部取消. CoroutineScope 内还可以创建 子CoroutineScope , 不同类型的作用域作用域代表着在此作用域内协程最大运行的时间不同。 例如 GlobalScope 表示协程的最大可运行时间为整个APP的运行生命周期,Activity CoroutineScope(lifecycleScope) 表示协程的最大可运行时间为Activity的生命周期, 协程伴随着 CoroutineScope 销毁而取消停止运行. Android 中常用的 CoroutineScope 类型和作用域 如下 :

Scope 作用域.drawio.png

Job

Job 表示在一个 CoroutineScope 内开启的一个协程任务, Job 内可以开启多个子Job , 通常每开启一个协程任务后会返回一个Job对象,可以通过执行 Job.cancel() 方法取消协程运行

viewLifecycleOwner.lifecycleScope.launch {  
val job = launch {  
// xxxx  
}  
  
val async = async {  
// xxxx  
}  
  
async.cancel()  
}

CoroutineScope & Job

CoroutineScope 可以开启多个 Job , Job内可以存在多个 CoroutineScope ,关系如下图

CoroutineScope.drawio.png

以下代码仅是为了表达两者之间的关系,不推荐这样使用, 后面我们会讲到 supervisorScope 的使用场景.

viewLifecycleOwner.lifecycleScope.launch {  
    launch {  
        supervisorScope {  
            launch {  
  
            }  
        }  
    }  
  
    supervisorScope {  
        launch {  
  
        }  
    }  
}

coroutineScope vs supervisorScope (推荐使用)

coroutineScope vs `supervisorScope.drawio.png




viewLifecycleOwner.lifecycleScope.launch {  
    try {  
        coroutineScope {  
            val job1 = launch {  
                delay(2000)  
                // 如果抛出异常 , job1 停止执行 , job2 也会被取消,停止执行  
                throw NullPointerException()  
            }  
  
            val job2 = launch {  
                delay(2000)  
                println("println coroutineScope job 2")
            }  
            // 如果执行 cancel ,job 1 , job 2 均取消  
            // cancel()  
        }  
    } catch (e: Throwable) {  
        // ignore  
    }  
  
  try {  
        supervisorScope {  
            val job1 = launch {  
                delay(2000)  
                // 如果抛出异常 , job1 停止执行 , job2 继续执行  
                throw NullPointerException()  
            }  
  
            val job2 = launch {  
                delay(2000)  
                println("println supervisorScope job 2")
            }  
            // 如果执行 cancel ,job 1 , job 2 均取消   
            // cancel()  
        }  
    } catch (e: Throwable) {  
        // ignore  
    }  

小节

coroutineScope 和 supervisorScope 实际业务开发中使用较少, 通常被使用在一个独立的模块或其他服务请求任务关联后者影响较小时开启一个子作用域, 例如一个独立的服务请求和子系统任务处理 。

  • supervisorScope 在此作用域内开启的子协程发生异常以后, 则该作用域内的其他兄弟协程不会受到影响,将正常执行.
  • coroutineScope在此作用域内开启的子协程发生异常以后, 该作用域内的所有协程都将被取消.
  • coroutineScopesupervisorScope内执行cancel()方法取消协程, 作用域内部的所有Job均会被取消.

SupervisorJob vs Job

SupervisorJob 、 Job 可以在开启一个协程时设置任务类型,默认开启一个协程方式为 launch(){....} 内部实现为Job(coroutineContext[Job]),也可以通过 launch(SupervisorJob(coroutineContext[Job])) { } , async(SupervisorJob(coroutineContext[Job])) { } 方式指定Job类型,它的主要作用异常发生时,对父协程、兄弟协程影响.

SupervisorJob

SupervisorJob下的supervisorJob1 发生异常后, SupervisorJob 下的 子协程supervisorJob2也会被取消, SupervisorJob兄弟协程 job1 将正常执行并输出 "println job1"

lifecycleScope.launch(handler) {  
    val job1 = launch {  
        delay(1000)  
        println("println job1")  
    }  
  
    launch(SupervisorJob(coroutineContext[Job])) {  
        val supervisorJob1 = launch {  
            delay(1000)  
            // 如果抛出异常 , job1 停止执行 , job2 继续执行  
            throw NullPointerException()  
        }  
  
        val supervisorJob2 = launch {  
            delay(2000)  
            println("println SupervisorJob job2")  
        }  
    }  
}


Job

Job下的supervisorJob1 发生异常后, Job 下的 supervisorJob2也会被取消, Job的 兄弟协程 job1 也将被取消.

lifecycleScope.launch(handler) {  

    val job1 = launch {  
        delay(1000)  
        println("println job1")  
    }  

    launch(Job(coroutineContext[Job])) {  
        val supervisorJob1 = launch {  
            delay(1000)  
            // 如果抛出异常 , job1 停止执行 , job2 继续执行  
            throw NullPointerException()  
        }  
  
        val supervisorJob2 = launch {  
            delay(2000)  
            println("println SupervisorJob job2")  
        }  
    }  
}


小节

只有在开启的协程任务在发生异常时不希望影响到父协程和兄弟协程时,可以使用 在 launch() 或者 async() 指定job类型为 SupervisorJob , 通常情况下无需单独设置SupervisorJob

  • SupervisorJobSupervisorJob 内的子Job发生异常时,不会影响兄弟协程和父协程执行。
  • Job : Job内的子Job发生异常时,会取消兄弟协程,异常会继续向上传递,直到向上传递的对应层级协程Job类型为 nullSupervisorJob 为止, 并取消对应层级的协程和子协程。

自定义 CoroutineScope

自定义一个 GlobalCoroutineScope
object MyGlobalScope : CoroutineScope {  
    override val coroutineContext: CoroutineContext  
    get() = EmptyCoroutineContext  
}

fun MyGlobalScope() {  
    MyGlobalScope.launch {  
        // xxxxxx  
    }  
}
自定义一个 ViewCoroutineScope

仅为示例,不推荐在项目中使用

跟view的移除,取消作用域协程的执行

class ViewCoroutineScope(override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main) : CoroutineScope  


class MyView @JvmOverloads constructor(  
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0  
) : View(context, attrs, defStyleAttr), CoroutineScope by ViewCoroutineScope() {  
    
    override fun onDetachedFromWindow() {  
        super.onDetachedFromWindow()  
        this.cancel()  
    }  
  
    fun test() {  
        launch {  
            // 在自定义作用域内开启协程.
        }  
    }  
}

线上错误作用域使用案例

Crash Log

android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@2142206 is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:1724)
... 

如以下场景案例均可能会导致Crash和内存无法及时回收 (因为持有Activity或者View的引用) .

错误案例一 : 谨慎使用 GlobalScope

执行一个点赞操作之后, 关闭Activity页面 , 2s弹出一个点赞成功弹窗

fun testGlobalScopeShowDialog01() {  
    GlobalScope.launch {  
    // 模拟一个点赞/收藏网络请求耗时操作  
        delay(2000)  
        // 弹出一个弹窗
        withContext(Dispatchers.Main) {  
            AlertDialog.Builder(this@MainActivity2).setTitle("收藏成功").show()  
        }  
    }  
}

错误风险CrashActivity/View内存无法及时回收

错误分析: GlobalScope生命周期大于Activity, Activity关闭后GlobalScope内的协程任务仍继续执行,延时任务结束后在一个已经关闭的Activity弹出Dialog

经验总结: 不要在GlobalScope执行一些Activity/View相关的操作 ,GlobalScope适合执行一些不依赖Activity/View相关的后台任务 ,例如 文件读写、日志上报 等等.

错误案例二 : 谨慎使用 view.postDelayed

在一个页面内,执行某些操作,2s之后,弹出一个弹窗或者对View执行一些操作

fun testGlobalScopeShowDialog02() {  
    testGlobalScopeShowDialog.postDelayed({
        // btnTestGlobalScopeShowDialog.text = "Hello World !"
        AlertDialog.Builder(this@MainActivity2).setTitle("收藏成功").show()  
    }, 2000)  
}

`错误风险` : `Crash` 、`Activity/View内存无法及时回收`

错误分析: view.postDelayed() 方法会将应用主Handler发送一个延时Message , 如果Activity页面关闭未及时移除延时任务,任务仍会被主Handler延时派发执行, 导致Crash或者Activity内存无法及时回收.

经验总结: 页面关闭时及时移除或者取消postDelayed任务

错误案例三: Fragment viewLifecycleOwner.lifecycleScope vs lifecycleScope

// 推荐  
viewLifecycleOwner.lifecycleScope.launch {  
    flow.collect {  
        view.setBackgroundColor(Color.RED)  
    }  
}  
// 不推荐
lifecycleScope.launch {  
    flow.collect {  
        view.setBackgroundColor(Color.RED)  
    }  
}
// 推荐
liveData.observe(viewLifecycleOwner) {  
     // xxx
}  
// 不推荐
liveData.observe(this) {  
    // xxx
}

  • viewLifecycleOwner.lifecycleScope 绑定fragmentonCreateView()到 onDestroyView()这个范围的生命周期

  • lifecycleScope 绑定 fragment 的整个生命周期onCreate()到onDestroy()这个范围的生命周期,生命周期范围会更长

错误风险: Crash

错误分析: lifecycleScope,当使用viewModel获得数据后通过flow发送数据,此时如果onDestroyView()被调用已经销毁掉了view,但 onDestroy()未被调用,lifecycleScope内则会将继续观察 flow view 为空导致空指针异常 。但是如果改成使用 viewLifecycleOwner.lifecycleScope view 销毁生命周期和viewLifecycleOwner.lifecycleScope 协程取消生命周期一致,则可以避免该问题

经验总结: 使用 viewLifecycleOwner 代替 this@FragmentLiveData进行观察 , 使用 viewLifecycleOwner.lifecycleScope 代替 lifecycleScope 进行协程相关操作

错误案例四

Activity lifecycleScope/ Fragment中使用ViewModel viewModelScope开启协程

错误案例

class Fragment {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 
        testViewModelScope()
    }

    fun testViewModelScope() {  
        viewModel.viewModelScope.launch {  
            // 模拟网络请求  
            delay(2000)  
            // 更新页面view
            view.setBackgroundColor(Color.RED)  
        }  
    }

}

错误风险CrashActivity/View内存无法及时回收

错误分析: 发生屏幕旋转时 Activity 会被销毁, ViewModel viewModelScope 开启的协程内如果持有对Activity相关的引用,则会导致Activity无法被回收

经验总结: Activity/Fragment 内使用自己lifecycleScope协程作用域, viewModelScope 尽量在 ViewModel内部使用 或 不要持有Activity/View相关引用.

实战 - 解决回调嵌套地狱

suspendCoroutine vs suspendCancellableCoroutine(推荐使用)

协程的一个重要的应用场景在于 Callback 嵌套地狱问题. 官方主要提供两个 suspendCoroutinesuspendCancellableCoroutine 这两个API函数来实现回调转换为同步调用的挂起函数方式。

suspendCancellableCoroutine : suspendCancellableCoroutine 的挂起函数内在协程取消后将会抛出一个 CancellationException , 协程默认不处理这个异常,如果对其进行 try catch, 将会捕获到 CancellationException异常 , it.resume(logoUrl) 执行后,将无法唤起恢复挂起函数继续运行 .

suspendCoroutine : suspendCoroutine 不会检查父协程状态 ,协程取消后, suspendCoroutine 函数内 it.resume(logoUrl) 执行后会唤起恢复挂起函数继续运行.

suspendCancellableCoroutinesuspendCoroutine 更加安全

案例一

RxJava Observable 转化为 suspend 函数

suspend fun <T> Observable<T>.toSuspend(): T {  
   return suspendCancellableCoroutine {  
       val disposable = subscribe({ value ->  
           it.resume(value)  
       }, { throwable ->  
           it.resumeWithException(throwable)  
       })  
       it.invokeOnCancellation {  
           disposable.dispose()  
       }  
   }  
}

fun load() {
   viewmodelScope.launch {
       val observable = ...   
       // Observable to suspend
       val result = observable.toSuspend()
   }
   
}

it.invokeOnCancellation {...} 函数非常关键 ,它的相关特性如下:

  1. 协程被取消回调 ,这里适合做一些资源回收、任务取消等相关工作. 例如 取消网络 等等
  2. it.resumeit.resumeWithException 执行完毕后, 即使协程被取消,该回调将不会再执行

案例二

需求如下 :

  1. 点击滑动 RecyclerView 列表
  2. 滑动结束后弹出Toast提示

常规方式

fun testOnRecyclerViewScroll() {  
    view.setOnClickListener {  
        recyclerView.smoothScrollToPosition(10)  
        recyclerView.addOnScrollListener(object : OnScrollListener() {  
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {  
                super.onScrollStateChanged(recyclerView, newState)  
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {  
                    Toast.makeText(context, "scroll end " , Toast.LENGTH_LONG).show()  
                }  
            }  
          })  
    }
}

协程方式

// RecyclerView 扩展挂起函数
suspend fun RecyclerView.onScrollIdle() {  
    suspendCancellableCoroutine<Unit> {  
        this.addOnScrollListener(object : OnScrollListener() {  
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {  
                super.onScrollStateChanged(recyclerView, newState)  
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {  
                    removeOnScrollListener(this)  
                    it.resume(Unit)  
                }  
            }  
       })  
    }  
}  
  
fun testOnRecyclerViewScroll() {  
    view.setOnClickListener {  
        lifecycleScope.launch {  
            recyclerView.smoothScrollToPosition(10) 
            recyclerView.onScrollIdle()  
            Toast.makeText(context, "scroll end " , Toast.LENGTH_LONG).show()  
        }  
    }  
}
  1. 列表滑动
  2. onScrollIdle 函数挂起
  3. 滑动结束 ,onScrollIdle函数恢复
  4. 执行Toast

案例三

例如实现一个简单的登录需求如下:

  1. 调用登录接口 ,返回用户 token 、头像地址 等
  2. 调用VIP服务接口,查询用户是否是VIP (假设VIP服务和登录不在一个服务)
  3. 下载用户头像到本地

Mock 函数接口如下

data class User(val token: String, val logoUrl: String)  
  
interface LoginCallback {  
    fun onSuccess(user: User)  
  
    fun onFail(throws: Throwable)  
}  
  
interface VIPCallback {  
    fun onSuccess(isVip: Boolean)  
  
    fun onFail(throws: Throwable)  
}  
  
interface LoadImgCallback {  
    fun onSuccess(logoUrl: String)  
  
    fun onFail(throws: Throwable)  
}  
  
fun doLoginCallback(loginCallback: LoginCallback) {  
    // onSuccess / onFail  
}  
  
fun doVipCallback(vipCallback: VIPCallback) {  
    // onSuccess / onFail  
}  
  
fun doLoadImageCallback(loadImgCallback: LoadImgCallback) {  
    // onSuccess / onFail  
}

常规方式

fun login() {  
    doLoginCallback(object : LoginCallback {  
        override fun onSuccess(user: User) {  
        // 魔鬼嵌套  
            doVipCallback(object : VIPCallback {  
                override fun onSuccess(isVip: Boolean) {  
                    // 魔鬼嵌套 ...  
                }  
  
                override fun onFail(throws: Throwable) {  
  
                }  
            })  
        }  
  
        override fun onFail(throws: Throwable) {  
  
        }    
    })  
}

转换为 suspend 函数

suspend fun suspendDoLoginCallback(): User {  
    return suspendCancellableCoroutine {  
        doLoginCallback(object : LoginCallback {  
            override fun onSuccess(user: User) {  
                it.resume(user)  
            }  
  
            override fun onFail(throws: Throwable) {  
                it.resumeWithException(throws)  
            }  
  
        })  
    }  
}  
  
suspend fun suspendDoVipCallback(): Boolean {  
    return suspendCancellableCoroutine {  
        doVipCallback(object : VIPCallback {  
            override fun onSuccess(isVip: Boolean) {  
                it.resume(isVip)  
            }  
  
            override fun onFail(throws: Throwable) {  
                it.resumeWithException(throws)  
            }  
        })  
    }  
}  
  
  
suspend fun suspendDoLoadImageCallback(): String {  
    return suspendCancellableCoroutine {  
        doLoadImageCallback(object : LoadImgCallback {  
            override fun onSuccess(logoUrl: String) {  
                it.resume(logoUrl)  
            }  
  
            override fun onFail(throws: Throwable) {  
                it.resumeWithException(throws)  
            }  
        })  
    }  
}

协程方式实现

fun login() {  
    lifecycleScope.launch {  
            try {  
                val user = suspendDoLoginCallback()  
                val isVip = suspendDoVipCallback()  
                val logoUrl = suspendDoLoadImageCallback()  
            } catch (throwable: Throwable) {  
                // 登录失败
            }  
    }
}

案例四

如果业务发生变更为:

  1. 调用登录接口
  2. 调用VIP服务接口 与 下载用户头像到本地 同时请求 , 无论成功/失败都不影响登录结果
fun login() {  
    lifecycleScope.launch {  
        val user = suspendDoLoginCallback()  
        val isVipDeferred = async {  
            suspendDoVipCallback()  
        }  
        val logoUrlDeferred = async {  
            suspendDoLoadImageCallback()  
        }  
        try {  
            val isVip = isVipDeferred.await()  
            val logoUrl = logoUrlDeferred.await()  
        } catch (throwable: Throwable) {  
            throwable.printStackTrace()  
            // 忽略失败  
        }  
    }  
}

异常处理

简单概括为为一句话 : 所有 suspend 类型的函数都可以被正常的 try catch 处理, 更多可以查看 Kotlin协程异常处理

结构化并发

协程的结构化并发设计的核心思想就是在处理 整体与局部局部与局部 之间的关系。 从 CoroutineScopeJob 中可以窥探一二,CoroutineScope父CoroutineScope 、 兄弟 CoroutineScope 、 子CoroutineScope 的影响和处理, Job父Job 、兄弟Job 、子Job的影响和处理, 其实都是在处理 整体局部的关系, 如何能够很好的理解CoroutineScopeJob 作用域、生命周期、异常分发和处理,将对掌握协程有很大帮助。

总结

  • 所有 suspend类型的函数都可以被 try catch 正常捕捉处理
  • suspendCoroutine & suspendCancellableCoroutineCallback回调函数转换协程suspend函数, 是处理回调嵌套地狱的关键, 推荐使用 suspendCancellableCoroutine ,因为它更加安全
  • supervisorScope 内的子协程发生异常时互不影响 ,coroutineScope 会取消作用域内所有的子协程。 supervisorScope 、coroutineScopesuspend 函数,可以使用 try catch 捕捉处理异常
  • 如果希望一个任务发生异常时不影响到其他兄弟协程和父协程 ,可以在 launchasync时设置为 SupervisorJob