使用Kotlin Coroutines进行简单的异步加载

计算机很擅长多任务操作。为了编写出好的软件我们需要对多任务操作和异步有个很好的了解。在Android上面这些包括了activities和fragments的异步的生命周期回调。

Kotlin Coroutines(Kotlin协程)是最近加入到了异步API和库的工具箱中。它不是一个解决所有问题的银弹(a silver bullet),但是在很多情境下它可以让问题变得更简单。本文不会深入探讨coroutines的内部工作原理,而只是举一个怎样在android开发中使用kotlin coroutines的例子。

Let’s get started!

准备,编写Gradle

目前为止Kotlin Coroutines还是实验性的特性,因此使用Kotlin Coroutines需要在app模块的build.gradle添加一些东西,直接在android片段后面加上下面的代码:

kotlin {
    experimental {
        coroutines 'enable'
    }
}

然后再添加两个依赖:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:0.20"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:0.20"

你的第一个coroutine

我们的需求是从媒体存储(media storage)中加载一个图片然后通过一个ImageView展示,同步方法可以这样写:

fun loadBitmapFromMediaStore(imageId: Int, imagesBaseUri: Uri): Bitmap {
  val uri = Uri.withAppendedPath(imagesBaseUri, imageId.toString())
  return MediaStore.Images.Media.getBitmap(contentResolver, uri)
}

由于这个方法是IO操作因此必须在后台线程中进行。函数返回Bitmap后我们使用ImageView展示它:

imageView.setImageBitmap(bitmap)

这个调用必须在UI线程否则会crash。只需要三行代码,我们可以这样写:

val uri = Uri.withAppendedPath(imagesBaseUri, imageId.toString())
val bitmap = MediaStore.Images.Media.getBitmap(contentResolver, uri)
imageView.setImageBitmap(bitmap)

取决于加载Bitmap的线程和时间,上面的代码将导致应用程序暂时冻结(糟糕的用户体验)或崩溃。如果使用Kotlin Coroutines我们可以这样写:

