Report: State of in-app subscriptions in the US 2023 Get a report

Android In-App-Käufe, Teil 2: Verarbeitung von Käufen mit der Google Play Billing Library

Vlad Guriev

Updated: März 20, 2023

Content

62fdf1c4519df62803e8fafe jp android tutorial 1 configuration 2

Im vorherigen Artikel haben wir eine Wrapper-Klasse erstellt, um mit der Billing Library zu arbeiten:

import android.content.Context
import com.android.billingclient.api.*
class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }
   class Error(val responseCode: Int, val debugMessage: String)
   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()
   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "some_inapp")
       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }
   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }
   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }
           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }
   override fun onPurchasesUpdated(billingResult: BillingResult, purchaseList: MutableList<Purchase>?) {
       // here come callbacks about new purchases
   }
}

Lassen Sie uns nun mit der Kaufimplementierung fortfahren und unsere Klasse verbessern.

Das Design eines Abonnement-Screens

Jede App mit In-App-Käufen hat einen Paywall-Screen. Es gelten Google-Richtlinien, die das Minimum an Elementen und Anleitungstexten definieren und in solchen Screens ersichtlich sein müssen. Wir fassen uns kurz und stellen sie Ihnen zusammenfassend vor. Im Paywall-Screen müssen Sie explizit Ihre Abonnement-Bedingungen erläutern. Dazu gehören die Kosten, die Dauer und ob das Abonnement notwendig ist, um die App nutzen zu können. Sie müssen vermeiden, dass Nutzer zusätzliche Schritte unternehmen müssen, um jene Bedingungen einsehen zu können. Sie müssen direkt ersichtlich sein. 

Hier nutzen wir einen vereinfachten Paywall-Screen als Beispiel.

631abc67c79cf6614ff8de49 3321
Android Sample Paywall

Wir haben die folgenden Elemente auf unserem Paywall-Screen:

  • Eine Überschrift.
  • Buttons, um den Kaufprozess zu starten. Diese Buttons führen den Nutzer durch die Einzelheiten des Abonnements, damit sie unter anderem die Kosten erfahren, die in der lokalen Währung angezeigt werden.
  • Anleitungstext. 
  • Einen Button zum Wiederherstellen der zuvor getätigten Käufe. Dieses Element ist ein Must-Have für jede App, die Abonnements oder nicht-verbrauchbare Käufe anbietet.

Optimierung des Codes zur Darstellung der Produktdetails

In unserem Beispiel gibt es vier Produkte:

  • Zwei automatisch verlängerbare Abonnements („premium_sub_month“ and „premium_sub_year“);
  • Ein Produkt, das nicht mehrfach gekauft werden kann (ein nicht-verbrauchbares Produkt) (“unlock_feature”);
  • Ein Produkt, dass mehrfach gekauft werden kann (ein verbrauchbares Produkt) (“coin_pack_large”).

Um das Beispiel zu vereinfachen, nutzen wir eine Aktivität, die wir in den BillingClientWrapper des vorherigen Artikels injizieren. Das Layout umfasst eine feste Anzahl von Kauf-Buttons.

class PaywallActivity: BaseActivity() {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       displayProducts() //to be declared below
   }
}

Praktischerweise fügen wir eine Map hinzu, in der die SKU des Produkts der Key ist und der Wert der jeweilige Button auf dem Screen ist.

private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
   mapOf(
       "premium_sub_month" to monthlySubButton,
       "premium_sub_year" to yearlySubButton,
       "coin_pack_large" to coinPackLargeButton,
       "unlock_feature" to unlockFeatureButton,
   )
}

Lassen Sie uns eine Methode deklarieren, um die Produkte im User Interface darzustellen. Sie basiert auf der Logik, die wir in unserem vorherigen Tutorial eingeführt haben.

