Kotlin 协程桥接(suspendCoroutine):将任意基于回调的 Android API 转换为挂起函数

大多数 Android 平台 API 和第三方 SDK 都是围绕回调(Callback)设计的。Google Play 计费库使用 PurchasesUpdatedListener,位置服务使用 LocationCallback,蓝牙 GATT 使用 BluetoothGattCallback,Camera2 使用 CameraCaptureSession.StateCallback。如果您编写 Android 代码已有数月时间,想必都写过嵌套层级很深的回调链------这类代码可读性差、测试难度高,而且当任意环节发生错误时,排查和推理错误原因也十分困难。Kotlin 协程通过 suspend 函数解决了这一问题,但平台本身和大多数 SDK 并不会直接提供挂起函数,您需要自己搭建一座"桥"。

本文将详细剖析 suspendCoroutine 桥接模式:包括它如何将基于回调的 API 转换为简洁的挂起函数,如何处理从单值结果到多参数成功/错误对的不同回调形式,如何设计能跨桥接层保留错误语义的异常体系,以及像 RevenueCat 这样的生产级 SDK 如何在 20 多个 API 层面大规模应用这些模式。

核心问题:回调(Callback)不具备组合性

以 Android 上常见的计费流程为例:您需要连接计费服务、查询商品,然后发起购买。使用原生的回调式 API,代码会是这样的:

kotlin 复制代码
billingClient.startConnection(object : BillingClientStateListener {
    override fun onBillingSetupFinished(result: BillingResult) {
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            val params = QueryProductDetailsParams.newBuilder()
                .setProductList(listOf(/* ... */))
                .build()
            billingClient.queryProductDetailsAsync(params) { billingResult, productDetails ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    // 现在启动购买流程...
                }
            }
        }
    }
    override fun onBillingServiceDisconnected() {
        // 重试?记录日志?两个回调没有结构化的错误处理路径
    }
})

每个回调都嵌套在前一个回调内部,错误处理分散在多个 if 判断和不同的重写方法中,没有结构化的方式将失败向上传播。而这还只是两层回调嵌套------完整的计费流程(连接、查询、购买、确认)通常需要四到五层嵌套。

对应的挂起函数版本则像顺序代码一样清晰:

kotlin 复制代码
val connected = billingClient.awaitConnect()
val products = billingClient.awaitQueryProducts(productIds)
val result = billingClient.awaitPurchase(activity, products.first())
billingClient.awaitAcknowledge(result.purchaseToken)

这并非是免费获得的语言特性:每个 await 函数都需要一个桥接层,将底层的回调转换为协程的挂起点。接下来我们详细拆解这座桥的实现原理。

核心桥接:suspendCoroutine

Kotlin 提供了 suspendCoroutine 作为连接回调式代码和协程的基础原型。该函数会挂起当前协程,并提供一个 Continuation<T> 对象。您可以调用 continuation.resume(value) 传递结果,或调用 continuation.resumeWithException(exception) 传递错误,协程会在挂起的位置恢复执行。

最简单的桥接用于处理单值回调:

kotlin 复制代码
suspend fun BillingClient.awaitConnect(): Boolean {
    return suspendCoroutine { continuation ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(result: BillingResult) {
                continuation.resume(
                    result.responseCode == BillingClient.BillingResponseCode.OK
                )
            }
            override fun onBillingServiceDisconnected() {
                // 连接在初始化完成后断开(非初始化过程中)
            }
        })
    }
}

这个模式包含三个部分:

  1. 调用 suspendCoroutine 暂停协程,并获取 Continuation 对象;
  2. 调用基于回调的 API,传入一个捕获了 continuation 的匿名实现类;
  3. 在回调内部,调用 resumeresumeWithException 传递结果,恢复协程执行。

有一条重要规则:必须且只能调用一次 resumeresumeWithException 。调用 0 次会导致协程永久挂起;调用 2 次则会抛出 IllegalStateException。回调中的所有代码路径都必须且只能走到一次 resume 调用。

大多数 SDK API 会将回调拆分为成功和错误路径,这恰好对应 resumeresumeWithException。我们来看 RevenueCat 的 Android SDK 是如何桥接其 offerings API 的。