val job = launch(Background) {
  val uri = Uri.withAppendedPath(imagesBaseUri, imageId.toString())
  val bitmap = MediaStore.Images.Media.getBitmap(contentResolver, 
  launch(UI) {
    imageView.setImageBitmap(bitmap)
  }
}

现在我们先暂时忽略返回值job,等一会再讨论它。重点launch方法以及它的两个参数Background和UI。这段代码和之前的三行代码的不同处在于launch()函数的调用。我们可以很容易地遵循这段代码,它与前面的三行完全同步的代码的的示例几乎完全相同。

函数launch()所做的事情是创建和启动一个coroutine。Background参数是一个CoroutineContext保证这个coroutine运行在后台线程中因此引用不会卡顿或者crash,你可以这样声明一个CoroutineContext:

internal val Background = newFixedThreadPoolContext(2, "bg")

这行代码将会给coroutine创建一个新的context且名叫“bg”,它会使用两个常规线程来执行它的任务。

在第一个协程(launch(Background)创建的)中我们调用了launch(UI),launch(UI)将会出发另一个协程coroutine,这个coroutine运行在预先定义好的使用UI线程的context。这意味着imageView.setImageBitmap()将会运行在UI线程而不会导致应用crash。

取消协程

上面的代码可能是您在使用其他api之前没有做过的。第一个挑战是activity的生命周期问题。如果在加载完成之前我们销毁了activity,那么调用imageView.setImageBitmap()将会导致应用崩溃。为了避免这中情况,我们必须取消这个加载。这个是launch()的返回值需要做的,我们把这个返回值job保存起来,在activity的onStop()中这样做:

job.cancel()

这和RxJava (Disposable调用dispose())或者AsyncTask (调用cancel())所做的事情是一样的。为了执行后台操作而阅读语法,我们并没有获得更多的便利性。我们来看看能否解决这个问题。

生命周期观察者LifecycleObserver

自从支持库(support library)出来之后,Android Architecture Components应该算是Google送给androiders最好的礼物了。有很多的文章来讲解ViewModel、Room和LiveData。另一个伟大的部分是Lifecycle API,利用它我们可以很方便地监听activity和fragment的生命周期变化并作出相应的反应。结合coroutines我们使用下面的代码:

class CoroutineLifecycleListener(val deferred: Deferred<*>) : LifecycleObserver {
  @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
  fun cancelCoroutine() {
    if (!deferred.isCancelled) {
      deferred.cancel()
    }
  }
}

我们为LifecycleOwner (FragmentActivity和support Fragment实现了它)创建一个叫做load的扩展函数(我默认并希望你了解kotlin的扩展函数,这是kotlin的基础知识,如果不懂这个基础,那么能懂kotlin coroutines就是奇迹了):

fun <T> LifecycleOwner.load(loader: () -> T): Deferred<T> {
  val deferred = async(context = Background, start = CoroutineStart.LAZY) {
    loader()
  }

  lifecycle.addObserver(CoroutineLifecycleListener(deferred))
  return deferred
}

好吧,我承认这个扩展函数有很多新的东西,我们一点一点来分析。

我们给LifecycleOwner添加了这个扩展函数,而且Activity和Fragment实现了LifecycleOwner,那么在activity和fragment里我们可以直接调用这个load()函数,函数里面我们获取成员变量lifecycle,并添加观察者CoroutineLifecycleListener。

load()函数的参数是一个叫做loader的lambda,这个lambda返回范型T。函数内部,我们调用async()来创建一个coroutine,async()将会运行在后台因为它的参数是Background coroutine context,需要注意的是async()还有第二个参数:start = CoroutineStart.LAZY,这表示这个coroutine不会启动直到有人显示地请求它返回值,后面内容你将会看到怎么使用它。

这个coroutine返回一个Deferred<T>对象给调用者,它和之前的job变量很像,但是它可以携带deferred值比如一个JavaScript Promise或者常规Java APIs中的Future<T>,好处是它可以有一个工作在coroutines中的await()方法,马上你就可以看到。

下面我们给Deferred<T>定义另一个扩展函数then(),而Deferred<T>正是上一个扩展函数返回类型。它也接受一个lambda参数block,block使用一个T类型的对象作为参数并返回Unit。

infix fun <T> Deferred<T>.then(block: (T) -> Unit): Job {
  return launch(context = UI) {
    block(this@then.await())
  }
}

这个函数使用launch()创建一个运行在UI线程的coroutine。block的lambda表达式传递给这个coroutine,它把完成的Deferred对象最为自己的参数。我们调用await()方法来暂停当前coroutine直到Deferred对象返回了值。

需要注意的是这个扩展函数使用了中缀符号infix,如果你不懂中缀符号,下面的对then函数的调用你可能不太明白是咋回事,我在这里简单地说明下,正常的函数调用是object.function(parameter),如果函数是infix函数,可以使用这样的调用方式:object function parameter,比如kotlin标准库的add函数就是infix函数,我们就可以这样调用add函数:1 add 2等价于1.add(2),大致是这么回事,有意见请留言。

这正是kotlin coroutines的迷人之处。await()的调用虽然是在UI线程完成的,但是它并不会阻塞UI线程。它只是暂停函数的执行直到准备好,Deferred对象传递给lambda后它就会唤起并开始执行。当这个coroutine 暂停的时候,UI线程可以继续执行其它的事情。Suspending functions是kotlin coroutines的核心概念和奇迹所在。

load()函数中添加的生命周期观察者(lifecycle observer)在activity的onDestroy()中会cancel掉第一个coroutine,这样会导致第二个coroutine也会被cancel掉因此避免block()执行。

Kotlin Coroutine DSL(Domain Specific Language,领域专用语言)

这两个扩展函数考虑到了coroutine的取消问题,下面看下我们的代码:

load {
  loadBitmapFromMediaStore(imageId, imagesBaseUri)
} then {
  imageView.setImageBitmap(it)
}

上面的代码我们给第一个扩展函数load()传递一个lambda表达式,这个lambda调用了必须运行在后台线程的loadBitmapFromMediaStore()方法。lambda返回Bitmap类型,因此load()扩展函数返回Deferred<Bitmap>类型。

上面代码对第二个扩展函数then()的调用看起来很玄幻,这是中缀符号infix特有的调用方式。传递给then()的lambda接收一个Bitmap,因此我们可以调用imageView.setImageBitmap(it)方法。多谢生命周期观察者(lifecycle observer),取消(Cancellation)这个问题我们也考虑到了。

上面的代码对于这种异步调用的情景是通用的:首先从后台线程获取数据,然后在UI线程展示数据。貌似kotlin coroutines不像RxJava那样强大,因为RxJava可以处理多个调用,但是kotlin coroutines更简单易读并且覆盖了大部分的应用场景。你可以写出这样安全的代码,而不必担心泄漏Context或者在每次调用中处理线程:

load { restApi.fetchData(query) } then { adapter.display(it) }

load()then()代码如此简短而不足以搞一个新的library,但是我希望将来一旦kotlin coroutines有了稳定的正式版本,在Kotlin-based library中能够出现类似的东西。

到目前为止,你有两个选择,既可以采用上面的简单代码也可以看下Anko Coroutines。在这里我还发布了一个更加完整的版本。祝你在kotlin coroutines的冒险中旅途愉快!


原文地址,翻译的不是很好,大致只翻译了技术部分,一些啰嗦的段落和句子没有翻译😂,有好的意见请留言。原文的第二个扩展函数then()有bug,具体bug和bugfix请看原文的两条评论:评论1评论2

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

推荐阅读更多精彩内容