在 Android 中使用协程(Coroutine)

image

简评:可能对于很多的 Android 程序员来说协程(Coroutine)并不是一个熟悉的概念,更多是和线程、回调打交道。但协程这一概念其实很早就提出来了,C#, Lua, Go 等语言也支持协程,Kotlin 也提供了 kotlinx.coroutines 库来帮助使用协程。所以,今天这里就介绍下怎么通过 Kotlin 在 Android 中使用协程。

Coroutine 中文大多翻译为「协程」,相关概念网上已有很多相关的资料(《计算机程序设计艺术 卷一》中就有讲到 Coroutine),这里就不再赘述。

在这篇文章中,主要关注如何通过 kotlinx.coroutines 库来在 Android 中实现 Coroutine。

如何启动一个协程(Coroutine)

kotlinx.coroutines 库中,我们可以使用 launchasync 来启动一个新的 coroutine。

从概念上讲,async 和 launch 是类似的,区别在于 launch 会返回一个 Job 对象,不会携带任何结果值。而 async 则是返回一个 Deferred - 一个轻量级、非阻塞的 future,代表了之后将会提供结果值的承诺(promise),因此可以使用 .await() 来获得其最终的结果,当然 Deferred 也是一个 Job,如果需要也是可以取消的。

如果你对于 future, promise, deferred 等概念感到困惑,可以先阅读并发 Promise 模型或其他资料了解相关概念。

Coroutine context

在 Android 中我们经常使用两类 context:

  • uiContext: 用于执行 UI 相关操作。
  • bgContext: 用于执行需要在后台运行的耗时操作。
// dispatches execution onto the Android main UI thread
private val uiContext: CoroutineContext = UI

// represents a common pool of shared threads as the coroutine dispatcher
private val bgContext: CoroutineContext = CommonPool

这里 bgContext 使用 CommonPool,可以限制同时运行的线程数小于 Runtime.getRuntime.availableProcessors() - 1。

launch + async (execute task)

父协程(The parent coroutine)使用 uiContext 通过 *launch *启动。

子协程(The child coroutine)使用 CommonPool context 通过 *async *启动。

注意:

  1. 父协程总是会等待所有的子协程执行完毕。
  2. 如果发生未检查的异常,应用将会崩溃。

下面实现一个简单的读取数据并由视图进行展示的例子:

private fun loadData() = launch(uiContext) {
    view.showLoading() // ui thread

    val task = async(bgContext) { dataProvider.loadData("Task") }
    val result = task.await() // non ui thread, suspend until finished

    view.showData(result) // ui thread
}

launch + async + async (顺序执行两个任务)

下面的两个任务是顺序执行的:

private fun loadData() = launch(uiContext) {
    view.showLoading() // ui thread

    // non ui thread, suspend until task is finished
    val result1 = async(bgContext) { dataProvider.loadData("Task 1") }.await()

    // non ui thread, suspend until task is finished
    val result2 = async(bgContext) { dataProvider.loadData("Task 2") }.await()

    val result = "$result1 $result2" // ui thread

    view.showData(result) // ui thread
}

launch + async + async (同时执行两个任务)

private fun loadData() = launch(uiContext) {
    view.showLoading() // ui thread

    val task1 = async(bgContext) { dataProvider.loadData("Task 1") }
    val task2 = async(bgContext) { dataProvider.loadData("Task 2") }

    val result = "${task1.await()} ${task2.await()}" // non ui thread, suspend until finished

    view.showData(result) // ui thread
}

启动 coroutine 并设置超时时间

可以通过 withTimeoutOrNull 来给 coroutine job 设置时限,如果超时将会返回 null。

private fun loadData() = launch(uiContext) {
    view.showLoading() // ui thread

    val task = async(bgContext) { dataProvider.loadData("Task") }

    // non ui thread, suspend until the task is finished or return null in 2 sec
    val result = withTimeoutOrNull(2, TimeUnit.SECONDS) { task.await() }

    view.showData(result) // ui thread
}

如何取消一个协程(coroutine)

var job: Job? = null

fun startPresenting() {
    job = loadData()
}

fun stopPresenting() {
    job?.cancel()
}

private fun loadData() = launch(uiContext) {
    view.showLoading() // ui thread

    val task = async(bgContext) { dataProvider.loadData("Task") }
    val result = task.await() // non ui thread, suspend until finished

    view.showData(result) // ui thread
}

当父协程被取消时,所有的子协程将递归的被取消。