private fun displayProducts() {
   billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
       override fun onSuccess(products: List<SkuDetails>>) {
           products.forEach { product ->
               purchaseButtonsMap[product.sku]?.apply {
                   text = "${product.description} for ${product.price}"
                   setOnClickListener {
                       billingClientWrapper.purchase(this@PaywallActivity, product) //will be declared below
                   }
               }
           }
       }

       override fun onFailure(error: BillingClientWrapper.Error) {
           //handle error
       }
   })
}

product.price ist ein bereits formatierter String, der die lokale Währung für ein Konto bestimmt. Er muss nicht weiter formatiert werden. Alle anderen Eigenschaften des SkuDetails Class Object sind ebenfalls lokalisiert.

Der Beginn des Kaufvorgangs

Um den Kauf zu verarbeiten, müssen wir die launchBillingFlow() Methode aus dem Main Thread der App ausführen.

Wir fügen dafür eine purchase() Methode im BillingClientWrapper ein. 

fun purchase(activity: Activity, product: SkuDetails) {
   onConnected {
       activity.runOnUiThread {
           billingClient.launchBillingFlow(
               activity,
               BillingFlowParams.newBuilder().setSkuDetails(product).build()
           )
       }
   }
}

Die launchBillingFlow() Methode hat kein Callback. Die Antwort kehr zurück zur onPurchasesUpdated() Methode. Erinnern Sie sich, wie wir sie im vorherigen Artikel deklariert und sie für später gespeichert haben? Jetzt brauchen wir sie.

Die onPurchasesUpdated() Methode wird genutzt, sobald es ein Ergebnis der Nutzerinteraktion mit dem Kaufdialog gibt. Dies kann ein erfolgreicher Kauf sein, aber auch eine Kaufstornierung, wenn der Nutzer des Dialogfenster schließt. In diesem Fall erhalten wir den BillingResponseCode.USER_CANCELED Code. Alternativ kann es auch jede andere mögliche Fehlernachricht sein.

Wie das OnQueryProductsListener Interface aus dem vorherigen Artikel deklarieren wir das OnPurchaseListener Interface in der BillingClientWrapper Klasse. Über dieses Interface erhalten wir entweder den Kauf (ein Purchase Class Object) oder eine Fehlernachricht, die wir in der vorherigen Anleitung deklariert haben. Als nächstes besprechen wir den Fall, in dem ein Purchase Class Objekt null sein kann, obwohl der Kauf erfolgreich war.

interface OnPurchaseListener {
   fun onPurchaseSuccess(purchase: Purchase?)
   fun onPurchaseFailure(error: Error)
}

var onPurchaseListener: OnPurchaseListener? = null

Nun implementieren wir es in der PaywallActivity:

class PaywallActivity: BaseActivity(), BillingClientWrapper.OnPurchaseListener {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
       mapOf(
           "premium_sub_month" to monthlySubButton,
           "premium_sub_year" to yearlySubButton,
           "coin_pack_large" to coinPackLargeButton,
           "unlock_feature" to unlockFeatureButton,
       )
   }

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       billingClientWrapper.onPurchaseListener = this

       displayProducts()
   }

   override fun onPurchaseSuccess(purchase: Purchase?) {
       //handle successful purchase
   }

   override fun onPurchaseFailure(error: BillingClientWrapper.Error) {
       //handle error or user cancelation
   }

   private fun displayProducts() {
       billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
           override fun onSuccess(products: List<SkuDetails>) {
               products.forEach { product ->
                   purchaseButtonsMap[product.sku]?.apply {
                       text = "${product.description} for ${product.price}"
                       setOnClickListener {
                           billingClientWrapper.purchase(this@PaywallActivity, product)
                       }
                   }
               }
           }

           override fun onFailure(error: BillingClientWrapper.Error) {
               //handle error
           }
       })
   }
}

Subscribe to Adapty newsletter

Get fresh paywall ideas, subscription insights, and mobile app news every month!

Lassen Sie uns onPurchaseUpdated() ein wenig Logik hinzufügen:

