BlogRight arrowTutorialRight Arrow安卓应用内购买,第2部分:使用Google Play Billing Library处理购买
BlogRight arrowTutorialRight Arrow安卓应用内购买,第2部分:使用Google Play Billing Library处理购买

安卓应用内购买,第2部分:使用Google Play Billing Library处理购买

安卓应用内购买,第2部分:使用Google Play Billing Library处理购买
Listen to the episode
安卓应用内购买,第2部分:使用Google Play Billing Library处理购买

在上一篇文章中,我们创建了一个包装器类来与Billing Library一起工作:

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
   }
}

让我们继续实现购买和改进我们的类。

⭐️ Download our guide on in-app techniques which will make in-app purchases in your app perfect

设计订阅屏

任何有应用内部购买功能的应用(APP)都有一个付费墙(paywall)屏幕。Google策略定义了此类屏中必须出现的最基本元素和说明文本。总结如下。在“付费墙”屏幕上,您必须明确说明您的订阅条件、费用和持续时间,并指定是否需要订阅才能使用应用程序。您还必须避免强迫用户执行任何额外操作来检查条件。 

这里,我们将使用一个简化的付费墙屏幕作为例子:

安卓示例付费墙

我们在付费墙屏幕上有以下元素:

  • 标题。
  • 设置为启动购买流程的按钮。这些按钮指导用户了解订阅选项的一般详细信息(例如标题和费用),这些信息是以当地货币显示的。
  • 指示文本。 
  • 可以恢复先前购买的按钮。这一元素对于任何具有订阅或非消耗性购买功能的应用程序来说都是必须的。

调整代码以显示产品细节

在我们的示例中有四个产品:

  • 两次自动续订服务(“premium_sub_month”和“premium_sub_year”);
  • 不可购买两次的产品,或非消耗品产品(“unlock_feature”);
  • 可以多次购买的产品,或者消耗性产品(“coin_pack_large”)。

为了简化这个例子,我们将使用一个Activity(通过在上一篇文章中所创建的BillingClientWrapper注入),以及一个带有固定数量购买按钮的布局。

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
   }
}

为了方便起见,我们将添加一个映射,其中密钥是产品的sku,该值为屏幕上对应的按钮。

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,
   )
}

让我们声明一个方法来在用户界面中显示产品,它将基于我们之前教程中介绍的逻辑。

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是一个已经格式化的字符串,用于指定账户的本地货币。它不需要任何额外格式。SkuDetails类对象的所有其他属性也完全本地化了。

开始购买过程

在购买过程中,我们必须从应用程序的主线程调用launchBillingFlow()方法。

为此,我们将向BillingClientWrapper添加一个purchase()方法。 

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

launchBillingFlow()方法没有回调。响应将返回到onPurchasesUpdated()方法。您还记得我们在前一篇文章中声明了这个,然后准备留着以后再用吗?那我们现在就到了需要的时候了。

每当用户与购买对话框的交互产生任何结果时,onPurchasesUpdated()方法就会被调用。这可以是一次成功的购买,也可以是用户关闭对话框导致的购买取消,在这种情况下,我们将得到BillingResponseCode.USER_CANCELED代码。或者,它也可以是任何其他可能的错误消息。

与上一篇文章中的OnQueryProductsListener接口类似,我们将在BillingClientWrapper类中声明一个OnPurchaseListener接口。通过该接口,我们将接收购买(一个Purchase类对象)消息或我们在上一篇指南中声明的错误消息。在下一个示例中,我们将讨论即使购买成功,Purchase类对象也可以为null的情况。

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

var onPurchaseListener: OnPurchaseListener? = null

接下来,我们将在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
           }
       })
   }
}

让我们添加一些逻辑到onPurchaseUpdated():

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

如果purchaseList不是空的,我们首先必须挑选出那些purchaseState等于PurchaseState.PURCHASED的条目,因为还有待定购买。如果是这种情况,那用户流到此结束。根据文件,我们应该在我们的服务器上验证购买。我们将在本系列接下来的文章中讨论这个问题。一旦服务器验证完成,您必须交付内容并让Google知道。如果没有后者,购买的产品将在三天内自动退款。非常有趣的一点是,这一政策是Google Play独有的——iOS没有任何类似的政策。您可以通过两种方式向用户传达您的内容:

如果处理一个消耗性产品,我们必须调用consumeAsync()方法。它在内部承认了购买行为,同时也让再次购买产品成为可能。这只能通过Billing Library实现。由于某些原因,Google Play Developer API没有提供任何在后端完成此操作的方法。很奇怪的是,与Google Play不同,App Store和AppGallery都分别通过App Store Connect和AppGallery Connect将产品配置为可消耗性。不过,也应该以一种明确的方式使用这样的AppGallery产品。

Start for free

Convenient in-app purchases infrastructure.

Adapty SDK has it all:
— server-side purchase validation,
— all side cases covered,
— simple implementation.

Start for free

让我们编写用于确认和消费的方法,以及两个版本的processPurchase()方法,以说明我们是否在运行自己的后端。

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

没有服务器验证:

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()
               }
           }
       }
   }
}

有服务器验证:

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
           }
       }
   }
}

在接下来的文章中,我们将更详细地介绍购买的服务器验证。

当然,我们也可以在客户端实现第二个示例中的确认。但是,如果您可以在后端做一些事情,那么您就应该这样做。如果确认或使用过程抛出任何错误,这些错误都不能被忽略。如果购买在收到PurchaseState.PURCHASED状态后3天内没有执行这些操作,就会被取消并退款。因此,如果在后端不可能做到这一点,并且在多次尝试之后仍然出现错误,那么有一个可靠的变通方法。您必须通过一些生命周期法获得用户当前的购买情况,如onStart()或onResume(),并继续尝试,希望用户将在连接互联网的3天内打开应用程序。:)

因此,BillingClientWrapper类的当前版本如下所示:

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

您可能想问,为什么无论用户是否已经购买这些产品,所有产品的按钮都是激活的。或者,如果您同时购买两种订阅,您可能会对发生的事情有一些疑问。第一个订阅将取代第二个订阅,还是它们会共存?如欲了解所有答案,请参阅即将发布的其他文章:)

Further reading

Adapty July Updates: retention and conversion charts
Adapty July Updates: retention and conversion charts
August 8, 2022
5 min read
How to choose money-making keywords for your app
How to choose money-making keywords for your app
March 31, 2022
10 min read
Dark patterns and tricks in mobile apps
Dark patterns and tricks in mobile apps
June 21, 2021
5 min read