BlogRight arrowTutorialRight ArrowMua trong ứng dụng Android, phần 2: xử lý giao dịch mua với Google Play Billing Library
BlogRight arrowTutorialRight ArrowMua trong ứng dụng Android, phần 2: xử lý giao dịch mua với Google Play Billing Library

Mua trong ứng dụng Android, phần 2: xử lý giao dịch mua với Google Play Billing Library

Mua trong ứng dụng Android, phần 2: xử lý giao dịch mua với Google Play Billing Library
Listen to the episode
Mua trong ứng dụng Android, phần 2: xử lý giao dịch mua với Google Play Billing Library

Trong bài viết trước, chúng tôi đã tạo một lớp wrapper để làm việc với 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
   }
}

Hãy tiếp tục triển khai giao dịch mua và cải thiện lớp của chúng ta.

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

Thiết kế màn hình gói đăng ký

Bất kỳ ứng dụng nào có tính năng mua trong ứng dụng đều có màn hình tường phí (paywall). Chính sách Google nêu rõ những yếu tố tối thiểu và văn bản hướng dẫn cần thể hiện trong màn hình như vậy. Sau đây là tóm tắt. Trên màn hình tường phí, bạn phải nêu rõ điều kiện đăng ký, giá, thời gian cũng như nêu cụ thể rằng có cần thiết phải đăng ký để sử dụng ứng dụng không. Bạn phải tránh buộc người dùng thực hiện thêm bất kỳ hành động nào khác để xem xét điều kiện. 

Ở đây, chúng ta sẽ dùng một màn hình tường phí đơn giản làm ví dụ:

Tường phí Android mẫu

Chúng tôi trình bày các yếu tố sau đây trên màn hình tường phí:

  • Tiêu đề.
  • Nút được cài đặt để bắt đầu quy trình mua. Những nút này hướng dẫn người dùng về thông tin chi tiết chung của tuỳ chọn đăng ký, chẳng hạn như tiêu đề và giá, được hiển thị bằng đơn vị tiền tệ địa phương.
  • Văn bản hướng dẫn. 
  • Nút để khôi phục giao dịch mua đã thực hiện trước đó. Đây là yếu tố cần thiết cho bất kỳ ứng dụng nào có gói đăng ký hoặc giao dịch mua không tiêu hao.

Chỉnh sửa mã để hiển thị thông tin chi tiết về sản phẩm

Có bốn sản phẩm trong ví dụ của chúng tôi:

  • Hai gói đăng ký tự động gia hạn ("premium_sub_month" và "premium_sub_year");
  • Sản phẩm không thể mua lần thứ hai hay là sản phẩm không tiêu hao (“unlock_feature”);
  • Sản phẩm có thể mua lại nhiều lần hay là sản phẩm tiêu hao (“coin_pack_large”).

Để đơn giản hoá ví dụ, chúng tôi sẽ chèn BillingClientWrapper đã tạo trong bài viết trước vào một Activity và sử dụng Activity này cùng với một bố cục có số lượng nút mua cố định.

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

Để thuận tiện, chúng tôi sẽ thêm bản đồ có khoá là sku của sản phẩm và giá trị là nút tương ứng trên màn hình.

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

Hãy khai báo một phương thức để hiển thị sản phẩm trên UI. Phương thức sẽ dựa trên logic được giới thiệu trong hướng dẫn trước của chúng tôi.

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  là một chuỗi đã được định dạng để chỉ định đơn vị tiền tệ địa phương cho tài khoản. Chuỗi này không cần định dạng thêm. Tất cả thuộc tính khác của đối tượng lớp SkuDetails cũng được địa phương hoá hoàn toàn.

Bắt đầu quy trình mua

Để xử lý giao dịch mua, chúng ta phải gọi phương thức launchBillingFlow() từ luồng chính của ứng dụng.

Chúng ta sẽ thêm phương thức purchase() vào BillingClientWrapper để thực hiện việc đó. 

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

Phương thức launchBillingFlow() không có hàm callback. Phản hồi sẽ trở về phương thức onPurchasesUpdated(). Bạn có nhớ rằng chúng tôi đã khai báo phương thức này trong bài viết trước và giữ lại để dùng sau? Chúng ta sẽ cần dùng ngay bây giờ.

Phương thức onPurchasesUpdated() được gọi ra khi có kết quả từ tương tác giữa người dùng với hộp thoại mua hàng. Kết quả có thể là mua hàng thành công hoặc huỷ giao dịch mua do người dùng đóng hộp thoại, trong trường hợp này chúng ta sẽ nhận mã BillingResponseCode.USER_CANCELED. Ngoài ra, kết quả có thể là bất kỳ thông báo lỗi nào khác.

