LoginSignup
8
5

More than 1 year has passed since last update.

サブスク始めてみませんか?Google Play Billing Libraryの簡単なまとめ

Posted at

はじめに

自分が開発しているAndroidアプリに、アプリ内購入機能を実装したい衝動に駆られました。
Androidアプリにアプリ内購入機能を実装するためには、Google Play Billing Libraryを使ってロジックを実装していく必要があります。

しかし、これがなかなか複雑で難しい。

サンプルコードをみてみても、どこが出発点でどこまでが前処理で、どこからが実際の購入フローなのかよくわからない箇所が多くありました。

そこで今回は、自分のためにGoogle Play Billing Libraryを使って課金機能を実装するのに必要な各クラスの意味やインターフェースについての解説を簡単にまとめてみようと思います。

Google Play Billing Libraryを用いた具体的な実装方法については、以下のコードラボとリポジトリを参照してください。この記事と照らし合わせながら読んでいくと理解が深まると思います。

ProductDetailsとPurchase

Google Play Billing Libraryで行う処理は、ProductDetailsPurchaseの内容に強く依存します。これらのデータを取得してから、それらの状態に応じて必要な処理を行なっていくのが最初に実装が必要な部分になります。

ここでは、ProductDetailsPurhcaseの存在理由やプロパティについて簡単に解説を行います。

ProductDetails

購入を行うために必要なのが、商品もしくは定期購入を表すProductDetailsです。

getProductId()を通してGoogle Play Consoleで設定した商品IDを取得することができます。

getProductType()を通して、BillingClient.ProductTypeを取得することができます。値として、アプリ内商品を表すINAPPかアプリのサブスクリプションを表すSUBSが格納されています。

getSubscriptionOfferDetails()を通して、ユーザーが対象となるオファーを取得することができます。オファーとは、定期購入商品を購入するために利用可能な購入プランのことを言います。

Purchase

ユーザーによるアプリ内課金での購入履歴を表すのがPurchaseです。

getPurchaseToken()を通して、購入トークンを取得することができます。この値はBillingClientacknowledgePurchase()を呼び出す際に必要なAcknowledgePurchaseParamsの生成に必要な値です。

isAcknowledged()を通して、この購入は承認済みかどうかを確認することができます。

ParamsとParams.Builder

Google Play Billing Libraryを使ってアプリ内課金のロジックを実装していると、至る所に**ParamsBuilderといった単語に出会します。そして、サンプルコード名をみても、すべての変数名がparamsになっていて、何のparamsを表しているのか初見ではわかりにくい箇所が多数存在します。

ここでは、主なParamsとそれを取得するために必要なParams.Builderについて簡単な解説を行います。

QueryProductDetailsParams

QueryProductDetailsParamsのインスタンスは、QueryProductDetailsParams.Builderのインスタンスに対してbuild()を呼び出すことで取得します。

val productDetailsParamsBuilder = QueryProductDetailsParams.newBuilder()
val productList = mutableListOf<QueryProductDetailsParams.Product>()

productDetailsParamsBuilder.setProductList(productList)
val productDetailsParams = productDetailsParamsBuilder.build()

QueryProductDetailsParams.Product

QueryProductDetailsParams.Productのインスタンスは、QueryProductDetailsParams.Product.Builderのインスタンスに対してbuild()を呼び出すことで取得します。

val product = "subscription-product-id"
val queryProductDetailsParamsProduct = QueryProductDetailsParams.Product
    .newBuilder()
    .setProductId(product)
    .setProductType(BillingClient.ProductType.SUBS)
    .build()

QueryPurchasesParams

QueryPurchasesParamsのインスタンスは、QueryPurchasesParams.Builderのインスタンスに対してbuild()を呼び出すことで取得します。

val queryPurchasesParams = QueryPurchasesParams
    .newBuilder()
    .setProductType(BillingClient.ProductType.SUBS)
    .build()

BillingFlowParams

