トップ «前の日記(2021-11-01) 最新 次の日記(2022-01-12)» 編集

Cocoa練習帳

iOS/iPhone/iPad/watchOS/tvOS/MacOSX/Android プログラミング, Objective-C, Cocoa, Swiftなど

2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|
2021|01|02|03|04|05|06|07|08|09|10|11|12|
2022|01|02|03|04|05|06|07|08|09|10|11|12|
2023|01|02|03|04|05|06|07|08|09|10|11|12|
2024|01|02|03|04|05|

2021-12-08 [Kotlin]Google Play Billing Library

In-app Billing(AIDL)の時は、Googleは基本的なAPIのみの提供で、高度な機能はサンプルとして提供し、開発者はサンプルをコピーして実装していた。

Google Play Billing Libraryになって、iOSのStoreKit並みの高レベルなAPIが提供されるようになった。

ただし、Androidには特有の問題があって、上記のサンプルでそれの対処方法が提示されているので、サンプルを参考にするのが賢明だ。

具体的には、Androidのプロセスはアプリの生き死にと無関係で、動作する必要があったときに存在しなければ生成され、動作する必要がない状況となれば破棄されることもある。また、画面の回転などでアクティビティが再生成される。

Jetpackライブラリ(androidx名前空間)は、上記のような状況にうまく対応するAPIが用意されていて、play-billing-samplesも利用している。

ただ、厄介なのはJetpackライブラリは進化し続けており、以前の手法が古くなることがある。今回、Google Play Billing Libraryのサンプルを作成しようと、新しいplay-billing-samplesの内容を確認したところ、手法が新しくなっていたので、こちらについても説明する。

_ データ・バインディング

Androidでは画面構成はXMLで定義し、プログラムでUI部品にアクセスするというのが今までのやり方だったが、これを簡単にするのがデータ・バインディング・ライブラリ。

play-billing-samplesのTrivialDriveKotlinで、ActivityMainBindingという見慣れるクラスが利用されていたが、Android Studioでプロジェクト内を検索しても、developersサイトを検索しても、クラス定義が見つけられなかった。調べて分かったのは、res/layout/activity_main.xmlに対して動的に生成されるクラスということだった。

_ アプリ・アーキテクチャ

iOSのフレームワークはCocoa MVCというアーキテクチャを想定しているが、このアーキテクチャを強制していない。また、Cocoa MVCも緩い内容でそれを実現するための仕組みも複数用意されている。

Android Jetpackで推奨するアーキテクチャは、これを実現するためのライブラリが用意されているため、自然と推奨されるアーキテクチャとなる。

アプリ・アーキテクチャ

以前のサンプルでは、Repositoryに決済処理を実装していたが、今回は、Data Sourceに決済処理を実装していた。

ViewModelの意図は、例えば、Activity / Fragmentのメンバーに決済処理クラスのインスタンスを保持する方法だと、Activity / Fragmentが再生成となると決済処理のインスタンスも破棄となったり、仕掛かり中の処理があった場合、結果を返す先がなくなってしまう。また、決済処理クラスでActivity / Fragmentのインスタンスを保持していて、それに対して結果を返す実装をしていたら、再生成によって保持しているインスタンスが死者となり、死んだインスタンスへのアクセスはクラッシュの原因となってしまう。そこで、ViewModelによってActivity / Fragmentとは別の生活環とし、Activity / Fragmentの死の道連れにならないようにし、決済クラスからの応答も、LiveDataという、iOSのNSNotificationなどを使ったキー値監視に似た仕組みで返している。

ここから本題のGoogle Play Billing Libraryの話。

_ Google Play Billing Libraryをプロジェクトに組み込む

build.gradleに依存関係を記述する。

dependencies {
    def billing_version = "4.0.0"
    implementation "com.android.billingclient:billing:$billing_version"
    implementation "com.android.billingclient:billing-ktx:$billing_version"
}

_ BillingClientを初期化する

サンプルではBillingClientのインスタンスはData Source保持し、コールバックの受け取り先もData Sourceとしている。