override fun onPurchasesUpdated(
   billingResult: BillingResult,
   purchaseList: MutableList<Purchase>?
) {
   when (billingResult.responseCode) {
       BillingClient.BillingResponseCode.OK -> {
           if (purchaseList == null) {
               //to be discussed in the next article
               onPurchaseListener?.onPurchaseSuccess(null)
               return
           }

           purchaseList.forEach(::processPurchase) //to be declared below
       }
       else -> {
           //error occured or user canceled
           onPurchaseListener?.onPurchaseFailure(
               BillingClientWrapper.Error(
                   billingResult.responseCode,
                   billingResult.debugMessage
               )
           )
       }
   }
}

Falls purchaseList nicht leer ist, müssen wir zunächst die Items raussuchen, deren purchaseState gleich PurchaseState.PURCHASED ist, da es auch ausstehende Käufe igbt. In diesem Fall endet der User Flow hier. Laut der Dokumente sollten wir den Kauf auf unserem Server überprüfen. Dies behandeln wir in den nächsten Artikeln dieser Serie. Sobald die Serverüberprüfung abgeschlossen ist, müssen Sie den Content abliefern und Google davon in Kenntnis setzen. Tun Sie das nicht, wird der Kauf nach drei Tagen automatisch erstattet. Dies gilt übrigens nur für Google Play — iOS kennt keine Richtlinien dieser Art. Sie haben zwei Möglichkeiten, die Lieferung des Contents an Ihre Nutzer zu bestätigen:

Handelt es sich um ein verbrauchbares Produkt, müssen wir stattdessen die consumeAsync() Methode nutzen. Sie bestätigt den Kauf im Hintergrund und ermöglicht dem Nutzer, das Produkt erneut zu kaufen. Dies kann nur mit der Billing Library erfolgen. Aus irgendeinem Grund bietet die Google Play Developer API keine andere Lösung für das Backend. Im Gegensatz zu Google Play gibt es sowohl im App Store als auch in der AppGallery die Möglichkeit, das Produkt als verbrauchbares Produkt per App Store Connect und AppGallery Connect zu konfigurieren. AppGallery Produkte sollten jedoch auf explizite Weise verbraucht werden.

Lassen Sie uns Methoden für die Bestätigung und den Verbrauch schreiben, aber auch zwei Versionen der processPurchase() Methode für die Fälle, dass wir unser eigenes Backend laufen lassen oder nicht.

private fun acknowledgePurchase(
   purchase: Purchase,
   callback: AcknowledgePurchaseResponseListener
) {
   onConnected {
       billingClient.acknowledgePurchase(
           AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
               .build(),
           callback::onAcknowledgePurchaseResponse
       )
   }
}

private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
   onConnected {
       billingClient.consumeAsync(
           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
       ) { billingResult, purchaseToken ->
           callback.onConsumeResponse(billingResult, purchaseToken)
       }
   }
}

Ohne Serverüberprüfung:

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       onPurchaseListener?.onPurchaseSuccess(purchase)

       if (purchase.skus.firstOrNull() == "coin_pack_large") {
           //consuming our only consumable product
           consumePurchase(purchase) { billingResult, purchaseToken ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to consume again in onResume()
               }
           }
       } else if (!purchase.isAcknowledged) {
           acknowledgePurchase(purchase) { billingResult ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to acknowledge again in onResume()
               }
           }
       }
   }
}

Mit Serverüberprüfung:

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       api.verifyPurchase(purchase.purchaseToken) { error ->
           if (error != null) {
               onPurchaseListener?.onPurchaseSuccess(purchase)

               if (purchase.skus.firstOrNull() == "coin_pack_large") {
                   //consuming our only consumable product
                   billingClient.consumeAsync(
                       ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                           .build()
                   ) { billingResult, purchaseToken ->
                       if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                           //implement retry logic or try to consume again in onResume()
                       }
                   }
               }
           } else {
               //handle verification error
           }
       }
   }
}

In den nächsten Artikeln besprechen wir die Serverüberprüfung für Käufe im Detail.