BillingFlowParamsのインスタンスは、BillingFlowParams.Builderのインスタンスに対してbuild()を呼び出すことで取得します。

BillingFlowParams
  .newBuilder()
  .setProductDetailsParamList(
    BillingFlowParams.ProductDetailsParams
      .newBuilder()
      .setProductDetails(productDetails)
      .setOfferToken(offerToken)
      .build()
  )

このときに指定するofferTokenList<ProductDetails.SubscriptionOfferDetails>から取得します。

val offerDetails: List<ProductDetails.SubscriptionOfferDetails> = ...

for (offer in offerDetails) {
  val offerToken = offer.offerToken
  Log.d(TAG, "offerToken: $offerToken")
}

サブスクリプションのアップグレードおよびダウングレードを行う場合は、build()を呼び出す前にsetSubscriptionUpdateParams()を呼び出します。

このときに指定するoldTokenは、アップグレードまたはダウングレードをされる側の購入トークンを表します。

BillingFlowParams
  .newBuilder()
  .setProductDetailsParamsList(
    listOf(
        BillingFlowParams.ProductDetailsParams
          .newBuilder()
          .setProductDetails(productDetails)
          .setOfferToken(offerToken)
          .build()
    )
  )
  .setSubscriptionUpdateParams(
    BillingFlowParams.SubscriptionUpdateParams
      .newBuilder()
      .setOldPurchaseToken(oldToken)
      .setReplaceProrationMode(
        BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE
      )
      .build()
  )
  .build()

BillingFlowParams.ProductDetailsParams

BillingFlowParams.ProductDetailsParamsのインスタンスは、BillingFlowParams.ProductDetailsParams.Builderのインスタンスに対して、build()を呼び出すことで取得します。

BillingFlowParams.ProductDetailsParams
    .newBuilder()
    .setProductDetails(productDetails)
    .setOfferToken(offerToken)
    .build()

AcknowledgePurchaseParams

AcknowledgePurchaseParamsのインスタンスは、AcknowledgePurchaseParams.Builderのインスタンスに対してbuild()を呼び出すことで取得します。

val acknowledgePurchaseParams = AcknowledgePurchaseParams
    .newBuilder()
    .setPurchaseToken(purchase.purchaseToken)
    .build()

BillingClient

BillingClientは、購入に関する通信を行うために使用されるという中心的な役割を果たします。
このクラスのインスタンスもまた、newBuilder()を使ってビルダーを取得し、それに対してbuild()を呼び出すことでインスタンスを取得するという流れになっています。

初期化と終了

BillingClientは一度しか使用できません。そのため、onDestroy()が呼び出されてGoogle Play Storeへの接続が終了し、onCreate()が呼び出されるたびに新しいインスタンスを作成する必要があります。

private lateinit var billingClient: BillingClient

// ...

billingClient = BillingClient.newBuilder(applicationContext)
    .setListener(
        object: PurchasesUpdatedListener {
            override fun onPurchasesUpdated(
                billingResult: BillingResult, 
                purchases: MutableList<Purchase>?
            ) {
                // TODO: ...
            }
        }
    )
    .enablePendingPurchases() // サブスクリプションには使用されません。
    .build()
if (!billingClient.isReady) {
    Log.d(TAG, "BillingClient: 接続を開始...")
    billingClient.startConnection(
        object : BillingClientStateListener {
            override fun onBillingSetupFinished(
                billingResult: BillingResult
            ) {
                // TODO: ...
            }

            override fun onBillingServiceDisconnected() {
                startBillingConnection(
                    /** BillingClienstStateListenerを実装したクラス **/
                )
            }
        }
    )
}

// ...

if (billingClient.isReady) {
    Log.d(TAG, "BillingClient: 接続を終了...")
    billingClient.endConnection()
}

以下では、このクラスに実装されている便利なメソッドについて簡単に解説していきます。

setListener()

setListener()に渡すのは、PurchasesUpdatedListenerを実装したクラス。
このメソッドを呼び出す理由は、すべてのアプリ内購入に関する最新のデータを渡したクラスで受信するためです。