Tương tự như giao diện OnQueryProductsListener trong bài viết trước, chúng tôi sẽ khai báo một giao diện OnPurchaseListener trong lớp BillingClientWrapper. Qua giao diện đó, chúng ta sẽ nhận được giao dịch mua (một đối tượng lớp Purchase) hoặc thông báo lỗi do chúng tôi khai báo trong hướng dẫn trước. Trong phần tới, chúng ta sẽ thảo luận trường hợp đối tượng lớp Purchase có thể bằng không kể cả khi đơn hàng thành công.

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

var onPurchaseListener: OnPurchaseListener? = null

Tiếp theo, chúng ta sẽ triển khai nó trong 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
           }
       })
   }
}

Hãy thêm logic vào 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
               )
           )
       }
   }
}

Nếu purchaseList không trống, đầu tiên chúng ta sẽ phải chọn mục có purchaseState tương đương PurchaseState.PURCHASED, do cũng có giao dịch mua đang chờ xử lý. Trong trường hợp này thì luồng người dùng dừng ở đây. Theo tài liệu, sau đó chúng ta cần phải xác minh giao dịch mua trên máy chủ của chúng ta. Chúng ta sẽ nói về công việc này trong bài viết tiếp theo của loạt bài này. Sau khi hoàn thành xác minh máy chủ, bạn phải giao nội dung và thông báo cho Google biết. Nếu không thông báo, giao dịch mua sẽ tự động được hoàn tiền trong ba ngày. Điều thú vị là chỉ Google Play mới có chính sách này — iOS không áp dụng bất kỳ chính sách tương tự nào. Bạn có hai cách để biết được đã giao nội dung cho người dùng:

Thay vào đó, nếu xử lý sản phẩm tiêu hao, chúng ta phải gọi phương thức consumeAsync(). Phương thức này công nhận giao dịch mua trong ứng dụng trong khi vẫn cho phép mua sản phẩm lần nữa. Chỉ có thể thực hiện với Billing Library. Vì một số lý do, Google Play Developer API không cung cấp bất kỳ hình thức nào để thực hiện phương thức này bên phía backend. Thật kỳ lạ vì không như Google Play, cả App Store và AppGallery đều dùng App Store Connect và AppGallery Connect tương ứng để cấu hình sản phẩm thành sản phẩm tiêu hao. Tuy nhiên, các sản phẩm AppGallery này cũng cần được tiêu thụ theo cách thức rõ ràng.

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

Hãy cùng viết phương thức công nhận và tiêu thụ cũng như hai phiên bản của phương thức processPurchase() để xem chúng ta có đang chạy backend của chính mình hay không.

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

Không có xác minh máy chủ:

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

Có xác minh máy chủ:

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

Trong các bài viết tiếp theo, chúng tôi sẽ trình bày chi tiết hơn về xác minh máy chủ đối với giao dịch mua.

Tất nhiên, chúng tôi cũng triển khai việc công nhận trong ví dụ thứ hai bên phía máy khách. Tuy nhiên, nếu có thể làm được gì ở backend thì bạn nên thực hiện. Không bỏ qua lỗi từ quy trình công nhận hoặc tiêu thụ. Trong trường hợp không thực thi quy trình nào trong 3 ngày sau khi giap dịch mua đã nhận trạng thái PurchaseState.PURCHASED, giao dịch mua sẽ bị hủy và được hoàn tiền. Nếu không thể thực hiện ở backend và bạn vẫn gặp phải lỗi sau nhiều nỗ lực thì vẫn còn có một cách giải quyết đáng tin cậy. Bạn sẽ phải dùng một số phương thức vòng đời để lấy giao dịch mua hiện tại của người dùng, ví dụ như onStart() hoặc onResume() và tiếp tục thử với hi vọng rằng người dùng sẽ mở ứng dụng trong vòng 3 ngày khi có kết nối với internet. :)

Do vậy, phiên bản hiện tại của lớp BillingClientWrapper sẽ có dạng như

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

Có lẽ bạn đang thắc mắc tại sao nút lại hoạt động cho tất cả sản phẩm, bất kể người dùng đã mua hay chưa. Hoặc bạn muốn biết về điều gì sẽ xảy ra nếu bạn mua cả hai gói đăng ký. Liệu gói đăng ký thứ nhất có thay thế gói đăng ký thứ hai không hoặc liệu hai gói đăng ký sẽ cùng tồn tại? Hãy xem giải đáp trong các bài viết tiếp theo :)

Further reading

Adapty API outage and what we've learned from it
Adapty API outage and what we've learned from it
April 8, 2021
5 min read
Meet brand new Adapty pricing
Meet brand new Adapty pricing
June 30, 2021
3 min read
How to find a new niche and grow your app
How to find a new niche and grow your app
May 25, 2022
42 min listen