Natürlich könnten wir die Bestätigung im zweiten Beispiel auch auf der Client-Seite implementieren. Wenn Sie etwas im Backend lösen können, sollten Sie dies auch so tun. Falls es bei der Bestätigung oder dem Verbrauch zu Fehlern kommt, können diese nicht ignoriert werden. Werden sie nicht innerhalb von 3 Tagen nach dem Erhalt des Kaufs laut PurchaseState.PURCHASED Status durchgeführt, wird der Kauf storniert und erstattet. Wenn Sie es nicht über das Backend lösen können und ständig Fehlermeldungen erhalten, gibt es also eine zuverlässige Abhilfe. Sie müssen die aktuellen Käufe des Nutzers über eine Lifecycle-Methode wie onStart() oder onResume() erhalten und es erneut versuchen, um zu hoffen, dass der Nutzer die App innerhalb von 3 Tagen nutzt, während er mit dem Internet verbunden ist.

Die aktuelle Version der BillingClientWrapper Klass sieht so aus:

import android.app.Activity
import android.content.Context
import com.android.billingclient.api.*

class BillingClientWrapper(context: Context, private val api: Api) : PurchasesUpdatedListener {

   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }

   interface OnPurchaseListener {
       fun onPurchaseSuccess(purchase: Purchase?)
       fun onPurchaseFailure(error: Error)
   }

   var onPurchaseListener: OnPurchaseListener? = null

   class Error(val responseCode: Int, val debugMessage: String)

   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()

   fun purchase(activity: Activity, product: SkuDetails) {
       onConnected {
           activity.runOnUiThread {
               billingClient.launchBillingFlow(
                   activity,
                   BillingFlowParams.newBuilder().setSkuDetails(product).build()
               )
           }
       }
   }

   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "coin_pack_large", "unlock_feature")

       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }

   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }

   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }

           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }

   override fun onPurchasesUpdated(
       billingResult: BillingResult,
       purchaseList: MutableList<Purchase>?
   ) {
       when (billingResult.responseCode) {
           BillingClient.BillingResponseCode.OK -> {
               if (purchaseList == null) {
                   //to be discussed in the next article
                   onPurchaseListener?.onPurchaseSuccess(null)
                   return
               }

               purchaseList.forEach(::processPurchase)
           }
           else -> {
               //error occured or user canceled
               onPurchaseListener?.onPurchaseFailure(
                   BillingClientWrapper.Error(
                       billingResult.responseCode,
                       billingResult.debugMessage
                   )
               )
           }
       }
   }

   private fun processPurchase(purchase: Purchase) {
       if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
           api.verifyPurchase(purchase.purchaseToken) { error ->
               if (error != null) {
                   onPurchaseListener?.onPurchaseSuccess(purchase)

                   if (purchase.skus.firstOrNull() == "coin_pack_large") {
                       //consuming our only consumable product
                       billingClient.consumeAsync(
                           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                               .build()
                       ) { billingResult, purchaseToken ->
                           if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                               //implement retry logic or try to consume again in onResume()
                           }
                       }
                   }
               } else {
                   //handle verification error
               }
           }
       }
   }

   private fun acknowledgePurchase(
       purchase: Purchase,
       callback: AcknowledgePurchaseResponseListener
   ) {
       onConnected {
           billingClient.acknowledgePurchase(
               AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                   .build(),
               callback::onAcknowledgePurchaseResponse
           )
       }
   }

   private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
       onConnected {
           billingClient.consumeAsync(
               ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
           ) { billingResult, purchaseToken ->
               callback.onConsumeResponse(billingResult, purchaseToken)
           }
       }
   }
}

Vermutlich fragen Sie sich, warum die Buttons für alle Produkte aktiv sind, egal ob der Nutzer sie bereits gekauft hat oder nicht. Vielleicht haben Sie auch Fragen dazu, was passiert, wenn man beide Abonnements kauft. Wird das erste Abonnement das zweite ersetzen? Oder existieren beide nebeneinander? In den folgenden Artikeln kommen wir auf diese Fragen zu sprechen.