美文网首页
一文读懂kotlin协程常用知识点

一文读懂kotlin协程常用知识点

作者: 蜗牛是不是牛 | 来源:发表于2022-04-09 20:36 被阅读0次

    先不讲概念,先上代码,看一下协程怎么用的。

    retrofit 请求代码

    interface HttpInterface {
        @GET("/photos/random")
        suspend fun getImageRandom(@Query("count") count: Number): ArrayList<ImageBean>
    }
    
    

    activity 中调用代码

    override fun onCreate(savedInstanceState: Bundle?) {
        lifecycleScope.launch {
            val imageArray: ArrayList<ImageBean> = httpInterface.getImageRandom(10)//发送请求
            textView.text = "图片数量为" + imageArray.size//更新UI
        }
    }
    
    

    可以看到发送请求和更新 UI 在一个代码块中,看起来像是都运行在主线程中,但是竟然没有任何报错。 这就是协程最有魅力的地方非阻塞式挂起,后边会详细介绍。

    创建协程

    创建协程有三种方式:launch、async、runBlocking

    launch

    launch 方法签名如下:

    public fun CoroutineScope.launch(
        context: CoroutineContext = EmptyCoroutineContext,
        start: CoroutineStart = CoroutineStart.DEFAULT,
        block: suspend CoroutineScope.() -> Unit
    ): Job {
        //省略
        return coroutine
    }
    
    

    launch 是 CoroutineScope 的扩展方法,需要 3 个参数。第一个参数,看字面意思是协程上下文,后边会重点讲到。第二个参数是协程启动模式,默认情况下,协程是创建后立即执行的。第三个参数,官方文档说这个 block 就是协程代码块,所以是必传的。返回的是一个 Job,这个 Job 可以理解为一个后台工作,在 block 代码块执行完成后会结束,也可以通过 Job 的 cancel 方法取消它。

    async

    async 方法签名如下:

    public fun <T> CoroutineScope.async(
        context: CoroutineContext = EmptyCoroutineContext,
        start: CoroutineStart = CoroutineStart.DEFAULT,
        block: suspend CoroutineScope.() -> T
    ): Deferred<T> {
        //省略
        return coroutine
    }
    
    

    同样也是 CoroutineScope 的扩展方法,参数跟 launch 是一模一样的,只是返回参数变成了 Deferred,这个 Deferred 继承于 Job,相当于一个带返回结果的 Job,返回结果可以通过调用它的 await 方法获取。

    runBlocking

    runBlocking 会阻塞调用他的线程,直到代码块执行完毕。

    Log.i("zx", "当前线程1-" + Thread.currentThread().name)
    runBlocking(Dispatchers.IO) {
        delay(2000)
        Log.i("zx", "休眠2000毫秒后,当前线程" + Thread.currentThread().name)
    }
    Log.i("zx", "当前线程2-" + Thread.currentThread().name)
    
    

    输出内容

    当前线程1-main
    休眠2000毫秒后,当前线程DefaultDispatcher-worker-1
    当前线程2-main
    
    

    可以看到,即使协程指定了运行在 IO 线程,依旧会阻塞主线程。runBlocking 主要用来写测试代码,平常不要随意用,所以不再过多介绍。

    CoroutineScope 协程作用域

    launch 和 async 都是 CoroutineScope 的扩展函数,CoroutineScope 又是什么呢,字面意思翻译过来是协程作用域,协程作用域类似于变量作用域,定义了协程代码的作用范围。作用域取消时,作用域中的协程都会被取消。 比如如下代码:

    MainScope().launch {
        var i = 0
    
        launch(Dispatchers.IO) {
            while (true) {
                Log.i("zx", "子协程正在运行着$i")
                delay(1000)
            }
        }
    
        while (true) {
            i++
            Log.i("zx", "父协程正在运行着$i")
    
            if (i>4) {
                cancel()
            }
            delay(1000)
        }
    }
    
    

    输出:

    父协程正在运行着1
    子协程正在运行着1
    父协程正在运行着2
    子协程正在运行着2
    父协程正在运行着3
    子协程正在运行着3
    父协程正在运行着4
    子协程正在运行着4
    子协程正在运行着4
    父协程正在运行着5
    
    

    5 秒后,父协程调用 cancel()结束了,子协程也就结束了,并没有继续打印出值。

    可以通过 CoroutineScope()来创建协程作用域,这并不是一个构造函数,CoroutineScope 是一个接口,所以没有构造函数,只是函数名与接口名同名而已,源码如下:

    @Suppress("FunctionName")
    public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
        ContextScope(if (context[Job] != null) context else context + Job())
    
    

    源码可见,创建 CoroutineScope 时需要传入 CoroutineContext,这个 CoroutineContext 也是 CoroutineScope 接口中唯一的成员变量。CoroutineScope.kt 这个文件中使用 CoroutineScope()创建了两个 Scope,一个是 MainScope,一个是 GlobalScope。源码如下:

    public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
    
    public object GlobalScope : CoroutineScope {
        override val coroutineContext: CoroutineContext
            get() = EmptyCoroutineContext
    }
    
    

    MainScope 是一个方法,返回了一个运行在主线程的作用域,需要手动取消。GlobalScope 是一个全局作用域,整个应用程序生命周期他都在运行,不能提前取消,所以一般不会使用这个作用域。Android 中,ktx 库提供了一些常用的作用域供我们使用,如 lifecycleScope 和 viewModelScope。在 LifecycleOwner 的所有实现类中,如 Activity 和 Fragment 中都可以直接使用 lifecycleScope,lifecycleScope 会跟随 Activity 或 Fragment 的生命周期,在 Activity 或 Fragment 销毁时,自动取消协程作用域中的所有协程,不用手动管理,不存在内存泄露风险。类似的 viewModelScope 也会随着 viewModel 的销毁而取消。

    目前已经有好几个地方出现了 CoroutineContext:启动协程时 launch 或者 async 方法需要 CoroutineContext,创建协程作用域时需要 CoroutineContext,协程作用域中有且只有一个成员变量也是 CoroutineContext,如下源码所示:

    public interface CoroutineScope {
        public val coroutineContext: CoroutineContext
    }
    
    

    如此看来,CoroutineContext 必定很重要。

    CoroutineContext 协程上下文

    CoroutineContext 保存了协程的上下文,是一些元素的集合(实际并不是用集合 Set 去存储),集合中每一个元素都有一个唯一的 key。通俗来讲,CoroutineContext 保存了协程所依赖的各种设置,比如调度器、名称、异常处理器等等。

    CoroutineContext 源码如下:

    public interface CoroutineContext {
    
        public operator fun <E : Element> get(key: Key<E>): E?
    
        public fun <R> fold(initial: R, operation: (R, Element) -> R): R
    
        public operator fun plus(context: CoroutineContext): CoroutineContext =
            if (context === EmptyCoroutineContext) this else
                context.fold(this) { acc, element ->
                    //省略
                }
    
        public fun minusKey(key: Key<*>): CoroutineContext
    
        public interface Key<E : Element>
    
        public interface Element : CoroutineContext {
            //省略
        }
    }
    
    

    CoroutineContext 里有一个接口 Element,这个 Element 就是组成 CoroutineContext 的元素,最重要的是 plus 操作符函数,这个函数可以把几个 Element 合并成为一个 CoroutineContext,由于是操作符函数,所以可以直接用+调用。比如:

    var ctx = Dispatchers.IO + Job()  + CoroutineName("测试名称")
    Log.i("zx", ctx.toString())
    
    

    输出

    [JobImpl{Active}@31226a0, CoroutineName(测试名称), Dispatchers.IO]
    
    

    共有哪几种元素呢?来看看 Element 的子类吧。Element 有这么几个子类(子接口):Job、CoroutineDispatcher、CoroutineName、CoroutineExceptionHandler。

    Job

    Job 可以简单理解为一个协程的引用,创建协程后会返回 Job 实例,可以通过 Job 来管理协程的生命周期。Job 是 CoroutineContext 元素的一种,可以传入 CoroutineScope 用来使协程有不同的特性。主要关注Job()SupervisorJob()这两个创建 Job 的函数以及Deferred这个 Job 的子接口。

    Job()

    创建一个处于活动状态的 Job 对象,可以传入父 Job,这样当父 Job 取消时就可以取消该 Job 以及他的子项。 该 Job 的任何子项失败都会立即导致该 Job 失败,并取消其其余子项。这个很好理解,例如:

    CoroutineScope(Dispatchers.IO + Job()+MyExceptionHandler()).launch {
        var index = 0
        launch {
            while (true) {
                index++
                if (index > 3) {
                    throw Exception("子协程1异常了")
                }
                Log.i("zx", "子协程1正在运行")
            }
        }
    
        launch {
            while (true) {
                Log.i("zx", "子协程2正在运行")
            }
        }
    }
    
    

    子协程 1 异常了,就会导致整个 Job 失败,子协程 2 也不会继续运行。

    SupervisorJob()

    创建一个处于活动状态的 Job 对象。 该 Job 的子项之间彼此独立,互不影响,子项的失败或取消不会导致主 Job 失败,也不会影响其他子项。

    CoroutineScope(Dispatchers.IO + SupervisorJob() + MyExceptionHandler()).launch {
        launch {
            while (true) {
                index++
                if (index > 3) {
                    throw Exception("子协程1异常了")
                }
                Log.i("zx", "子协程1正在运行")
            }
        }
    
        launch {
            while (true) {
                Log.i("zx", "子协程2正在运行")
            }
        }
    }
    
    

    同样的代码,把 Job()换成 SupervisorJob()后,可以发现子协程 2 会一直运行,并不会因为子协程 1 异常而被取消。

    我们常见的 MainScope、viewModelScope、lifecycleScope 都是用 SupervisorJob()创建的,所以这些作用域中的子协程异常不会导致根协程退出。 kotlin 提供了一个快捷函数创建一个使用 SupervisorJob 的协程,那就是 supervisorScope。例如:

    CoroutineScope(Dispatchers.IO).launch {
        supervisorScope {
            //这里的子协程代码异常不会导致父协程退出。
        }
    }
    
    

    等同于

    CoroutineScope(Dispatchers.IO).launch {
        launch(SupervisorJob()) {
    
        }
    }
    
    

    Deferred

    是 Job 的子接口,是一个带有返回结果的 Job。async 函数创建的协程会返回一个 Deferred,可以通过 Deferred 的await()获取实际的返回值。async 与 await 类似于其他语言(例如 JavaScript)中的 async 与 await,通常用来使两个协程并行执行。 例如如下代码

    suspend fun testAsync1(): String = withContext(Dispatchers.Default)
    {
        delay(2000)
        "123"
    }
    
    suspend fun testAsync2(): String = withContext(Dispatchers.Default)
    {
        delay(2000)
        "456"
    }
    
    lifecycleScope.launch {
        val time1 = Date()
        val result1 = testAsync1()
        val result2 = testAsync2()
        Log.i("zx", "结果为${result1 + result2}")
        Log.i("zx", "耗时${Date().time - time1.time}")
    }
    
    

    会输出:

    结果为123456
    耗时5034
    
    

    如果改为使用 async,让两个协程并行。代码如下:

    lifecycleScope.launch {
        val time1 = Date()
        val result1 = async { testAsync1() }
        val result2 = async { testAsync2() }
        Log.i("zx", "结果为${result1.await() + result2.await()}")
        Log.i("zx", "耗时${Date().time - time1.time}")
    }
    
    

    输出

    结果为123456
    耗时3023
    
    

    总耗时为两个并行协程中耗时较长的那个时间。

    CoroutineDispatcher 调度器

    指定了协程运行的线程或线程池,共有 4 种。

    • Dispatchers.Main 运行在主线程,Android 平台就是 UI 线程,是单线程的。
    • Dispatchers.Default 默认的调度器,如果上下文中未指定调度器,那么就是 Default。适合用来执行消耗 CPU 资源的计算密集型任务。它由 JVM 上的共享线程池支持。 默认情况下,此调度器使用的最大并行线程数等于 CPU 内核数,但至少为两个。
    • Dispatchers.IO IO 调度器,使用按需创建的线程共享池,适合用来执行 IO 密集型阻塞操作,比如 http 请求。此调度器默认并行线程数为内核数和 64 这两个值中的较大者。
    • Dispatchers.Unconfined 不限于任何特定线程的协程调度器,不常用。

    需要注意的是 Default 和 IO 都是运行在线程池中,两个子协程有可能是在一个线程中,有可能不是一个线程中。例如如下代码:

    CoroutineScope(Dispatchers.IO).launch {
        launch {
            delay(3000)
            Log.i("zx", "当前线程1-" + Thread.currentThread().name)
        }
    
        launch {
            Log.i("zx", "当前线程2-" + Thread.currentThread().name)
        }
    }
    
    

    输出

    当前线程2-DefaultDispatcher-worker-2
    当前线程1-DefaultDispatcher-worker-5
    
    

    所以,如果涉及线程的 ThreadLocal 数据时,记得做处理。

    如果一不小心用错了 Dispatchers.Default 去发 IO 请求会有什么后果呢?猜测结果:由于 Default 调度器并行线程数远小于 IO 调度器,IO 请求的一个特性就是等待时间很长,而实际的处理时间很短,所以会造成大量请求处于等待分配线程的状态中,造成效率低下。实际情况可以写个程序测试一下,这里就不试了。

    CoroutineName 协程名称

    传入一个 String 作为协程名称,一般用于调试时日志输出,以区分不同的调度器。

    CoroutineExceptionHandler 异常处理器

    用于处理协程作用域内所有未捕获的异常。实现 CoroutineExceptionHandler 接口就好了,代码如下:

    class MyExceptionHandler : CoroutineExceptionHandler {
        override val key: CoroutineContext.Key<*>
            get() = CoroutineExceptionHandler
    
        override fun handleException(context: CoroutineContext, exception: Throwable) {
            Log.i("zx", "${context[CoroutineName]}中发生异常,${exception.message}")
        }
    }
    
    

    然后用+拼接并设置给作用域。

    CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
        launch(CoroutineName("子协程1") + MyExceptionHandler()) {
            throw Exception("完蛋了,异常了")
        }
    }
    
    

    输出内容为

    CoroutineName(父协程)中发生异常,完蛋了,异常了
    
    

    不对呀,明明是子协程 1 抛出的异常,为什么输出的是父协程抛出的异常呢?原来,异常规则就是子协程会将异常一级一级向上抛,直到根协程。那什么是根协程呢?跟协程简单来讲就是最外层协程,还有一个特殊的规则就是,使用 SupervisorJob 创建的协程也视为根协程。比如如下代码:

    CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
        launch(CoroutineName("子协程1") + MyExceptionHandler() + SupervisorJob()) {
            throw Exception("完蛋了,异常了")
        }
    }
    
    

    输出内容为

    CoroutineName(子协程1)中发生异常,完蛋了,异常了
    
    

    说起处理异常,大家肯定想到 try / catch,为什么有了 try / catch,协程里还要有一个 CoroutineExceptionHandler 呢?或者说 CoroutineExceptionHandler 到底起什么作用,什么时候用 CoroutineExceptionHandler 什么时候用 try / catch 呢?官方文档是这么描述 CoroutineExceptionHandler 的用于处理未捕获的异常,是用于全局“全部捕获”行为的最后一种机制。 你无法从CoroutineExceptionHandler的异常中恢复。 当调用处理程序时,协程已经完成。,这段文字描述的很清楚了,这是全局(这个全局是指根协程作用域全局)的异常捕获,是最后的一道防线,此时协程已经结束,你只能处理异常,而不能做其他的操作。举个例子吧

    CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
        val test = 5 / 0
        Log.i("zx", "即使异常了,我也想继续执行协程代码,比如:我要通知用户,让用户刷新界面")
    }
    
    

    协程体中第一行 5/0 会抛出异常,会在 CoroutineExceptionHandler 中进行处理,但是协程就会直接结束,后续的代码不会再执行,如果想继续执行协程,比如弹出 Toast 通知用户,这里就做不到了。换成 try / catch 肯定就没有问题了。

    CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
        try {
            val test = 5 / 0
        } catch (e: Exception) {
            Log.i("zx", "我异常了")
        }
        Log.i("zx", "继续执行协程的其他代码")
    }
    
    

    那既然如此,我直接把协程中所有代码都放在 try / catch 里,不用 CoroutineExceptionHandler 不就行了?听起来好像没毛病,那我们就试试吧

    inline fun AppCompatActivity.myLaunch(
        crossinline block: suspend CoroutineScope.() -> Unit
    ) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                block()
            } catch (e: Exception) {
                Log.e("zx", "异常了," + e.message)
            }
        }
    }
    
    

    做了一个封装,只要是调用封装的 myLaunch 函数,那所有的协程代码都被 try / catch 包着,这肯定没问题了吧。比如我这样调用

    myLaunch {
        val test = 5 / 0
    }
    
    

    程序没崩,很好。换个代码继续调用

    myLaunch {
        launch {
            val test = 5 / 0
        }
    }
    
    

    APP 崩了,不对呀,这里最外层明明已经包了一层 try / catch,怎么捕获不到呢?想一下之前协程抛异常的规则:子协程会将异常一级一级向上抛,直到根协程。这里用 launch 又新创建了一个子协程,异常代码运行在子协程中,子协程直接把异常抛给了父协程,所以 try / catch 捕获不到。这里父协程又没有指定异常处理器,所以就崩了。有人可能要抬杠了,那我直接在子协程里 try / catch 不就不会崩了?确实不会崩了,这里你记住了加try / catch,那别的地方会不会忘了加呢。所以 CoroutineExceptionHandler 全作用域捕获异常的优势就出来了。所以简单总结一下二者的区别和使用场景吧。

    • CoroutineExceptionHandler 以协程为作用域全局捕获未处理异常,可以捕获子协程的异常,捕获到异常时,协程就已经结束了。适用于做最后的异常处理以保证不崩溃,比如用来记录日志等。
    • try / catch 可以更加精细的捕获异常,精确到一行代码或者一个操作,无法捕获子协程的异常,不会提前结束协程。适用于捕获可以预知的异常。

    以下是个人的心得,不一定正确,仅供参考。

    CoroutineExceptionHandler 适用于捕获无法预知的异常。try / catch 适用于可以预知的异常。 什么是可以预知的异常和不可预知的异常呢?举个例子:你要往磁盘写文件,可能会没有权限,也可能磁盘写满了,这些异常都是可以预知的,此时应该用 try / catch。不可预知的异常就是指,代码看起来没毛病,但我不知道哪里会不会出错,不知道 try / catch 该往哪里加,try / catch 有没有少加,这个时候就该交给 CoroutineExceptionHandler,毕竟 CoroutineExceptionHandler 是最后一道防线。

    CoroutineContext 总结

    CoroutineContext 由 Job、CoroutineDispatcher、CoroutineName、CoroutineExceptionHandler 组成。Job 可以控制协程的生命周期,也决定了子项异常时,父Job会不会取消。CoroutineDispatcher决定了协程运行在哪个线程。CoroutineName给协程起名字,用于调试时区分。CoroutineExceptionHandler 用于全作用域捕获并处理异常。子协程会自动继承父协程的CoroutineContext,并可以覆盖。CoroutineContext元素之间可以通过 + 运算符组合,也可以通过对应的key检索出CoroutineContext中的元素。

    CoroutineStart 启动模式

    上边讲了 launch 和 async 的第二个参数就是 CoroutineStart,也就是协程的启动模式,共分为如下 4 种:

    • DEFAULT-默认模式,立即调度协程;

    • LAZY-仅在需要时才懒惰地启动协程,使用start()启动;

    • ATOMIC-原子地(以不可取消的方式)调度协程,执行到挂起点之后可以被取消;

    • UNDISPATCHED-同样是原子地(以不可取消的方式)执行协程到第一个挂起点。与ATOMIC的区别是:UNDISPATCHED不需要调度,直接执行的,而ATOMIC是需要调度后再执行的;UNDISPATCHED是在父协程指定的线程中执行,到达挂起点之后会切到自己上下文中指定的线程,ATOMIC是在自己的协程上下文中指定的线程执行。

      需要注意的是调度(schedules)和执行(executes)是不一样的,调度之后并不一定是立即执行。

    分别举例说明。

    LAZY 模式:

    val job = lifecycleScope.launch(start = CoroutineStart.LAZY) {
        Log.i("zx", "协程运行了1")
    }
    
    

    上边的代码,并不会打印出内容,需要手动调用job.start(),才能启动协程并打印出内容。

    ATOMIC 模式:

    val job = lifecycleScope.launch(start = CoroutineStart.ATOMIC) {
        Log.i("zx", "协程运行了1")
        delay(2000)
        Log.i("zx", "协程运行了2")
    }
    job.cancel()
    
    

    由于使用的 ATOMIC 启动模式,执行到挂起点之前(delay 是挂起函数)是不能被取消的,所以无论如何都会打印出 "协程运行了 1"。执行到挂起点之后可以被取消,所以不会打印出第二行。

    UNDISPATCHED 模式:

    lifecycleScope.launch {
        Log.i("zx", "父协程,当前线程" + Thread.currentThread().name)
    
        val job = launch(Dispatchers.IO, CoroutineStart.UNDISPATCHED) {
            Log.i("zx", "子协程,当前线程" + Thread.currentThread().name)
            delay(1000)
            Log.i("zx", "子协程delay后,当前线程" + Thread.currentThread().name)
        }
    }
    
    

    上述代码输出

    父协程,当前线程main
    
    子协程,当前线程main
    
    子协程delay后,当前线程DefaultDispatcher-worker-1
    
    

    结果验证了,在到达第一个挂起点之前,都是使用父协程所在线程去执行协程,到达挂起点之后才会使用自己 coroutineContext 中设置的线程。类似于 ATOMIC ,在到达第一个挂起点之前同样是不可取消的。

    suspend 与 withContext

    前边反复提到挂起点,那什么是挂起点呢?什么又是挂起呢?挂起点实际上就是协程代码执行到 suspend 函数时的点,此时协程会暂停,suspend 函数之后的代码不会再执行,等到 suspend 函数执行完之后,协程代码会自动继续执行。上边用到的 delay 函数就是一个挂起函数,他会暂停(suspend)当前协程代码块,先执行 delay 函数,等 delay 执行完后继续执行原有的代码。先暂停,等代码执行完了在再自动恢复(resume)执行这个特性非常适合处理异步任务。例如如下代码:

    private suspend fun getBitmapByHttp(): Bitmap {
        Log.i("zx", "当前线程" + Thread.currentThread().name)
        val url = URL("https://www.baidu.com/img/flexible/logo/pc/result.png");
        val imageConnection = url.openConnection() as HttpURLConnection
        imageConnection.requestMethod = "GET"
        imageConnection.connect()
        val inputStream: InputStream = imageConnection.inputStream
        return BitmapFactory.decodeStream(inputStream)
    }
    
    lifecycleScope.launch {
        val bitmap = getBitmapByHttp()//第一个行
        viewBinding.imageView.setImageBitmap(bitmap)//第二行
    }
    
    

    先定义了一个 suspend 函数,这个函数从网络加载图片获取到 bitmap。然后启动一个 lifecycleScope 的协程,在里边调用这个 suspend 函数。应该如我们所想,第一行是个 suspend 函数,是个挂起点,会等到 getBitmapByHttp 执行完再继续执行第二行 setImageBitmap。然而运行起来之后,先是输出 "当前线程 main" 然后应用崩了,抛出了 NetworkOnMainThreadException 异常,为什么这里的 suspend 函数会运行在主线程呢?因为 suspend 并不知道具体要切到哪个线程,所以依旧运行在主线程。并且上述代码,Android Studio 会提示 Redundant 'suspend' modifier(多于的 suspend 修饰符)。如何让 suspend 函数切换到具体的线程呢?这就要用到 withContext 了。

    public suspend fun <T> withContext(
        context: CoroutineContext,
        block: suspend CoroutineScope.() -> T
    ): T
    
    

    这是 withContext 的签名,可以看到 withContext 必须要传入协程上下文以及一个协程代码块。协程上下文中包含了 Dispatchers,它指定了 withContext 将要切到哪个线程中去执行。withContext 也是一个 suspend 挂起函数,所以 withContext 执行时,调用它的协程会先暂停,等到它切到指定的线程并执行完之后,会自动再切回到调用它的协程,并继续执行协程代码。这其实就是挂起,自动切走,执行完了再自动切回来继续之前的操作。同样是之前的代码,加上 withContext 之后就没问题了。

    private suspend fun getBitmapByHttp(): Bitmap = withContext(Dispatchers.IO) {
        Log.i("zx", "当前线程" + Thread.currentThread().name)
        val url = URL("https://www.baidu.com/img/flexible/logo/pc/result.png");
        val imageConnection = url.openConnection() as HttpURLConnection
        imageConnection.requestMethod = "GET"
        imageConnection.connect()
        val inputStream: InputStream = imageConnection.inputStream
        BitmapFactory.decodeStream(inputStream)
    }
    
    lifecycleScope.launch {
        val bitmap = getBitmapByHttp()
        viewBinding.imageView.setImageBitmap(bitmap)
    }
    
    

    既然 withContext 可以切走再切回来,那调用时不要最外层的 lifecycleScope.launch {},不启动协程可以吗。试了一下发现 AS 提示错误,编译都过不了,提示"Suspend function 'getBitmapByHttp' should be called only from a coroutine or another suspend function",意思是挂起函数只能在另一个挂起函数或者协程里调用,那另一个挂起函数也只能在另另一个挂起函数或者协程里调用,如此套娃,最终就是挂起函数只能在一个协程里调用,这么限制是因为暂停、切走、切回去并恢复执行这些操作是由协程框架完成的,如果不在协程里运行,这些是没法实现的。

    如果某个函数比较耗时,我们就可以将其定义为挂起函数,用 withContext 切换到非 UI 线程去执行,这样就不会阻塞 UI 线程。上边的例子也展示了自定义一个挂起函数的过程,那就是给函数加上 suspend 关键字,然后用 withContext 等系统自带挂起函数将函数内容包起来。

    试想一下,如果不用 suspend 和 withContext,那我们就需要自己写开启 Thread,并自己用 Handler 去实现线程间通信。有了协程之后,这些都不需要我们考虑了,一下简单了很多,更重要的是,这样不会破坏代码的逻辑结构,两行代码之间就像普通阻塞式代码一样,但是却实现了异步非阻塞式的效果,这也就是非阻塞式的含义

    小总结:

    • 挂起 就是一个切走再自动切回来继续执行的线程调度操作,这个操作由协程提供,所以限制了suspend方法只能在协程里调用。
    • withContext 就是协程提供的一个挂起函数,起到的就是切到指定线程执行代码块,执行完再切回来的作用。
    • suspend 仅仅只是一个限制,限制了挂起函数只能在协程中调用,并没有实际的切线程
    • 非阻塞式 写法像普通阻塞式代码一样,却实现了非阻塞式的效果,没有回调也没有嵌套,不破坏代码逻辑结构
    • 自定义挂起函数 给函数加上suspend关键字并用withContext等系统自带挂起函数将函数内容包起来

    简单使用

    就像文章一开始那样,就可以简单使用协程+Retrofit 发送异步网络请求了,但是没有异常处理,我们可以简单封装一下加上异常处理以及 loading 显示等。

    全局协程异常处理

    class GlobalCoroutineExceptionHandler(
        val block: (context: CoroutineContext, exception: Throwable) -> Unit
    ) :
        CoroutineExceptionHandler {
        override val key: CoroutineContext.Key<*>
            get() = CoroutineExceptionHandler
    
        override fun handleException(context: CoroutineContext, exception: Throwable) {
            block(context, exception)
        }
    }
    
    

    这里的 handleException 并没有实际处理异常,实际处理异常的方法是外边初始化 CoroutineExceptionHandler 时传进来的block。

    Http 请求 Activity 基类

    open class HttpActivity : AppCompatActivity() {
        val httpInterface: HttpInterface = RetrofitFactory.httpInterface
        private var progress: ProgressDialog? = null
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
        }
    
        inline fun launchMain(
            crossinline block: suspend CoroutineScope.() -> Unit
        ) {
            val job = lifecycleScope.launch(GlobalCoroutineExceptionHandler(::handException)) {
                showProgress()
                block()
            }
    
            job.invokeOnCompletion {
                hideProgress()
            }
        }
    
        fun showProgress() {
            if (progress == null) {
                progress = ProgressDialog.show(
                    this, "", "加载中,请稍后...", false, true
                )
            }
        }
    
        fun hideProgress() {
            if (progress != null && progress!!.isShowing) {
                progress!!.dismiss()
                progress = null
            }
        }
    
        open fun handException(context: CoroutineContext, exception: Throwable) {
            var msg = ""
            if (exception is HttpException) {
                msg = when (exception.code()) {
                    404 -> {
                        "$localClassName-异常了,请求404了,请求的资源不存在"
                    }
    
                    500 -> {
                        "$localClassName-异常了,请求500了,内部服务器错误"
                    }
                    500 -> {
                        "$localClassName-异常了,请求401了,身份认证不通过"
                    }
                    else -> {
                        "$localClassName-http请求异常了,${exception.response()}"
                    }
    
                }
            } else {
                msg = "$localClassName-异常了,${exception.stackTraceToString()}"
    
            }
            Log.e("zx", msg)
    
            hideProgress()
            Snackbar.make(
                window.decorView,
                msg,
                Snackbar.LENGTH_LONG
            )
                .show()
        }
    }
    
    

    定义了一个 launchMain 函数,launchMain 中统一开启协程,统一设置 CoroutineExceptionHandler,并会在请求时显示环形进度条,请求结束后隐藏进度条。

    定义了一个 handException 函数,这个函数是实际处理异常的函数,处理了一些常见的异常,并通过 Snackbar 显示出来。这里用的继承,你也可以用扩展函数去实现。

    使用时让 Activity 继承 HttpActivity,然后就直接使用了,如果想自己处理异常,实现handException函数就可以了。

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        launchMain {
            val result = httpInterface.searchPhotos("china")
            //更新UI
        }
    }
    
    

    当然这只是一种简单的用法,没有结合 ViewModel 和 LiveData,如果你不需要 MVVM,仅仅只需要在 Activity 中发请求,或许可以考虑使用这种方式。

    相关文章

      网友评论

          本文标题:一文读懂kotlin协程常用知识点

          本文链接:https://www.haomeiwen.com/subject/joxmsrtx.html