协程在Android实际开发中到底带来哪些好处(一)

2,671 阅读10分钟

前言

这篇文章并不打算去剖析协程的概念和原理等东西,类似的文章在网上已经有很多了,相信很多文章解释得比我更准确和透彻,如果大家感兴趣的话可以自行查阅学习。

对于协程还仅限于听说的程度的同学,可以查阅一下这些资料:

网上有很多协程+Retrofit或协程+XXX框架进行一系列封装的文章,我也不过多赘述,但如果有同学说,我就想单独用协程呢?仅协程这个单独的个体来说,能给实际开发解决什么痛点?

这一系列文章会假设大家对于协程有基本上的了解但不知道怎么去用(实际上不了解也没关系,看完文章你至少能够知道协程带来的好处),从最基础的粒度上告诉大家,哪些场景下可以使用协程?可以带来哪些好处?

scope

万物始于CoroutinScope,可能有一些老的文章还在使用GlobalScope.launch来教你怎么启动一个协程,那么这么用有问题吗?当然没问题了,连官方文档的第一篇入门文章也是这么教的,只是这么用的话,会需要你注意自行处理协程的开始和结束等问题以免导致内存泄漏或是其它你不想看见的异常,如果有更好的选择的话,何苦为难自己?

实际上现在Android官方的Jetpack组件在很多情况下已经提供给开发者默认的Scope,比如ViewModelScopeLifecycleScope等,不过这不是这篇文章的重点,前言已经说了,从最基础的粒度上对吧。那么抛弃这些框架组件来说,我推荐你用什么呢?

val mainScope = MainScope()

就这么简单,一行代码。让我们来看看Kotlin协程官方提供的这个MainScope()是什么东西:

@Suppress("FunctionName")
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

很简洁明了,这是一个内部的取消操作只会向下传播,运行在主线程CoroutineScope

如果你想要一个默认运行在子线程的Scope:

val ioScope = MainScope() + Dispatchers.IO

或者你想要一个自动捕获内部包括子协程可能抛出的异常的Scope:

val mainScope = MainScope() + CoroutineExceptionHandler { context, throwable ->
    throwable.printStackTrace()
}

我推荐你这么使用的目的是,你可以在Activity的OnDestroy生命周期或者其它任何你需要的时机里调用mainScope.cancel(),即可在大部分情况下取消所有可能还在运行的子协程;

为什么是大部分情况下?因为在某些时候,比如子协程里进行循环读取文件流等阻塞操作时,你可能需要自行加上判断Scope的运行状态进行中断操作。

launch

Scope说完了,现在说说最简单的一个函数:launch

假设你现在有某一部分代码,你只是简单的想延迟几秒后执行,你不想用postDelay不想写Timer等等,更不想在延迟的那几秒内阻塞主线程(这不是废话么;D),你可以这么写:

private fun delayToExecute(duration: Long, execution: () -> Unit) = mainScope.launch {
    delay(duration)
    execution()
}

这里我们将launch放到了方法上,实际上这个方法返回一个Job,如果你不需要的话,完全可以不去管它;并且我们直接使用的是mainScope.launch,没有指定其它Dispatchers,因此execution函数体会在主线程里执行,可以直接在里面做修改UI等操作,你现在可以这么调用:

delayToExecute(3000) {
    textView.text = "xxxx"
}

你也可以将launch放到调用者上,将前面的方法改为:

private suspend fun delayToExecute(duration: Long, execution: () -> Unit) {
    delay(duration)
    execution()
}

调用的时候就得改为:

mainScope.launch {
    delayToExecute(3000) {
         textView.text = "xxxx"
    }
}

两种方法有什么区别呢?去查阅一下suspend标识符的意义吧,前言里扔老师的视频对于suspend的讲解很清楚,这里就不过多赘述

如果你希望execution函数体执行在子线程上的话也很简单,launch支持指定Dispatchers,加一个参数即可:

mainScope.launch(Dispatchers.IO)

是不是觉得太简单了?就这?别急,launch的本质目的只是让你启动一个基础协程,有了它,你才能够跟各种之后要说的各种suspend方法以及其他协程函数打交道。

async

上面说了launch本质上只是启动一个基础协程,它返回一个Job

假设现在你有一个或多个函数,你希望它运行在子线程上,里面进行一些耗时处理,一段时间后返回处理结果,当且仅当所有这些函数全都返回了结果之后,才进行后续的处理。

想一想这种情况下,如果不使用RxJava,是不是需要写一堆的回调去接收和处理结果?甚至即使使用RxJava,是不是也或多或少的存在一些嵌套代码?

这时候就是协程最让开发者舒服的时候了,即所谓的用同步风格,写异步代码

我们将这种情况拆开了看,这些运行在子线程上的函数可以想象为处理者,收到一些数据,将数据处理之后返回结果,而调用者希望以最简单的形式去使用这些处理者,也就是调起处理者,以返回值的形式接收结果,不要再让调用者去写回调接收结果。

我们来看看假设有两个处理者,怎么去实现:

private fun processA(data: String): String {
    info { "${Thread.currentThread().name}: process A start" }
    Thread.sleep(4000)
    info { "${Thread.currentThread().name}: process A done" }
    return "$data --> process A done"
}

