處理 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) }
        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)

問題

此嚴重錯誤表示用戶端應用程式透過 BillingClient 連至 Google Play 商店服務的連線已中斷。

可能的解決方法

為了避免發生這個錯誤,請一律透過 BillingClient.isReady() 呼叫,先檢查與 Google Play 服務的連線,再使用 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_NOT_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 帳款服務程式庫的呼叫。此外,請查看偵錯訊息,進一步瞭解錯誤內容。