startConnection()

startConnection()に渡すのは、BillingClientStateListenerを実装したクラス。

Google Playとの接続を確立するために、このメソッドは呼び出されます。接続の準備ができ次第、渡したクラスのメソッドが呼び出されます。

queryProductDetailsAsync()

アプリで提供されている購入可能な商品の情報を取得するために、このメソッドは呼び出されます。

ここでは、ClassyTaxiAppKotlinのコードを参考に、queryProductDetailsAsync()を呼び出すまでの手順を見ていきます。

private fun queryProductDetails() {

    val productList: MutableList<QueryProductDetailsParams.Product> = arrayListOf()

    // LIST_OF_PRODUCTSは商品IDのリスト
    for (product in LIST_OF_PRODUCTS) {
        // QueryProductDetailsParams.Productのインスタンスを作成してリストに追加する
        productList.add(
            QueryProductDetailsParams.Product.newBuilder()
                .setProductId(product)
                .setProductType(BillingClient.ProductType.SUBS)
                .build()
        )
    }
    //  QueryProductDetailsParams.Builderを準備する
    val params = QueryProductDetailsParams.newBuilder()
    // QueryProductDetailsParams.BuilderにQueryProductDetailsParams.Productの配列をセットする。
    // paramsとproductDetailsParamsは同じ値を指す(変数名が違うため混同しないように注意)
    params.setProductList(productList).let { productDetailsParams ->
        billingClient.queryProductDetailsAsync(
            productDetailsParams.build(), 
            object : ProductDetailsResponseListener { 
                override fun onProductDetailsResponse(
                    billingResult: BillingResult,
                    productDetailsList: MutableList<ProductDetails>
                ) {
                    // TODO: 
                }
             }
        )
    }
}

それでは、ロジックの詳細を確認していきます。

QueryProductDetailsParams.Productを準備する

商品IDのリストを準備します。

val productList: MutableList<QueryProductDetailsParams.Product> = arrayListOf()

QueryProductDetailsParams.Product.newBuilder()を呼び出して、QueryProductDetailsParams.Product.Builderを取得します。

QueryProductDetailsParams.Product.newBuilder()

それに対して、setProductId()で商品IDをセットします。

QueryProductDetailsParams.Product.newBuilder()
    .setProductId(product)

それに対して、setProductType()を呼び出して、商品タイプを設定します。

QueryProductDetailsParams.Product.newBuilder()
    .setProductId(product)
    .setProductType(BillingClient.ProductType.SUBS)

それに対して、build()を呼び出して、QueryProductDetailsParams.Productを取得します

QueryProductDetailsParams.Product.newBuilder()
    .setProductId(product)
    .setProductType(BillingClient.ProductType.SUBS)
    .build()

これを用意した商品IDの個数分繰り返して、作成されたQueryProductDetailsParams.Productが格納されたリストを作成します。

for (product in LIST_OF_PRODUCTS) {
    productList.add(
        QueryProductDetailsParams.Product.newBuilder()
            .setProductId(product)
            .setProductType(BillingClient.ProductType.SUBS)
            .build()
    )
}

QueryProductDetailsParamsを準備する

QueryProductDetailsParams.newBuilder()を呼び出して、QueryProductDetailsParams.Builderを取得します。

val params = QueryProductDetailsParams.newBuilder()

QueryProductDetailsParams.Builder#setProductList()を呼び出して、上記で作成したQueryProductDetailsParams.Productが格納されたリストをセットします。

val params = QueryProductDetailsParams.newBuilder()
params.setProductList(productList)

それに対して、build()を呼び出してQueryProductDetailsParamsのインスタンスを取得します。

val params = QueryProductDetailsParams.newBuilder()
params.setProductList(productList)
params.build()

用意したparamsを渡してqueryProductDetailsAsync()を呼び出す

