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,这些模式都能为您提供简洁、正确的协程集成基础。

原文链接

相关推荐
limuyang23 小时前
PDF Viewer KMP(基于 chrome 的 PDFium 内核)
android·kotlin
pengyu10 小时前
【Kotlin 协程修仙录 · 大乘境 · 初阶】 | 跳出三界:协程在 KMP 与后端开发中的跨平台之道
android·kotlin
XiaoLeisj15 小时前
Jetpack Compose 知识点
android·kotlin·android jetpack·compose
Kapaseker20 小时前
Compose SelectionState API — 主动控制文本选择
android·kotlin
小强闯江湖1 天前
不只是让 AI 写代码:ViewCompose 把 Android UI 做成了可编译、可渲染的闭环
android·人工智能·kotlin
bytebitx3 天前
MacOS 编译腾讯 Mars XLog 及使用(已16KB对齐)
kotlin·mac·apk
alexhilton3 天前
让架构边界变成可执行的测试
android·kotlin·android jetpack
数据治理自习室4 天前
AI 应用评测体系(GraphRAG)
android·大数据·人工智能·kotlin
名剑走天下5 天前
Android 面试题大全
android·kotlin
chjif5 天前
Android 设备管控开发实战:自定义 Launcher 如何只显示指定 App?
android·kotlin