以下是 awaitOfferings 扩展函数的实现:

kotlin 复制代码
@JvmSynthetic
@Throws(PurchasesException::class)
suspend fun Purchases.awaitOfferings(): Offerings {
    return suspendCoroutine { continuation ->
        getOfferingsWith(
            onSuccess = continuation::resume,
            onError = { continuation.resumeWithException(PurchasesException(it)) },
        )
    }
}

请注意其结构:

  • OnSuccess回调直接使用方法引用 continuation::resume------当回调签名与 (T) -> Unit 匹配且 continuation 期望接收 T 类型时,方法引用是最简洁的写法;
  • onError会将原生的 PurchasesError 包装为 PurchasesException,再传递给 resumeWithException(因为 resumeWithException 要求接收 Throwable 类型,但 SDK 的错误类型是普通数据对象而非异常)。

@JvmSynthetic 注解防止这个扩展函数出现在 Java 代码中(Java 调用者应使用回调版本);@Throws 注解会在字节码中生成 throws 子句,确保 Java 互操作和文档工具能正确标识该函数可能抛出的异常。

回调工厂:抽象接口模板代码

在搭建挂起函数桥接层之前,还有一层桥接工作要做:许多 Android SDK API 接收的是类型化的回调接口,而非 lambda 对。例如 Google Play 计费库使用带 onCompletedonError 方法的 PurchaseCallback,RevenueCat 内部 API 使用带 onReceivedonError 方法的 ReceiveOfferingsCallback

如果在每个挂起函数中都编写这些接口的匿名实现,会产生大量冗余代码。解决方案是编写一组工厂函数,将 lambda 对转换为类型化的回调对象。

以下是 offerings 相关的回调工厂:

kotlin 复制代码
internal fun receiveOfferingsCallback(
    onSuccess: (offerings: Offerings) -> Unit,
    onError: (error: PurchasesError) -> Unit,
) = object : ReceiveOfferingsCallback {
    override fun onReceived(offerings: Offerings) {
        onSuccess(offerings)
    }
    override fun onError(error: PurchasesError) {
        onError(error)
    }
}

这是一个很小的函数,但在大规模场景下价值显著。RevenueCat SDK 为 offerings、客户信息、商店商品、购买、登录、同步等功能都提供了对应的工厂函数,每个函数都将 (onSuccess, onError) lambda 对转换为底层 API 所需的特定回调接口。

购买相关的回调工厂处理更复杂的参数结构:

kotlin 复制代码
internal fun purchaseCompletedCallback(
    onSuccess: (purchase: StoreTransaction, customerInfo: CustomerInfo) -> Unit,
    onError: (error: PurchasesError, userCancelled: Boolean) -> Unit,
) = object : PurchaseCallback {
    override fun onCompleted(storeTransaction: StoreTransaction, customerInfo: CustomerInfo) {
        onSuccess(storeTransaction, customerInfo)
    }
    override fun onError(error: PurchasesError, userCancelled: Boolean) {
        onError(error, userCancelled)
    }
}

请注意其中的不对称性:

  • 成功回调返回两个值:交易信息(transaction)和更新后的客户信息(customer info);
  • 错误回调也返回两个值:错误信息和表示用户是否取消的布尔值。

这并非简单的 (T) -> Unit 结构,要将其桥接为挂起函数,需要额外的设计考量。

多值回调:包装类

当回调返回多个值时,您需要一个容器来让挂起函数返回这些值。解决方案很直接:定义一个数据类来封装这些值。

以下是 RevenueCat 的 PurchaseResult

kotlin 复制代码
@Poko
class PurchaseResult(
    val storeTransaction: StoreTransaction,
    val customerInfo: CustomerInfo,
)

挂起函数桥接层会在成功路径中构造这个包装类:

kotlin 复制代码
@JvmSynthetic
@Throws(PurchasesTransactionException::class)
suspend fun Purchases.awaitPurchase(purchaseParams: PurchaseParams): PurchaseResult {
    return suspendCoroutine { continuation ->
        purchase(
            purchaseParams = purchaseParams,
            callback = purchaseCompletedCallback(
                onSuccess = { storeTransaction, customerInfo ->
                    continuation.resume(PurchaseResult(storeTransaction, customerInfo))
                },
                onError = { purchasesError, userCancelled ->
                    continuation.resumeWithException(
                        PurchasesTransactionException(purchasesError, userCancelled)
                    )
                },
            ),
        )
    }
}

有两点值得注意:

  1. onSuccess将两个值包装为 PurchaseResult,让调用者获得单一的类型化返回值;
  2. onError使用 PurchasesTransactionException 而非普通的 PurchasesException------这是因为错误回调包含额外的 userCancelled 布尔值,调用者需要用它区分"用户主动取消"和"实际错误",异常体系完整保留了这一信息。

将回调桥接为协程时,一个常见错误是丢失错误信息。如果简单地将所有错误包装为 Exception(message),会丢弃调用者进行程序化错误处理所需的结构化错误码。

来看 RevenueCat 的异常设计:

kotlin 复制代码
open class PurchasesException internal constructor(
    val error: PurchasesError,
    internal val overridenMessage: String? = null,
) : Exception() {
    val code: PurchasesErrorCode
        get() = error.code
    val underlyingErrorMessage: String?
        get() = error.underlyingErrorMessage
    override val message: String
        get() = overridenMessage ?: error.message
}

该异常包装了原生的 PurchasesError 对象,保留了类型化的 PurchasesErrorCode 枚举。调用者可以通过 when 表达式匹配错误码,处理特定的错误场景:

kotlin 复制代码
try {
    val offerings = Purchases.sharedInstance.awaitOfferings()
    showPaywall(offerings)
} catch (e: PurchasesException) {
    when (e.code) {
        PurchasesErrorCode.NetworkError -> showRetryDialog()
        PurchasesErrorCode.StoreProblemError -> showStoreErrorMessage()
        else -> showGenericError(e.message)
    }
}

交易异常在其基础上扩展了取消标志:

kotlin 复制代码
class PurchasesTransactionException(
    purchasesError: PurchasesError,
    val userCancelled: Boolean,
) : PurchasesException(purchasesError)

这个异常体系的设计思路很清晰:

  • (error) 参数的回调对应 PurchasesException
  • (error, userCancelled) 参数的回调对应 PurchasesTransactionException

这并非偶然,而是刻意的设计------确保挂起函数 API 具备与回调 API 同等的表达能力。

kotlin 复制代码
try {
    val result = Purchases.sharedInstance.awaitPurchase(params)
    grantEntitlement(result.customerInfo)
} catch (e: PurchasesTransactionException) {
    if (e.userCancelled) {
        // 用户点击返回或关闭了购买弹窗,不属于错误
        return
    }
    showPurchaseError(e.message)
} catch (e: PurchasesException) {
    showGenericError(e.message)
}

Result 变体:并非所有场景都适合用异常

并非所有调用者都想使用 try/catch,有些开发者更倾向于使用 kotlin.Result<T> 进行组合式错误处理。RevenueCat 为每个挂起函数桥接层都提供了第二种变体:

kotlin 复制代码
@JvmSynthetic
suspend fun Purchases.awaitOfferingsResult(): Result<Offerings> =
    suspendCoroutine { continuation ->
        getOfferingsWith(
            onSuccess = { continuation.resume(Result.success(it)) },
            onError = { continuation.resume(Result.failure(PurchasesException(it))) },
        )
    }

核心区别在于:onError调用的是 continuation.resume(Result.failure(...)),而非 continuation.resumeWithException(...)。从协程的角度看,函数始终会成功完成,返回一个 Result 对象供调用者解析:

kotlin 复制代码
val result = Purchases.sharedInstance.awaitOfferingsResult()
result.fold(
    onSuccess = { offerings -> showPaywall(offerings) },
    onFailure = { error -> showError(error.message) },
)

这种模式在需要链式操作且不想用 try/catch 的场景中非常实用:

kotlin 复制代码
suspend fun loadPaywallData(): Result<PaywallData> {
    return Purchases.sharedInstance.awaitOfferingsResult()
        .mapCatching { offerings ->
            val currentOffering = offerings.current
                ?: throw IllegalStateException("No current offering")
            PaywallData(currentOffering)
        }
}

购买相关的 Result 变体遵循相同模式:

kotlin 复制代码
suspend fun Purchases.awaitPurchaseResult(
    purchaseParams: PurchaseParams
): Result<PurchaseResult> {
    return suspendCoroutine { continuation ->
        purchase(
            purchaseParams = purchaseParams,
            callback = purchaseCompletedCallback(
                onSuccess = { storeTransaction, customerInfo ->
                    continuation.resume(
                        Result.success(PurchaseResult(storeTransaction, customerInfo))
                    )
                },
                onError = { purchasesError, userCancelled ->
                    continuation.resume(
                        Result.failure(
                            PurchasesTransactionException(purchasesError, userCancelled)
                        )
                    )
                },
            ),
        )
    }
}

错误信息并未丢失:PurchasesTransactionException 仍包含在 Result.failure 中,因此需要 userCancelled 标志的调用者可以正常访问:

kotlin 复制代码
val result = Purchases.sharedInstance.awaitPurchaseResult(params)
result.onFailure { error ->
    if (error is PurchasesTransactionException && error.userCancelled) {
        return
    }
    showError(error.message)
}

这种"双 API"设计(抛出异常的挂起函数 + 返回 Result 的挂起函数)让使用者可以自主选择风格,而非强制使用某一种------SDK 不偏袒任何一种方式,而是同时支持两者。

Lambda 便捷层:桥接之前的桥接

在原生回调接口 API 和挂起函数桥接层之间,还有一层值得分析的中间层。RevenueCat 提供了接收 lambda 对而非类型化回调对象的扩展函数:

kotlin 复制代码
fun Purchases.getOfferingsWith(
    onError: (error: PurchasesError) -> Unit = ON_ERROR_STUB,
    onSuccess: (offerings: Offerings) -> Unit,
) {
    getOfferings(receiveOfferingsCallback(onSuccess, onError))
}

这是一种"两步桥接"设计:

  1. lambda 扩展函数(getOfferingsWith)将 lambda 转换为类型化回调;
  2. 挂起扩展函数(awaitOfferings)将 lambda 扩展函数转换为协程。

每层只负责一件事,职责清晰。

请注意默认的错误处理器:

kotlin 复制代码
internal val ON_ERROR_STUB: (error: PurchasesError) -> Unit = {}

这允许不关心错误的调用者省略错误处理器------这在"只执行不关心结果"的场景中很有用,但需谨慎使用,因为静默吞掉错误是常见的 bug 根源。

购买相关的扩展函数有自己的默认处理器:

kotlin 复制代码
internal val ON_PURCHASE_ERROR_STUB: (error: PurchasesError, userCancelled: Boolean) -> Unit =
    { _, _ -> }

两种不同的回调结构对应两个独立的默认处理器,每个处理器都精确匹配其回调所需的 lambda 签名。

实际应用:直接桥接 Google Play 计费 API

suspendCoroutine 模式适用于任何基于回调的 Android API。以下是桥接 Google Play 计费库确认 API 的示例:

kotlin 复制代码
suspend fun BillingClient.awaitAcknowledge(purchaseToken: String): Boolean {
    return suspendCoroutine { continuation ->
        val params = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(purchaseToken)
            .build()
        acknowledgePurchase(params) { billingResult ->
            continuation.resume(
                billingResult.responseCode == BillingClient.BillingResponseCode.OK
            )
        }
    }
}

消耗 API 的桥接结构完全相同:

kotlin 复制代码
suspend fun BillingClient.awaitConsume(purchaseToken: String): Boolean {
    return suspendCoroutine { continuation ->
        val params = ConsumeParams.newBuilder()
            .setPurchaseToken(purchaseToken)
            .build()
        consumeAsync(params) { billingResult, _ ->
            continuation.resume(
                billingResult.responseCode == BillingClient.BillingResponseCode.OK
            )
        }
    }
}

即使是包含两个独立回调方法(onBillingSetupFinishedonBillingServiceDisconnected)的计费客户端连接,也能完美桥接:

kotlin 复制代码
suspend fun BillingClient.awaitConnect(): Boolean {
    if (isReady) return true
    return suspendCoroutine { continuation ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                continuation.resume(
                    billingResult.responseCode == BillingClient.BillingResponseCode.OK
                )
            }
            override fun onBillingServiceDisconnected() {
                // 该方法在初始化完成后连接断开时调用,而非初始化过程中
                // continuation 已通过 onBillingSetupFinished 恢复执行
            }
        })
    }
}

onBillingServiceDisconnected 方法在初始化成功后连接断开时触发,而非初始化过程中的备选路径------这是一个重要的细节。如果这两个方法都可能在初始化过程中触发,您就需要额外的状态跟踪来确保只调用一次 resume。

suspendCoroutine vs suspendCancellableCoroutine

Kotlin 还提供了更高级的变体:suspendCancellableCoroutine。其区别在于,它会提供一个响应协程取消操作的 CancellableContinuation 对象。

kotlin 复制代码
suspend fun BillingClient.awaitConnectCancellable(): Boolean {
    if (isReady) return true
    return suspendCancellableCoroutine { continuation ->
        val listener = object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (continuation.isActive) {
                    continuation.resume(
                        billingResult.responseCode == BillingClient.BillingResponseCode.OK
                    )
                }
            }
            override fun onBillingServiceDisconnected() {}
        }
        startConnection(listener)
        continuation.invokeOnCancellation {
            // 清理操作:如果协程取消,关闭计费连接
            endConnection()
        }
    }
}

该如何选择?

  • 使用 suspendCoroutine :当底层操作无法取消,或取消时无需清理资源时。大多数 SDK 回调都属于此类------一旦调用 getOfferings,就无法"撤销"调用,让回调自然完成的成本远低于处理取消的复杂度。
  • 使用 suspendCancellableCoroutine:当底层操作占用了需要在取消时释放的资源时。长连接、数据流或分配了昂贵资源的操作,都能从取消支持中获益。

RevenueCat 的公共协程扩展函数使用 suspendCoroutine,因为其底层 SDK 调用都是短生命周期的网络请求------回调会快速触发,即使协程已取消,让回调完成的成本也可以忽略不计,无需引入取消处理的复杂度。

模式扩展:不止于计费 API

该桥接模式并非只适用于计费 API,而是适用于所有基于回调的 Android API。以下是几个示例:

示例 1:FusedLocationProviderClient

通过 LocationCallback 提供位置信息的 FusedLocationProviderClient:

kotlin 复制代码
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? {
    return suspendCoroutine { continuation ->
        lastLocation
            .addOnSuccessListener { location -> continuation.resume(location) }
            .addOnFailureListener { e -> continuation.resumeWithException(e) }
    }
}

示例 2:SharedPreferences

使用 OnSharedPreferenceChangeListener 监听变更的 SharedPreferences:

kotlin 复制代码
suspend fun SharedPreferences.awaitChange(key: String): String? {
    return suspendCancellableCoroutine { continuation ->
        val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, changedKey ->
            if (changedKey == key) {
                continuation.resume(prefs.getString(key, null))
            }
        }
        registerOnSharedPreferenceChangeListener(listener)
        continuation.invokeOnCancellation {
            unregisterOnSharedPreferenceChangeListener(listener)
        }
    }
}

核心结构始终不变:用 suspendCoroutine 包装、注册回调、回调触发时 resume。变化的只是回调的形式、需要捕获的值的数量,以及是否需要处理取消清理。

常见错误及规避方法

错误 1:未调用 resume

如果存在回调永远不会触发的代码路径,协程会永久挂起。这种问题在包含多个回调方法的连接监听器中尤为常见:

kotlin 复制代码
// 危险:如果 onBillingServiceDisconnected 在 onBillingSetupFinished 之前触发,
// 协程将永远无法恢复
suspend fun BillingClient.awaitConnectBroken(): Boolean {
    return suspendCoroutine { continuation ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                continuation.resume(true)
            }
            override fun onBillingServiceDisconnected() {
                // 漏洞:在极少数边缘场景下,该方法可能先触发,
                // 导致 onBillingSetupFinished 永远不会执行
            }
        })
    }
}