在上面的例子中,如果 stopPresenting 在被调用时 dataProvider.loadData 正在运行,那么 view.showData 方法将不会被调用。

如何处理异常

try-catch block

我们还是可以像平时一样用 try-catch 来捕获和处理异常。不过这里推荐将 try-catch 移到 dataProvider.loadData 方法里面,而不是直接包裹在外面,并提供一个统一的 Result 类方便处理。

data class Result<out T>(val success: T? = null, val error: Throwable? = null)

private fun loadData() = launch(uiContext) {
    view.showLoading() // ui thread

    val task = async(bgContext) { dataProvider.loadData("Task") }
    val result: Result<String> = task.await() // non ui thread, suspend until the task is finished

    if (result.success != null) {
        view.showData(result.success) // ui thread
    } else if (result.error != null) {
        result.error.printStackTrace()
    }
}

async + async

当通过 async 来启动父协程时,将会忽略掉任何异常:

private fun loadData() = async(uiContext) {
    view.showLoading() // ui thread

    val task = async(bgContext) { dataProvider.loadData("Task") }
    val result = task.await() // non ui thread, suspend until the task is finished

    view.showData(result) // ui thread
}

在这里 loadData() 方法会返回 Job 对象,而 exception 会被存放在这个 Job 对象中,我们可以用 invokeOnCompletion 函数来进行检索:

var job: Job? = null

fun startPresenting() {
    job = loadData()
    job?.invokeOnCompletion { it: Throwable? ->
        it?.printStackTrace() // (1)
        // or
        job?.getCompletionException()?.printStackTrace() // (2)

        // difference between (1) and (2) is that (1) will NOT contain CancellationException
        // in case if job was cancelled
    }
}

launch + coroutine exception handler

我们还可以为父协程的 context 中添加 CoroutineExceptionHandler 来捕获和处理异常:

val exceptionHandler: CoroutineContext = CoroutineExceptionHandler { _, throwable -> throwable.printStackTrace() }

private fun loadData() = launch(uiContext + exceptionHandler) {
    view.showLoading() // ui thread

    val task = async(bgContext) { dataProvider.loadData("Task") }
    val result = task.await() // non ui thread, suspend until the task is finished

    view.showData(result) // ui thread
}

怎么测试协程(coroutine)?

要启动协程(coroutine)必须要指定一个 CoroutineContext

class MainPresenter(private val view: MainView,
                    private val dataProvider: DataProviderAPI) {

    private fun loadData() = launch(UI) { // UI - dispatches execution onto Android main UI thread
        view.showLoading()

        // CommonPool - represents common pool of shared threads as coroutine dispatcher
        val task = async(CommonPool) { dataProvider.loadData("Task") }
        val result = task.await()

        view.showData(result)
    }

}

因此,如果你想要为你的 MainPresenter 写单元测试,你就必须要能为 UI 和 后台任务指定 coroutine context。

最简单的方式就是为 MainPresenter 的构造方法增加两个参数,并设置默认值:

class MainPresenter(private val view: MainView,
                    private val dataProvider: DataProviderAPI
                    private val uiContext: CoroutineContext = UI,
                    private val ioContext: CoroutineContext = CommonPool) {

    private fun loadData() = launch(uiContext) { // use the provided uiContext (UI)
        view.showLoading()

        // use the provided ioContext (CommonPool)
        val task = async(bgContext) { dataProvider.loadData("Task") }
        val result = task.await()

        view.showData(result)
    }

}

现在,就可以在测试中传入 kotlin.coroutines 提供的 EmptyCoroutineContext 来让代码运行在当前线程里。

@Test
fun test() {
    val dataProvider = Mockito.mock(DataProviderAPI::class.java)
    val mockView = Mockito.mock(MainView::class.java)

    val presenter = MainPresenter(mockView, dataProvider, EmptyCoroutineContext, EmptyCoroutineContext)
    presenter.startPresenting()
    ...
}

上面就是 kotlin 中协程(coroutine)的基本用法,完整代码可以查看 Github 项目

如果想了解更多内容还可以查看 Kotlin 的官方示例:Kotlin/kotlinx.coroutines

原文:Android Coroutine Recipes
推荐阅读:
为什么 Android 开发者都应该尝试一下 Anko?
Kotlin 让使用 Android API 变得轻松
“Effective Java” 可能对 Kotlin 的设计造成了怎样的影响——第一部分

出处:在 Android 中使用协程(Coroutine)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269