使用协程和 Flow 简化 API 设计

image

如果您是库作者,您也许希望用户在使用 Kotlin 协程与 Flow 时可以更加轻松地调用您基于 Java 或回调的 API。另外,如果您是 API 的使用者,则可能愿意将第三方 API 界面适配协程,以使它们对 Kotlin 更友好。

本文将会介绍如何使用协程和 Flow 简化 API,以及如何使用 suspendCancellableCoroutine 和 callbackFlow API 创建您自己的适配器。针对那些富有好奇心的读者,本文还会对这些 API 进行剖析,以帮您了解它们底层的工作原理。

如果您更喜欢观看视频,可以 点击这里

检查现有协程适配器

在您为现有 API 编写自己的封装之前,请检查是否已经存在针对您的用例的适配器或者 扩展方法。下面是一些包含常见类型协程适配器的库。

Future 类型

对于 future 类型,Java 8 集成了 CompletableFuture,而 Guava 集成了 ListenableFuture。这里提到的并不是全部,您可以在线搜索以确定是否存在适用于您的 future 类型的适配器。

// 等待 CompletionStage 的执行完成而不阻塞线程
suspend fun <T> CompletionStage<T>.await(): T 
 
// 等待 ListenableFuture 的执行完成而不阻塞线程
suspend fun <T> ListenableFuture<T>.await(): T

使用这些函数,您可以摆脱回调并挂起协程直到 future 的结果被返回。

Reactive Stream

对于响应式流的库,有针对 RxJavaJava 9 API响应式流库 的集成:

// 将给定的响应式 Publisher 转换为 Flow
fun <T : Any> Publisher<T>.asFlow(): Flow<T>

这些函数将响应式流转换为了 Flow。

Android 专用 API

对于 Jetpack 库或 Android 平台 API,您可以参阅 Jetpack KTX 库 列表。现有超过 20 个库拥有 KTX 版本,构成了您所熟悉的 Java API。其中包括 SharedPreferences、ViewModels、SQLite 以及 Play Core。

回调

回调是实现异步通讯时非常常见的做法。事实上,我们在 后台线程任务运行指南 中将回调作为 Java 编程语言的默认解决方案。然而,回调也有许多缺点: 这一设计会导致令人费解的回调嵌套。同时,由于没有简单的传播方式,错误处理也更加复杂。在 Kotlin 中,您可以简单地使用协程调用回调,但前提是您必须创建您自己的适配器。

创建您自己的适配器

如果没有找到适合您用例的适配器,更直接的做法是自己编写适配器。对于一次性异步调用,可以使用 suspendCancellableCoroutine API;而对于流数据,可以使用 callbackFlow API。

作为练习,下面的示例将会使用来自 Google Play Services 的 Fused Location Provider API 来获取位置数据。此 API 界面十分简单,但是它使用回调来执行异步操作。当逻辑变得复杂时,这些回调容易使代码变得不可读,而我们可以使用协程来摆脱它们。

如果您希望探索其它解决方案,可以通过上面函数所链接的源代码为您带来启发。

一次性异步调用

Fused Location Provider API 提供了 getLastLocation 方法来获得 最后已知位置。对于协程来说,理想的 API 是一个直接返回确切结果的挂起函数。

注意: 这一 API 返回值为 Task,并且已经有了对应的 适配器。出于学习的目的,我们用它作为范例。

我们可以通过为 FusedLocationProviderClient 创建扩展函数来获得更好的 API:

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location

由于这是一个一次性异步操作,我们使用 suspendCancellableCoroutine 函数: 一个用于从协程库创建挂起函数的底层构建块。

suspendCancellableCoroutine 会执行作为参数传入的代码块,然后在等待继续信号期间挂起协程的执行。当协程 Continuation 对象中的 resumeresumeWithException 方法被调用时,协程会被恢复执行。有关 Continuation 的更多信息,请参阅: Kotlin Vocabulary | 揭秘协程中的 suspend 修饰符

我们使用可以添加到 getLastLocation 方法中的回调来在合适的时机恢复协程。参见下面的实现:

// FusedLocationProviderClient 的扩展函数,返回最后已知位置
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =

  // 创建新的可取消协程
  suspendCancellableCoroutine<Location> { continuation ->

    // 添加恢复协程执行的监听器
    lastLocation.addOnSuccessListener { location ->
      // 恢复协程并返回位置
      continuation.resume(location)
    }.addOnFailureListener { e ->
      // 通过抛出异常来恢复协程
      continuation.resumeWithException(e)
    }

    // suspendCancellableCoroutine 块的结尾。这里会挂起协程
    //直到某个回调调用了 continuation 参数
  }

注意: 尽管协程库中同样包含了不可取消版本的协程构建器 (即 suspendCoroutine),但最好始终选择使用 suspendCancellableCoroutine 处理协程作用域的取消及从底层 API 传播取消事件。

suspendCancellableCoroutine 原理

在内部,suspendCancellableCoroutine 使用 suspendCoroutineUninterceptedOrReturn 在挂起函数的协程中获得 Continuation。这一 Continuation 对象会被一个 CancellableContinuation 对象拦截,后者会从此时开始控制协程的生命周期 (其 实现 具有 Job 的功能,但是有一些限制)。

接下来,传递给 suspendCancellableCoroutine 的 lambda 表达式会被执行。如果该 lambda 返回了结果,则协程将立即恢复;否则协程将会在 CancellableContinuation 被 lambda 手动恢复前保持挂起状态。