class BillingDataSource private constructor(
        application: Application,
        private val defaultScope: CoroutineScope,
        knownInappSKUs: Array?
) :
    LifecycleObserver, PurchasesUpdatedListener, BillingClientStateListener {
    private val billingClient: BillingClient
 
    override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) {
        ...
    }
 
    init {
        billingClient = BillingClient.newBuilder(application)
            .setListener(this)
            .enablePendingPurchases()
            .build()
        billingClient.startConnection(this)
    }
}

_ Google Playとの接続を確立する

初期化時に接続を開始している、サンプルでは終了時やエラーに対応している。

    override fun onBillingSetupFinished(billingResult: BillingResult) {
        val responseCode = billingResult.responseCode
        val debugMessage = billingResult.debugMessage
        Log.d(TAG, "onBillingSetupFinished: $responseCode $debugMessage")
        when (responseCode) {
            BillingClient.BillingResponseCode.OK -> {
                reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS
                ...
            }
            else -> retryBillingServiceConnectionWithExponentialBackoff()
        }
    }
 
    override fun onBillingServiceDisconnected() {
        retryBillingServiceConnectionWithExponentialBackoff()
    }
 
    private fun retryBillingServiceConnectionWithExponentialBackoff() {
        handler.postDelayed(
                { billingClient.startConnection(this@BillingDataSource) },
                reconnectMilliseconds
        )
        reconnectMilliseconds = min(
                reconnectMilliseconds * 2,
                RECONNECT_TIMER_MAX_TIME_MILLISECONDS
        )
    }

_ 商品情報リストを取得する

商品識別子skuをパラメータとして渡して、商品情報を受け取る。

val knownInappSKUs = ArrayList()
knownInappSKUs.add("jp.co.bitz.Example.Consumable01")
knownInappSKUs.add("jp.co.bitz.Example.Consumable02")
 
    private fun onSkuDetailsResponse(billingResult: BillingResult, skuDetailsList: List?) {
        val responseCode = billingResult.responseCode
        val debugMessage = billingResult.debugMessage
        when (responseCode) {
            BillingClient.BillingResponseCode.OK -> {
                if (skuDetailsList == null || skuDetailsList.isEmpty()) {
                    Log.e(
                            TAG,
                            "onSkuDetailsResponse: " +
                                    "Found null or empty SkuDetails. " +
                                    "Check to see if the SKUs you requested are correctly published " +
                                    "in the Google Play Console."
                    )
                } else {
                    for (skuDetails in skuDetailsList) {
                        val sku = skuDetails.sku
                        ...
                    }
                }
            }
            BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
            BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
            BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
            BillingClient.BillingResponseCode.ITEM_UNAVAILABLE,
            BillingClient.BillingResponseCode.DEVELOPER_ERROR,
            BillingClient.BillingResponseCode.ERROR ->
                Log.e(TAG, "onSkuDetailsResponse: $responseCode $debugMessage")
            BillingClient.BillingResponseCode.USER_CANCELED ->
                Log.i(TAG, "onSkuDetailsResponse: $responseCode $debugMessage")
            BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
            BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED,
            BillingClient.BillingResponseCode.ITEM_NOT_OWNED ->
                Log.wtf(TAG, "onSkuDetailsResponse: $responseCode $debugMessage")
            else -> Log.wtf(TAG, "onSkuDetailsResponse: $responseCode $debugMessage")
        }
        if (responseCode == BillingClient.BillingResponseCode.OK) {
            ...
        } else {
            ...
        }
    }
 
    private suspend fun querySkuDetailsAsync() {
        if (!knownInappSKUs.isNullOrEmpty()) {
            val skuDetailsResult = billingClient.querySkuDetails(
                    SkuDetailsParams.newBuilder()
                            .setType(BillingClient.SkuType.INAPP)
                            .setSkusList(knownInappSKUs)
                            .build()
            )
            onSkuDetailsResponse(skuDetailsResult.billingResult, skuDetailsResult.skuDetailsList)
        }
    }

_ 購入フローを起動する