BillingClient#queryProductDetailsAsync()QueryProductDetailsParamsのインスタンスとProductDetailsResponseListenerを実装したクラスを渡して呼び出します。

billingClient.queryProductDetailsAsync(params.build(), object : ProductDetailsResponseListener {
    override fun onProductDetailsResponse(
        billingResult: BillingResult,
        productDetails: MutableList<ProductDetails>
    ) {
        // TODO: ...
    }
})

queryPurchasesAsync()

queryPurchasesAsync()を呼び出すことでGoogle Play Billingに対して、既存の購入履歴をリクエストします。

新規購入の情報についてはPurchasesUpdatedListenerを実装したクラスに渡されることになっています。このメソッドの役割の詳細については、以下の章で解説しています。

購入トークンがいつ削除されるかを知りたい場合は、Google Play Billing APIを通して確認してください。

第一引数には、QueryPurchasesParamsのインスタンスを渡します。
第二引数には、PurchasesResponseListenerを実装したクラスを渡します。

billingClient.queryPurchasesAsync(
    QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build(),
    object: PurchasesResponseListener {
        override fun onQueryPurchasesResponse(
            p0: BillingResult,
            p1: MutableList<Purchase>
        ) {
            TODO("Not yet implemented")
        }
    }
)

launchBillingFlow()

現在のアクティビティに基づいて購入フローを実行するためにlaunchBillingFlow()を呼び出します。

第一引数には、Activity型の値を渡します。
第二引数には、BillingFlowParamsのインスタンスを渡します。

if (!billingClient.isReady) {
    Log.e(TAG, "launchBillingFlow: BillingClient is not ready")
}
billingClient.launchBillingFlow(context, billingFlowParams)

コールバックを受信するリスナー用のインターフェース

BillingClientを使って課金機能を実装していくには、コールバックを受け取るためのインターフェースを実装したリスナー用のクラスを作成して渡していく必要があります。

ここでは、そのコールバック用のメソッドが定義された主なインターフェースについて簡単にまとめていきます。

BillingClientStateListener

BillingClientの初期化時に呼び出されるstartConnection()にこのインターフェースを実装したクラスを渡す。

object : BillingClientStateListener {
    override fun onBillingSetupFinished(billingResult: BillingResult) {
        // TODO: ...
    }

    override fun onBillingServiceDisconnected() {
        // TODO: ...
    }
}

onBillingSetupFinished()

受け取ったBillingResultresponseCodeを確認します。
BillingClient.BillingResponseCode.OKを表している場合は、BillingClientの使用準備が整ったことを表します。

準備が整ったら、ProductDetailsPurchasesの取得を行います。

override fun onBillingSetupFinished(billingResult: BillingResult) {
    val responseCode = billingResult.responseCode
    // val debugMessage = billingResult.debugMessage

    if (responseCode == BillingClient.BillingResponseCode.OK) {
        // ここにたどり着いた時点で、BillingClientの使用準備が完了したことを意味する。

        // ProductDetailsを取得する
        queryProductDetails()
        // Purchasesを取得する
        queryPurchases()
    }
}

onBillingServiceDisconnected()

BillingClientの接続が切断された際に呼び出されます。通常はstartConnect()を再度呼び出します。

override fun onBillingServiceDisconnected() {
    billingClient.startConnection(/* BillingClientStateListenerを実装したクラスを渡す */)
}

PurchasesUpdatedListener

BillingClientの初期化時に呼び出すsetListener()に、このインターフェースを実装したクラスを渡します。

onPurchasesUpdated()

onPurchasesUpdated()では、購入ボタンをクリックして購入を完了させたり、戻るボタンをタップすることによって購入がキャンセルされたなどの、ユーザーが行った購入フローの最終結果を取得することができます。

第一引数で受け取ったbillingResultを通して、購入に成功したかどうかの判断を行うことができます。