修复方案 :确保所有回调路径都能走到 resume 调用,或使用 suspendCancellableCoroutine 配合超时机制:

kotlin 复制代码
suspend fun BillingClient.awaitConnectSafe(): Boolean {
    return withTimeout(5_000) {
        suspendCancellableCoroutine { continuation ->
            startConnection(object : BillingClientStateListener {
                override fun onBillingSetupFinished(billingResult: BillingResult) {
                    if (continuation.isActive) {
                        continuation.resume(
                            billingResult.responseCode == BillingClient.BillingResponseCode.OK
                        )
                    }
                }
                override fun onBillingServiceDisconnected() {
                    if (continuation.isActive) {
                        continuation.resume(false)
                    }
                }
            })
        }
    }
}

错误 2:多次调用 resume

如果回调可能多次触发(例如持续发送位置更新的位置监听器),使用 suspendCoroutine 会导致崩溃(第二次 resume 会抛出 IllegalStateException)。

修复方案 :对于重复触发的回调,应使用 callbackFlow 而非 suspendCoroutine

kotlin 复制代码
fun FusedLocationProviderClient.locationUpdates(
    request: LocationRequest
): Flow<Location> = callbackFlow {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
            result.lastLocation?.let { trySend(it) }
        }
    }
    requestLocationUpdates(request, callback, Looper.getMainLooper())
    awaitClose { removeLocationUpdates(callback) }
}

总结:suspendCoroutine 适用于一次性回调,callbackFlow 适用于流式回调。选错原语会导致崩溃或挂起。

错误 3:丢失结构化错误信息

将所有错误简单包装为 Exception(error.message) 会丢弃调用者所需的结构化错误数据:

kotlin 复制代码
// 错误示例:调用者无法区分错误类型
onError = { error ->
    continuation.resumeWithException(Exception(error.message))
}
// 正确示例:调用者可以匹配错误码
onError = { error ->
    continuation.resumeWithException(PurchasesException(error))
}

虽然定义类型化异常类需要额外工作,但每当调用者需要区分处理不同错误场景时,这些工作都会体现价值。

总结

本文详细剖析了 suspendCoroutine 桥接模式:从最简单的单值回调,到 RevenueCat Android SDK 中使用的生产级模式(多值包装类、类型化异常体系、回调工厂函数、异常/Result 双 API 风格)。核心模式始终是三步:挂起协程、注册回调、确保只 resume 一次。

理解这种桥接方式是每位 Android 开发者的实用技能------您日常使用的大多数平台 API(计费、位置、蓝牙、相机)都是围绕回调设计的。将它们转换为挂起函数,能让代码更具顺序性、可测试性和组合性。本文涵盖的模式(尤其是回调工厂层、类型化异常体系和 Result<T> 变体)可直接复用到您的项目中。

无论您是桥接 Google Play 计费库的 PurchasesUpdatedListener、封装遗留的网络库,还是为自己的 SDK 构建友好的挂起函数 API,这些模式都能为您提供简洁、正确的协程集成基础。

原文链接

相关推荐
zhangphil8 小时前
Kotlin高阶函数作为参数与Java普通接口interface等效性
java·kotlin
Kapaseker9 小时前
千万不要以为你搞懂了 var 和 val
android·kotlin
帅次9 小时前
WebView 并发初始化竞争风险分析
android·xml·flutter·kotlin·webview·androidx·dalvik
hnlgzb1 天前
目前编写安卓app的话有哪几种设计模式?
android·设计模式·kotlin·android jetpack·compose
黄林晴1 天前
Kotlin 2.4.0 正式发布,快来看看有哪些更新
android·kotlin
Kapaseker1 天前
Android 吐槽大会:音频焦点反人类
android·kotlin
进击的cc2 天前
Android Kotlin:委托属性深度解析
android·kotlin
进击的cc2 天前
Android Kotlin:Kotlin数据类与密封类
android·kotlin
博.闻广见2 天前
19-Compose开发-LazyColumn
kotlin·composer