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

Androidアプリ内購入、パート 2: Google Play Billing Libraryを使用した購入の処理

Vlad Guriev

Updated: 3月 20, 2023

Content

62fdf1c4519df62803e8fafe jp android tutorial 1 configuration 2

前回の記事では、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
   }
}

購入の実装に進み、クラスを改善しましょう。

定期購入画面のデザイン

アプリ内購入 (in-app purchase) を特徴とするアプリには、ペイウォール (paywall) 画面があります。このような画面にはGoogleポリシーが適用され、表示する必要がある最小限の要素や説明テキストを定義しています。概要は次のとおりです。ペイウォール画面では、定期購入 (subscription) の条件、料金、期間を明示し、アプリを使用するために定期購入が必要かどうかを指定する必要があります。また、条件を確認するためにユーザーに追加のアクションを強制することも避けなければなりません。

ここでは、簡略化したペイウォール画面を例として使用します。

android sample paywall
Androidペイウォール画面のサンプル 

ペイウォール画面には次の要素があります。

● 見出し

● 購入プロセスを開始するためのボタンが設定されています。これらのボタンは、タイトルや現地通貨で表記された料金など、定期購入オプションの一般的な詳細について案内します。

● 説明テキスト

● 以前に行った購入を復元するためのボタン。定期購入または消費不可アイテムの購入を特徴とするアプリでは必須の要素です。

コードを調整してアイテムの詳細を表示する

この例では、4つのアイテムがあります。

● 2つの自動更新型定期購入 (「premium_sub_month」と「premium_sub_year」)

● 2回購入できないアイテム、すなわち消費不可アイテム (「unlock_feature」)

● 複数回購入できるアイテム、すなわち消費可能アイテム(「coin_pack_large」)

例を簡略化するために、前回の記事で作成したBillingClientWrapperを挿入するActivityと、固定数の購入ボタンを含むレイアウトを使用します。

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

UIにアイテムを表示するメソッドを宣言しましょう。これは、前回のチュートリアルで紹介したロジックに基づいています。

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

Subscribe to Adapty newsletter

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

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に通知する必要があります。通知しなかった場合、購入は3日後に自動的に払い戻されます。これはGoogle Play独自のポリシーであり、iOS では同様のポリシーを設けていません。ユーザーにコンテンツを配信したことを確認するには、次の2つの方法があります。

● クライアントサイドのacknowledgePurchase()経由

● バックエンドサイドの Product.Purchases.Acknowledge/Purchases.Subscriptions.Acknowledge経由

消費可能アイテムを取り扱う場合は、代わりにconsumeAsync()メソッドを呼び出す必要があります。内部での購入を承認すると同時に、アイテムの再購入を可能にします。これは、Billing Libraryでのみ実行できます。何らかの理由で、Google Play Developer APIではバックエンドサイドで実行する方法がありません。Google Playとは異なり、App StoreとAppGalleryのいずれも、それぞれApp Store ConnectとAppGallery Connectを介してアイテムを消費可能として設定しています。ただし、これらのAppGalleryのアイテムも明示的に消費される必要があります。

独自のバックエンドを実行しているかどうかを説明するために、acknowledgeとconsumerのメソッド、およびprocessPurchase()メソッドの2つのバージョンを記述しましょう。

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

以降の記事では、購入のためのサーバー認証について詳しく説明します。

もちろん、2番目の例のacknowledgementをクライアントサイドに実装することもできます。ただし、バックエンドで何かを実行できるなら、実行してください。acknowledgementまたはconsumptionプロセスのいずれかでエラーが返された場合、これらは無視できません。 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)
           }
       }
   }
}

ユーザーがすでに購入したかどうかに関わらず、すべてのアイテムでボタンが有効になっている理由を知りたいかもしれません。もしくは、両方の定期購入を購入するとどうなるかについて、質問があるかもしれません。最初の定期購入が2回目の定期購入に置き換わるのか、それとも共存するのか……すべての回答については、今後の記事を参照してください。