处理 BillingResult 响应代码

当调用 Play 结算库触发某个操作时,库会返回 BillingResult 响应,以告知开发者结果。例如,如果您使用 queryProductDetailsAsync 获取用户的可用优惠,则响应代码要么包含 OK 代码并提供正确的 ProductDetails 对象,要么包含其他响应,指示无法提供 ProductDetails 对象的原因。

并非所有响应代码都是错误。BillingResponseCode 参考页面详细介绍了本指南中讨论的每个响应。一些不表示错误的响应代码示例包括

当响应代码确实表示错误时,原因有时是由于瞬时状况,因此可以恢复。当对 Play 结算库方法的调用返回指示可恢复状况的 BillingResponseCode 值时,您应该重试调用。在其他情况下,状况不被视为瞬时状况,因此不建议重试。

瞬时错误需要不同的重试策略,具体取决于各种因素,例如错误是否发生在用户会话期间(例如,用户正在进行购买流程时),或者错误是否发生在后台(例如,您在 onResume 期间查询用户现有购买项目时)。下面的重试策略部分提供了这些不同策略的示例,而可重试的 BillingResult 响应部分则推荐了每种响应代码的最佳策略。

除了响应代码之外,某些错误响应还包含用于调试和日志记录的消息。

重试策略

简单重试

在用户处于会话期间的情况下,最好实现一个简单的重试策略,以便错误尽可能少地干扰用户体验。在这种情况下,我们建议采用设置最大尝试次数作为退出条件的简单重试策略。

以下示例演示了在建立 BillingClient 连接时处理错误的简单重试策略

class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
  // Initialize the BillingClient.
  private val billingClient = BillingClient.newBuilder(context)
    .setListener(this)
    .enablePendingPurchases()
    .build()

  // Establish a connection to Google Play.
  fun startBillingConnection() {
    billingClient.startConnection(object : BillingClientStateListener {
      override fun onBillingSetupFinished(billingResult: BillingResult) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
          Log.d(TAG, "Billing response OK")
          // The BillingClient is ready. You can now query Products Purchases.
        } else {
          Log.e(TAG, billingResult.debugMessage)
          retryBillingServiceConnection()
        }
      }

      override fun onBillingServiceDisconnected() {
        Log.e(TAG, "GBPL Service disconnected")
        retryBillingServiceConnection()
      }
    })
  }

  // Billing connection retry logic. This is a simple max retry pattern
  private fun retryBillingServiceConnection() {
    val maxTries = 3
    var tries = 1
    var isConnectionEstablished = false
    do {
      try {
        billingClient.startConnection(object : BillingClientStateListener {
          override fun onBillingSetupFinished(billingResult: BillingResult) {
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
              isConnectionEstablished = true
              Log.d(TAG, "Billing connection retry succeeded.")
            } else {
              Log.e(
                TAG,
                "Billing connection retry failed: ${billingResult.debugMessage}"
              )
            }
          }
        })
      } catch (e: Exception) {
        e.message?.let { Log.e(TAG, it) }
      } finally {
        tries++
      }
    } while (tries <= maxTries && !isConnectionEstablished)
  }
  ...
}

指数回退重试

我们建议对在后台发生且不会影响用户会话期间用户体验的 Play 结算库操作使用指数回退。

例如,在确认新购买项目时实施此策略是合适的,因为此操作可以在后台进行,并且如果发生错误,确认不需要实时进行。

private fun acknowledge(purchaseToken: String): BillingResult {
  val params = AcknowledgePurchaseParams.newBuilder()
    .setPurchaseToken(purchaseToken)
    .build()
  var ackResult = BillingResult()
  billingClient.acknowledgePurchase(params) { billingResult ->
    ackResult = billingResult
  }
  return ackResult
}