第二引数のpurchaseListでは、このアプリを通してユーザーが行ったすべての購入が含まれたPurchaseのリストを取得できます。
Purchaseには、商品IDや、購入トークン、そして承認済みかどうかを表すフラグなどがプロパティとして格納されています。これらの値を用いて、承認作業が必要な新規の購入なのか、それともこれ以上処理を行う必要のない既存の購入を表しているかを判断することが可能となっています。

override fun onPurchasesUpdated(
   billingResult: BillingResult,
   purchases: List<Purchase>?
) {
   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK
       && !purchases.isNullOrEmpty()
   ) {
       // TODO: purchasesの要素をひとつひとつ調べて承認が必要なのかを判断して処理する
   } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
       // TODO: 購入がキャンセルされたときの処理
   } else {
       // TODO: 他のエラーの処理
   }
}

PurchasesResponseListener

BillingClientqueryPurchasesAsync()の第二引数に、このインターフェースを実装したクラスを渡します。

billingClient.queryPurchasesAsync(
    QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
) { billingResult, purchaseList ->
    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
        // TODO: ...
    } else {
        // TODO: ...
    }
}

onQueryPurchasesResponse()

既存の購入に関する情報をbillingResultpurchaseListという引数を通じて取得します。
アプリの起動中に発生した新しい購入の情報については、上記で説明したPurchasesUpdatedListenerで処理をします。
アプリが実行中でなかったときにPURCHASEDステータスに移行した購入情報を処理するために、onResume()のタイミングで、queryPurchasesAsync()を呼び出し、このメソッドで処理を完了することが望まれています。

override fun onQueryPurchasesResponse(
    billingResult: BillingResult,
    purchasesList: MutableList<Purchase>
) {
    // TODO: 未処理の購入(Purchase)を処理する
}

第二引数で受け取ったMutableList<Purchase>nullかどうかを確認します。
加えて、アプリで保存してある以前のMutableList<Purchase>と変更がないかを確認します。

if (purchasesList == null || isUnchangedPurchaseList(purchasesList)) {
    Log.d(TAG, "processPurchases: Purchase list has not changed")
    return
}

ProductDetailsResponseListener

BillingClientqueryProductDetailsAsync()の第二引数に、このインターフェースを実装したクラスを渡します。

object : ProductDetailsResponseListener {
    override fun onProductDetailsResponse(
        billingResult: BillingResult,
        productDetailsList: MutableList<ProductDetails>
    ) {
        // TOOD: ...
    }
}

onProductDetailsResponse()

このメソッドでは、queryProductDetailsAsync()の呼び出し結果をbillingResultproductDetailsListを通して受け取ります。

override fun onProductDetailsResponse(
    billingResult: BillingResult,
    productDetailsList: MutableList<ProductDetails>
) {
    val responseCode = billingResult.responseCode
    when (responseCode) {
        BillingClient.BillingResponseCode.OK -> {
            var newMap = emptyMap<String, ProductDetails>()
            if (!productDetailsList.isNullOrEmpty()) {
                newMap = productDetailsList.associateBy {
                    it.productId
                }
            }
            // TODO: ここで作成したnewMapをこのメソッド外の変数に保存できる仕組みを実装する
        }
        else -> {
            // TODO: エラーが発生した場合の処理を行う
        }
    }
}

onProductDetailsResponse()で受け取ったproductDetailsListの値を他の場所から参照できるにするために、商品IDがキーで、ProductDetailsが値のマップを準備します。このメソッドの役割は、このマップに値を保存するところにあります。

もし溶融がある場合は、準備していた商品IDのリストと、格納したProductDetailsの個数が一致するかを確認します。一致しない場合は不具合が発生している可能性がありますので、対処を行います。

val productDetailsCount = productsWithProductDetails.size
if (productsWithProductDetails.size == LIST_OF_PRODUCTS.size) {
    // ...
} else {
    // ...
}

購入(Purhcase)を処理する際の共通の実装

onQueryPurchasesResponse()onPurchasesUpdated()が行うべき処理のほとんどは共通しています。これらは、呼び出されるタイミングや文脈こそ違えど、受け取る引数の数や種類はほとんど一致しています。