您可以通过我在下面代码片段 (原版实现) 中的注释来了解发生了什么:

public suspend inline fun <T> suspendCancellableCoroutine(
  crossinline block: (CancellableContinuation<T>) -> Unit
): T =
  // 获取运行此挂起函数的协程的 Continuation 对象 
  suspendCoroutineUninterceptedOrReturn { uCont ->

    // 接管协程。Continuation 已经被拦截,
    // 接下来将会遵循 CancellableContinuationImpl 的生命周期
    val cancellable = CancellableContinuationImpl(uCont.intercepted(), ...)
    /* ... */
 
    // 使用可取消 Continuation 调用代码块
    block(cancellable)
        
    // 挂起协程并且等待 Continuation 在 “block” 中被恢复,或者在 “block” 结束执行时返回结果 
    cancellable.getResult()
  }

想了解更多有关挂起函数的工作原理,请参阅这篇: Kotlin Vocabulary | 揭秘协程中的 suspend 修饰符

流数据

如果我们转而希望用户的设备在真实的环境中移动时,周期性地接收位置更新 (使用 requestLocationUpdates 函数),我们就需要使用 Flow 来创建数据流。理想的 API 看起来应该像下面这样:

fun FusedLocationProviderClient.locationFlow(): Flow<Location>

为了将基于回调的 API 转换为 Flow,可以使用 callbackFlow 流构建器来创建新的 flow。callbackFlow 的 lambda 表达式的内部处于一个协程的上下文中,这意味着它可以调用挂起函数。不同于 flow 流构建器,channelFlow 可以在不同的 CoroutineContext 或协程之外使用 offer 方法发送数据。

通常情况下,使用 callbackFlow 构建流适配器遵循以下三个步骤:

  1. 创建使用 offer 向 flow 添加元素的回调;
  2. 注册回调;
  3. 等待消费者取消协程,并注销回调。

将上述步骤应用于当前用例,我们得到以下实现:

// 发送位置更新给消费者
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
  // 创建了新的 Flow。这段代码会在协程中执行。
  // 1. 创建回调并向 flow 中添加元素
  val callback = object : LocationCallback() {
    override fun onLocationResult(result: LocationResult?) {
      result ?: return  // 忽略为空的结果
      for (location in result.locations) {
        try {
          offer(location)  // 将位置发送到 flow
        } catch (t: Throwable) {
          // 位置无法发送到 flow
        }
      }
    }
  }
  
  // 2. 注册回调并通过调用 requestLocationUpdates 获取位置更新。
  requestLocationUpdates(
    createLocationRequest(),
    callback,
    Looper.getMainLooper()
  ).addOnFailureListener { e ->
    close(e)  // 在出错时关闭 flow
  }
  
  // 3. 等待消费者取消协程并注销回调。这一过程会挂起协程,直到 Flow 被关闭。
  awaitClose {
    // 在这里清理代码
    removeLocationUpdates(callback)
  }
}

callbackFlow 内部原理

在内部,callbackFlow 使用了一个 channel。channel 在概念上很接近阻塞 队列 —— 它在配置时需要指定容量 (capacity): 即可以缓冲的元素个数。在 callbackFlow 中创建的 channel 默认容量是 64 个元素。如果将新元素添加到已满的 channel,由于 offer 不会将元素添加到 channel 中,并且会立即返回 false,所以 send 会暂停生产者,直到频道 channel 中有新元素的可用空间为止。

awaitClose 内部原理

有趣的是,awaitClose 内部使用的是 suspendCancellableCoroutine。您可以通过我在以下代码片段中的注释 (查看 原始实现) 一窥究竟:

public suspend fun ProducerScope<*>.awaitClose(block: () -> Unit = {}) {
  ...
  try {
    // 使用可取消 continuation 挂起协程
    suspendCancellableCoroutine<Unit> { cont ->
      // 仅在 Flow 或 Channel 关闭时成功恢复协程,否则保持挂起
      invokeOnClose { cont.resume(Unit) }
    }
  } finally {
    // 总是会执行调用者的清理代码
    block()
  }
}

复用 Flow

除非额外使用中间操作符 (如: conflate),否则 Flow 是冷且惰性的。这意味着每次调用 flow 的终端操作符时,都会执行构建块。对于我们的用例来说,由于添加一个新的位置监听器开销很小,所以这一特性不会有什么大问题。然而对于另外的一些实现可就不一定了。

您可以使用 shareIn 中间操作符在多个收集器间复用同一个 flow,并使冷流成为热流。

val FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
  ...
}.shareIn(
  // 让 flow 跟随 applicationScope
  applicationScope,
  // 向新的收集器发送上一次发送的元素
  replay = 1,
  // 在有活跃的订阅者时,保持生产者的活跃状态
  started = SharingStarted.WhileSubscribed()
)

您可以通过文章《协程中的取消和异常 | 驻留任务详解》来了解更多有关在应用中使用 applicationScope 的最佳实践。

您应当考虑通过创建协程适配器使您的 API 或现存 API 简洁、易读且符合 Kotlin 的使用习惯。首先检查是否已经存在可用的适配器,如果没有,您可以使用 suspendCancellableCoroutine 针对一次性调用;或使用 callbackFlow 针对流数据,来创建您自己的适配器。

您可以通过 codelab: 创建 Kotlin 扩展库,来上手本文所介绍的话题。

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

推荐阅读更多精彩内容