はじめに
自分が開発しているAndroidアプリに、アプリ内購入機能を実装したい衝動に駆られました。
Androidアプリにアプリ内購入機能を実装するためには、Google Play Billing Libraryを使ってロジックを実装していく必要があります。
しかし、これがなかなか複雑で難しい。
サンプルコードをみてみても、どこが出発点でどこまでが前処理で、どこからが実際の購入フローなのかよくわからない箇所が多くありました。
そこで今回は、自分のためにGoogle Play Billing Libraryを使って課金機能を実装するのに必要な各クラスの意味やインターフェースについての解説を簡単にまとめてみようと思います。
Google Play Billing Libraryを用いた具体的な実装方法については、以下のコードラボとリポジトリを参照してください。この記事と照らし合わせながら読んでいくと理解が深まると思います。
ProductDetailsとPurchase
Google Play Billing Libraryで行う処理は、ProductDetails
とPurchase
の内容に強く依存します。これらのデータを取得してから、それらの状態に応じて必要な処理を行なっていくのが最初に実装が必要な部分になります。
ここでは、ProductDetails
とPurhcase
の存在理由やプロパティについて簡単に解説を行います。
ProductDetails
購入を行うために必要なのが、商品もしくは定期購入を表すProductDetails
です。
getProductId()
を通してGoogle Play Consoleで設定した商品IDを取得することができます。
getProductType()
を通して、BillingClient.ProductType
を取得することができます。値として、アプリ内商品を表すINAPP
かアプリのサブスクリプションを表すSUBS
が格納されています。
getSubscriptionOfferDetails()
を通して、ユーザーが対象となるオファーを取得することができます。オファーとは、定期購入商品を購入するために利用可能な購入プランのことを言います。
Purchase
ユーザーによるアプリ内課金での購入履歴を表すのがPurchase
です。
getPurchaseToken()
を通して、購入トークンを取得することができます。この値はBillingClient
のacknowledgePurchase()
を呼び出す際に必要なAcknowledgePurchaseParams
の生成に必要な値です。
isAcknowledged()
を通して、この購入は承認済みかどうかを確認することができます。
ParamsとParams.Builder
Google Play Billing Libraryを使ってアプリ内課金のロジックを実装していると、至る所に**Params
やBuilder
といった単語に出会します。そして、サンプルコード名をみても、すべての変数名が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()
)
このときに指定するofferToken
はList<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()
受け取ったBillingResult
のresponseCode
を確認します。
BillingClient.BillingResponseCode.OK
を表している場合は、BillingClient
の使用準備が整ったことを表します。
準備が整ったら、ProductDetails
とPurchases
の取得を行います。
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
BillingClient
のqueryPurchasesAsync()
の第二引数に、このインターフェースを実装したクラスを渡します。
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
) { billingResult, purchaseList ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
// TODO: ...
} else {
// TODO: ...
}
}
onQueryPurchasesResponse()
既存の購入に関する情報をbillingResult
とpurchaseList
という引数を通じて取得します。
アプリの起動中に発生した新しい購入の情報については、上記で説明した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
BillingClient
のqueryProductDetailsAsync()
の第二引数に、このインターフェースを実装したクラスを渡します。
object : ProductDetailsResponseListener {
override fun onProductDetailsResponse(
billingResult: BillingResult,
productDetailsList: MutableList<ProductDetails>
) {
// TOOD: ...
}
}
onProductDetailsResponse()
このメソッドでは、queryProductDetailsAsync()
の呼び出し結果をbillingResult
とproductDetailsList
を通して受け取ります。
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
に対する処理を見ていきます。
まず最初に、受け取ったpurchasesList
がnull
でない、かつ以前と変更点があるかを確認します。条件に合致した場合は定期購入の認証作業を行います。
Purchase
が認証済みかどうかは、isAcknowledged
プロパティを通じて確認できます。
取得したpurchasesList
のacknowledgeとnot acknowledgedの件数を調べます。
購入したばかりのPurchase
が最初に受信された時は、まだ承認されていないので、isAcknowledged
はfalse
になります。
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の両方を使用する理由
PurchasesUpdatedListener
でPurchase
の最新データをリッスンするだけでは、すべてのシナリオをカバーできないことが公式ドキュメントで記述されています。
詳しくはこちらをご覧ください。
PurchasesUpdatedListener
が反応しない場合のシナリオは主に3つ存在します。
- ユーザーは購入に成功しているものの、何らかの障害が発生したことによりスマートフォンのネットワーク接続が切れてしまい、Googleは購入した情報を送信しているものの
PurchasesUpdatedListener
が反応できないケース。 - 同時に複数の端末で同じアプリを起動している状態で、一方の端末でのみ購入を行った場合には購入を行った端末には
PurchasesUpdatedListener
でカバーできるものの、もう一方の端末ではリッスンできないケース。 - アプリ以外の場所で購入が行われたケース。
これらの問題に対処するために、必ずライフサイクルがonResume()
の場合にBillingClient
のqueryPurchasesAsync()
を呼び出して未処理のPurchase
を処理することが求められています。
まとめ
アプリ内課金と言っても、ご覧いただいた通り登場するクラスやメソッド、それにインターフェースがさまざまあり、承認自体もアプリ内で行うべきなのかサーバー側で行うべきなのか、サンプルリポジトリは用意してくれているものの、ドキュメントにわかりやすい実装方法が掲載されていないため、初見にとってはよくわからないという印象を強く抱かせてしまっています。
しかし、以下に掲載したDroidKaigiの発表など、日本語のかつわかりやすい解説などは探せばいくつか見つかります。(僕はランニングしながら、この動画の音声を聴いたことによりGoogle Play Billing Libraryの全体像を把握することができました。)
それらの情報を掻い摘みながら、自分のユースケースにとってベストな実装方法を選択していただく際の補助にこの記事がなってくれれば幸いです。
参考にした記事