それらのメソッドが呼ばれた際に行うべきPurchaseに依存した共通の処理を確認していきましょう。
ここでは、定期購入のPurchaseに対する処理を見ていきます。

まず最初に、受け取ったpurchasesListnullでない、かつ以前と変更点があるかを確認します。条件に合致した場合は定期購入の認証作業を行います。

Purchaseが認証済みかどうかは、isAcknowledgedプロパティを通じて確認できます。

取得したpurchasesListacknowledgenot acknowledgedの件数を調べます。
購入したばかりのPurchaseが最初に受信された時は、まだ承認されていないので、isAcknowledgedfalseになります。

for (purchase in purchasesList) {
    if (purchase.isAcknowledged) {
        // TODO: 承認済みだった場合の処理
    } else {
        // TODO: 未承認だった場合の処理
    }
}

Firebase Functionsなどに購入トークンを送信します。サーバー上で購入トークンとユーザーの紐付けを行います。
紐付けが完了した後は、サーバー側で認証作業を行うか、アプリ内でBillingClient#acknowledgePurchase()を呼び出して対象のPurchaseの購入トークンを承認します。

purchaseTokenを使ってAcknowledgePurchaseParamsインスタンスを作成します。

val params = AcknowledgePurchaseParams.newBuilder()
    .setPurchaseToken(purchaseToken)
    .build()

BillingClient#acknowledgePurchase()AcknowledgePurchaseParamsインスタンスを渡して、購入トークンの承認を行います。

for (purchase in purchasesList) {
    if (purchase.isAcknowledged) {
        // TODO: 承認済みだった場合の処理
    } else {
        // NOTE: 未承認だった場合の処理
        val params = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()

        billingClient.acknowledgePurchase(
            params
        ) { billingResult ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
                purchase.purchaseState == Purchase.PurchaseState.PURCHASED
            ) {
                // TODO: Purchaseが承認された場合に行う処理
            }
        }
    }
}

PurchasesUpdatedListenerとPurchasesResponseListenerの両方を使用する理由

PurchasesUpdatedListenerPurchaseの最新データをリッスンするだけでは、すべてのシナリオをカバーできないことが公式ドキュメントで記述されています。

詳しくはこちらをご覧ください。

PurchasesUpdatedListenerが反応しない場合のシナリオは主に3つ存在します。

  1. ユーザーは購入に成功しているものの、何らかの障害が発生したことによりスマートフォンのネットワーク接続が切れてしまい、Googleは購入した情報を送信しているもののPurchasesUpdatedListenerが反応できないケース。
  2. 同時に複数の端末で同じアプリを起動している状態で、一方の端末でのみ購入を行った場合には購入を行った端末にはPurchasesUpdatedListenerでカバーできるものの、もう一方の端末ではリッスンできないケース。
  3. アプリ以外の場所で購入が行われたケース。

これらの問題に対処するために、必ずライフサイクルがonResume()の場合にBillingClientqueryPurchasesAsync()を呼び出して未処理のPurchaseを処理することが求められています。

まとめ

アプリ内課金と言っても、ご覧いただいた通り登場するクラスやメソッド、それにインターフェースがさまざまあり、承認自体もアプリ内で行うべきなのかサーバー側で行うべきなのか、サンプルリポジトリは用意してくれているものの、ドキュメントにわかりやすい実装方法が掲載されていないため、初見にとってはよくわからないという印象を強く抱かせてしまっています。

しかし、以下に掲載したDroidKaigiの発表など、日本語のかつわかりやすい解説などは探せばいくつか見つかります。(僕はランニングしながら、この動画の音声を聴いたことによりGoogle Play Billing Libraryの全体像を把握することができました。)

それらの情報を掻い摘みながら、自分のユースケースにとってベストな実装方法を選択していただく際の補助にこの記事がなってくれれば幸いです。

参考にした記事

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5