private fun processB(data: String): String {
    info { "${Thread.currentThread().name}: process B start" }
    Thread.sleep(2000)
    info { "${Thread.currentThread().name}: process B done" }
    return "$data --> process B done"
}

processA函数在4秒后返回处理结果,processB在2秒后返回处理结果。

再来看看调用者怎么实现:

private fun invokeProcess() = mainScope.launch {
    val defA = async(Dispatchers.IO) {
        processA("Data for A")
    }
    val defB = async(Dispatchers.IO) {
        processB("Data for B")
    }
    val resultA = defA.await()
    val resultB = defB.await()
    info { "${Thread.currentThread().name}: $resultA \t $resultB" }
}

之后执行invokeProcess(),打印出来的日志为:

DefaultDispatcher-worker-2: process A start
DefaultDispatcher-worker-1: process B start
DefaultDispatcher-worker-1: process B done
DefaultDispatcher-worker-2: process A done
main: Data for A --> process A done 	 Data for B --> process B done

从日志上可以清晰的看出来,async函数在调用时即立刻执行(也可以根据需要指定不立即执行,具体可以了解一下async接收的CoroutineStart参数)

从通俗的角度上解释一下async这个函数,它会启动一个子协程,将lambda表达式(即上例中的处理者函数)运行在指定的线程中,并且返回一个Deferred对象,该对象的await()方法的返回值即asynclambda表达式内的返回值。

在调用对应的Deferredawait()方法时,若函数还在处理中,则挂起当前协程(即上例中的调用者所在的协程)等待返回值;若函数此时已经有返回值,则立即得到结果。

这里仅仅只介绍了async函数最基础的用法,感兴趣的同学可以看看官方文档组合挂起函数了解更多可配置的参数。


可能有的同学又说了,不就是asyncDeferred嘛,类似的东西Java甚至其它语言也有啊。

那么下面就来说说重头戏,Kotlin协程是怎么真正解决回调地狱的场景的。

解决回调地狱:suspendCoroutine

在上面的例子中,细心的同学可能发现了,处理者中的结果都是以return的方式返回的,然而实际开发中,很有可能处理者脱离了你的控制,没办法以return的方式返回结果。

比如说你可能需要在处理者中调用某个第三方框架,这个第三方框架限制了你必须以回调的形式来接收结果;在这种情况下,处理者无法避免的涉及到一些回调嵌套,那么我们看看怎么样让调用者最大限度的避免回调地狱。

以我个人很喜欢的一个动态权限处理框架AndPermission来作为例子,这个框架帮助开发者去处理动态权限的判断和申请,以回调的形式接收结果,大概是这个样子的:

AndPermission.with(this)
    .runtime()
    .permission(Manifest.permission.CAMERA)
    .onGranted {
        // 已有权限或是用户点击了授予权限
    }
    .onDenied {
        // 无法获取权限或是用户点击了拒绝授予权限
    }
    .start()

可以看到,这个例子里,权限的是否获取是通过onGrantedonDenied来回调的。

如果不使用协程来的话,是不是得在那两个回调里嵌套进拿到权限结果后的逻辑代码?这还是只嵌套了一层的情况,假设有更多的类似这样的嵌套情况呢?

让我们换回刚才说aync时候的思路,把这个问题看成调用者处理者的关系:

  • 权限的申请应该是一个独立的处理者,内部的逻辑不应该需要调用者去关注;

  • 对于调用者来说,权限的申请只应该关心最终结果,也就是true或者false就够了。

现在来看看权限申请的处理者怎么实现:

private suspend fun checkPermission(context: Context, vararg permissions: String) =
    suspendCoroutine<Boolean> { continuation ->
        AndPermission.with(context)
            .runtime()
            .permission(permissions)
            .onGranted {
                // 已有权限或是用户点击了授予权限
                continuation.resume(true)
            }
            .onDenied {
                // 无法获取权限或是用户点击了拒绝授予权限
                continuation.resume(false)
            }
            .start()
    }

这里稍微将参数改造了一下,甚至可以将这个方法抽为一个工具类的静态方法,在任何需要判断权限的时候都可以使用。

suspendCoroutine函数接收一个lambda表达式,调起的时候挂起调用者,并continuation.resume()将结果以返回值的形式返回给调用者。

有点拗口?没事,再来看看调用者的代码:

private fun startCamera() = mainScope.launch {
    val permission = checkPermission(context, Manifest.permission.CAMERA)
    if (permission) {
        // 获得了权限,开启摄像头或其他操作
    } else {
        // 未获得权限,提醒用户使用该权限的目的
    }
}

调用者的代码是不是简洁明了许多?调用处理者,接收一个布尔值,然后就直接可以进行后续的逻辑处理了。

以此延伸,不管你有多少个类似这样的异步返回结果的处理者的时候,对于调用者来说都没关系,统统都是一行代码,接收一个返回值,搞定,是不是很方便?

留一个很常见的场景大家可以尝试自己去实现一下试试:

读取SD卡内的某张图片,对其进行压缩,再将其上传到服务器上


这个系列的第一篇文章就到此结束了,仅仅从最基本的角度讲了Kotlin协程几个函数在默认情况下的使用场景,有兴趣的同学可以自行看一下官方文档,了解一下这几个函数的一些可选参数:)