購入APIを呼び出す。

    fun launchBillingFlow(activity: Activity?, sku: String) {
        val skuDetails = skuDetailsMap[sku]?.value
        if (null != skuDetails) {
            val billingFlowParamsBuilder = BillingFlowParams.newBuilder()
            billingFlowParamsBuilder.setSkuDetails(skuDetails)
            defaultScope.launch {
                val br = billingClient.launchBillingFlow(
                        activity!!,
                        billingFlowParamsBuilder.build()
                )
                if (br.responseCode == BillingClient.BillingResponseCode.OK) {
                    billingFlowInProcess.emit(true)
                } else {
                    Log.e(TAG, "Billing failed: + " + br.debugMessage)
                }
            }
        } else {
            Log.e(TAG, "SkuDetails not found for: $sku")
        }
    }
 
    override fun onPurchasesUpdated(billingResult: BillingResult, list: List?) {
        when (billingResult.responseCode) {
            BillingClient.BillingResponseCode.OK -> if (null != list) {
                processPurchaseList(list, null)
                return
            } else Log.d(TAG, "Null Purchase List Returned from OK response!")
            BillingClient.BillingResponseCode.USER_CANCELED -> Log.i(TAG, "onPurchasesUpdated: User canceled the purchase")
            BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> Log.i(TAG, "onPurchasesUpdated: The user already owns this item")
            BillingClient.BillingResponseCode.DEVELOPER_ERROR -> Log.e(
                    TAG,
                    "onPurchasesUpdated: Developer error means that Google Play " +
                            "does not recognize the configuration. If you are just getting started, " +
                            "make sure you have configured the application correctly in the " +
                            "Google Play Console. The SKU product ID must match and the APK you " +
                            "are using must be signed with release keys."
            )
            else -> Log.d(TAG, "BillingResult [" + billingResult.responseCode + "]: " + billingResult.debugMessage)
        }
        defaultScope.launch {
            billingFlowInProcess.emit(false)
        }
    }

In-app Billingでは購入フローの状態を考えて、失敗する購入APIの呼び出しを避ける実装をしていた場合、Billing Libraryではやめるべきだ。理由は保留中だったり、トランザクションが期限切れとなり払い戻しとなる場合があり、この状況をアプリから知るAPIは用意されていないので、誤動作となる危険がある。購入フローの状況に対応してlaunchBillingFlow()は処理するので、Billing Libraryに任せるのが正解だ。

_ 購入を処理する

In-app Billingでは消費APIを呼べば消費型となり、呼ばなければ非消費型となったが、Billing Libraryでは必ず購入完了のAPIを呼ぶ必要がある。これは、トランザクションの期限切れの機能が入ったためではないかと考えられる。

以下は消費型の消費APIの呼び出し。

    private suspend fun consumePurchase(purchase: Purchase) {
        if (purchaseConsumptionInProcess.contains(purchase)) {
            return
        }
        purchaseConsumptionInProcess.add(purchase)
        val consumePurchaseResult = billingClient.consumePurchase(
                ConsumeParams.newBuilder()
                        .setPurchaseToken(purchase.purchaseToken)
                        .build()
        )
 
        purchaseConsumptionInProcess.remove(purchase)
        if (consumePurchaseResult.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
            defaultScope.launch {
                purchaseConsumedFlow.emit(purchase.skus)
            }
            for (sku in purchase.skus) {
                setSkuState(sku, SkuState.SKU_STATE_UNPURCHASED)
            }
        } else {
            Log.e(TAG, "Error while consuming: ${consumePurchaseResult.billingResult.debugMessage}")
        }
    }

以下は非消費型の承認API呼び出し。

                            val billingResult = billingClient.acknowledgePurchase(
                                    AcknowledgePurchaseParams.newBuilder()
                                            .setPurchaseToken(purchase.purchaseToken)
                                            .build()
                            )

_ ソースコード

GitHubからどうぞ。
https://github.com/murakami/InAppBilling - GitHub

トップ «前の日記(2021-11-01) 最新 次の日記(2022-01-12)» 編集