suspend fun acknowledgePurchase(purchaseToken: String) {

  val retryDelayMs = 2000L
  val retryFactor = 2
  val maxTries = 3

  withContext(Dispatchers.IO) {
    acknowledge(purchaseToken)
  }

  AcknowledgePurchaseResponseListener { acknowledgePurchaseResult ->
    val playBillingResponseCode =
    PlayBillingResponseCode(acknowledgePurchaseResult.responseCode)
    when (playBillingResponseCode) {
      BillingClient.BillingResponseCode.OK -> {
        Log.i(TAG, "Acknowledgement was successful")
      }
      BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> {
        // This is possibly related to a stale Play cache.
        // Querying purchases again.
        Log.d(TAG, "Acknowledgement failed with ITEM_NOT_OWNED")
        billingClient.queryPurchasesAsync(
          QueryPurchasesParams.newBuilder()
            .setProductType(BillingClient.ProductType.SUBS)
            .build()
        )
        { billingResult, purchaseList ->
          when (billingResult.responseCode) {
            BillingClient.BillingResponseCode.OK -> {
              purchaseList.forEach { purchase ->
                acknowledge(purchase.purchaseToken)
              }
            }
          }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.ERROR,
         BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
         BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
       ) -> {
        Log.d(
          TAG,
          "Acknowledgement failed, but can be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        runBlocking {
          exponentialRetry(
            maxTries = maxTries,
            initialDelay = retryDelayMs,
            retryFactor = retryFactor
          ) { acknowledge(purchaseToken) }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
         BillingClient.BillingResponseCode.DEVELOPER_ERROR,
         BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
       ) -> {
        Log.e(
          TAG,
          "Acknowledgement failed and cannot be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        throw Exception("Failed to acknowledge the purchase!")
      }
    }
  }
}

private suspend fun <T> exponentialRetry(
  maxTries: Int = Int.MAX_VALUE,
  initialDelay: Long = Long.MAX_VALUE,
  retryFactor: Int = Int.MAX_VALUE,
  block: suspend () -> T
): T? {
  var currentDelay = initialDelay
  var retryAttempt = 1
  do {
    runCatching {
      delay(currentDelay)
      block()
    }
      .onSuccess {
        Log.d(TAG, "Retry succeeded")
        return@onSuccess;
      }
      .onFailure { throwable ->
        Log.e(
          TAG,
          "Retry Failed -- Cause: ${throwable.cause} -- Message: ${throwable.message}"
        )
      }
    currentDelay *= retryFactor
    retryAttempt++
  } while (retryAttempt < maxTries)

  return block() // last attempt
}

可重试的 BillingResult 响应

NETWORK_ERROR (错误代码 12)

问题

此错误表示设备与 Play 系统之间的网络连接存在问题。

可能的解决方案

要恢复,请使用简单重试或指数回退,具体取决于哪个操作触发了错误。

SERVICE_TIMEOUT (错误代码 -3)

问题

此错误表示请求在 Google Play 响应之前已达到最大超时。例如,这可能是由 Play 结算库调用请求的操作执行延迟引起的。

可能的解决方案

这通常是一个瞬时问题。请使用简单重试或指数回退策略重试请求,具体取决于哪个操作返回了错误。

与下面的 SERVICE_DISCONNECTED 不同,与 Google Play 结算服务的连接并未断开,您只需重试尝试的任何 Play 结算库操作即可。

SERVICE_DISCONNECTED (错误代码 -1)

问题

此致命错误表示客户端应用与 Google Play 商店服务通过 BillingClient 的连接已断开。

可能的解决方案

为了尽可能避免此错误,在使用 Play 结算库进行调用之前,请始终通过调用 BillingClient.isReady() 检查与 Google Play 服务的连接。

要尝试从 SERVICE_DISCONNECTED 恢复,您的客户端应用应尝试使用 BillingClient.startConnection 重新建立连接。

SERVICE_TIMEOUT 一样,使用简单重试或指数回退,具体取决于哪个操作触发了错误。

SERVICE_UNAVAILABLE (错误代码 2)

重要说明

从 Google Play 结算库 6.0.0 开始,SERVICE_UNAVAILABLE 不再针对网络问题返回。当结算服务不可用以及已弃用的 SERVICE_TIMEOUT 情况时,会返回此错误。

问题

此瞬时错误表示 Google Play 结算服务目前不可用。在大多数情况下,这意味着客户端设备与 Google Play 结算服务之间的任何地方都存在网络连接问题。

可能的解决方案

这通常是一个瞬时问题。请使用简单重试或指数回退策略重试请求,具体取决于哪个操作返回了错误。

SERVICE_DISCONNECTED 不同,与 Google Play 结算服务的连接并未断开,您需要重试正在尝试的任何操作。

BILLING_UNAVAILABLE (错误代码 3)

问题

此错误表示在购买过程中发生了用户结算错误。可能出现这种情况的示例包括

  • 用户设备上的 Play 商店应用已过时。
  • 用户所在的国家/地区不受支持。
  • 用户是企业用户,且其企业管理员已禁止用户进行购买。
  • Google Play 无法向用户的付款方式收费。例如,用户的信用卡可能已过期。

可能的解决方案

在这种情况下,自动重试不太可能有效。但是,如果用户解决了导致问题的情况,手动重试可能会有所帮助。例如,如果用户将其 Play 商店版本更新到受支持的版本,则手动重试初始操作可能会奏效。

如果此错误发生在用户不在会话期间,重试可能没有意义。当您由于购买流程收到 BILLING_UNAVAILABLE 错误时,用户很可能在购买过程中收到了 Google Play 的反馈,并且可能知道出了什么问题。在这种情况下,您可以显示一条错误消息,指明出了问题,并提供一个“再试一次”按钮,以便用户在解决问题后选择手动重试。

ERROR (错误代码 6)

问题

这是一个致命错误,表示 Google Play 本身内部存在问题。

可能的解决方案

有时导致 ERROR 的 Google Play 内部问题是瞬时问题,可以实现指数回退重试来缓解。当用户处于会话期间时,简单重试更可取。

ITEM_ALREADY_OWNED

问题

此响应表示 Google Play 用户已拥有他们尝试购买的订阅或一次性购买商品。在大多数情况下,这不是一个瞬时错误,除非是由过时的 Google Play 缓存引起。

可能的解决方案

为避免在原因不是缓存问题时发生此错误,请勿在用户已拥有商品时提供购买。确保在显示可购买商品时检查用户的权利,并相应地过滤用户可以购买的内容。当客户端应用因缓存问题收到此错误时,该错误会触发 Google Play 的缓存使用来自 Play 后端的最新数据进行更新。在这种情况下,错误后重试应该能解决这个特定的瞬时情况。在收到 ITEM_ALREADY_OWNED 后,调用 BillingClient.queryPurchasesAsync() 检查用户是否已获取该商品,如果尚未获取,则实施简单重试逻辑以重新尝试购买。

ITEM_NOT_OWNED

问题

此购买响应表示 Google Play 用户不拥有他们尝试替换、确认或消耗的订阅或一次性购买商品。在大多数情况下,这不是一个瞬时错误,除非是由 Google Play 的缓存进入过时状态引起。

可能的解决方案

当因缓存问题收到错误时,该错误会触发 Google Play 的缓存使用来自 Play 后端的最新数据进行更新。错误后使用简单重试策略应该能解决这个特定的瞬时情况。在收到 ITEM_ALREADY_OWNED 后,调用 BillingClient.queryPurchasesAsync() 检查用户是否已获取该商品。如果他们尚未获取,则使用简单重试逻辑以重新尝试购买。

不可重试的 BillingResult 响应

您无法使用重试逻辑从这些错误中恢复。

FEATURE_NOT_SUPPORTED

问题

此不可重试错误表示用户的设备不支持 Google Play 结算功能,可能是由于 Play 商店版本过旧。

例如,您的某些用户设备可能不支持应用内消息传递。

可能的缓解措施

在调用 Play 结算库之前,使用 BillingClient.isFeatureSupported() 检查功能支持。

when {
  billingClient.isReady -> {
    if (billingClient.isFeatureSupported(BillingClient.FeatureType.IN_APP_MESSAGING)) {
       // use feature
    }
  }
}

USER_CANCELED

问题

用户已点击退出结算流程 UI。

可能的解决方案

这仅用于提供信息,可以正常失败。

ITEM_UNAVAILABLE

问题

此用户无法购买 Google Play 结算订阅或一次性购买商品。

可能的缓解措施

确保您的应用按照建议通过 queryProductDetailsAsync 刷新商品详细信息。考虑您在 Play 管理中心配置中更改商品目录的频率,以便在需要时实施额外刷新。仅尝试在 Google Play 结算系统中销售通过 queryProductDetailsAsync 返回正确信息的商品。检查商品资格配置是否存在任何不一致之处。例如,您可能正在查询一个仅在用户尝试购买的区域以外的区域可用的商品。要可供购买,商品需要处于活跃状态,其应用需要已发布,并且其应用需要在用户的国家/地区可用。

有时,特别是在测试期间,商品配置一切正常,但用户仍然看到此错误。这可能是由于商品详细信息在 Google 服务器之间传播延迟所致。请稍后重试。

DEVELOPER_ERROR

问题

这是一个致命错误,表示您不正确地使用了 API。例如,向 BillingClient.launchBillingFlow 提供不正确的参数可能会导致此错误。

可能的解决方案

确保您正确使用了不同的 Play 结算库调用。此外,查看调试消息以获取有关错误的更多信息。