💎

個人開発Androidアプリに Google Play Billing Library v7 でサブスク+買い切り課金を実装した話

に公開

はじめに

「かえたお」というタオルの交換タイミングを管理するアプリを個人開発しています。iOS版に続いて Android版(Kotlin + Jetpack Compose)をリリースしました。

https://10half.jp/kaetao.html

無料版には制限をかけて、Pro プランで解除するフリーミアムモデルを採用しています。プランは2つ:

  • 月額プランpro_monthly): 月額100円、自動更新サブスクリプション
  • 買い切りプランpro_lifetime): 500円、一度きりの購入

この記事では、Google Play Billing Library v7 を使ってサブスクと買い切りの両方に対応した課金機能をどう実装したかを紹介します。

設計の全体像

┌─────────────────────────────────────────────┐
│  UI Layer                                    │
│  ProPaywallScreen (Compose)                  │
│       ↕ StateFlow                            │
│  ProPaywallViewModel (@HiltViewModel)        │
├─────────────────────────────────────────────┤
│  Domain Layer                                │
│  ProLimits (制限値の一元定義)                  │
│  ProProduct / ProPlan (モデル)                │
├─────────────────────────────────────────────┤
│  Data Layer                                  │
│  BillingRepository (@Singleton)              │
│       ↕ BillingClient                        │
│  ForwardingPurchasesUpdatedListener          │
│       ↕ DataStore                            │
│  isPro 永続化 (kaetao_prefs)                 │
└─────────────────────────────────────────────┘

Hilt の循環依存を解消する

Google Play Billing Library を Hilt で DI しようとすると、最初にハマるのが循環依存です。

BillingClient を生成するには PurchasesUpdatedListener が必要で、PurchasesUpdatedListener の処理には BillingRepository のメソッドを呼びたい。でも BillingRepository のコンストラクタには BillingClient が必要——というループになります。

BillingClient → PurchasesUpdatedListener → BillingRepository → BillingClient
                        ↑ 循環! ─────────────────────────────────┘

ForwardingPurchasesUpdatedListener で解決

解決策は、リスナーをデリゲートパターンで分離することです。

@Singleton
class ForwardingPurchasesUpdatedListener @Inject constructor() : PurchasesUpdatedListener {

    @Volatile
    private var delegate: PurchasesUpdatedListener? = null

    fun setDelegate(delegate: PurchasesUpdatedListener) {
        this.delegate = delegate
    }

    override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
        delegate?.onPurchasesUpdated(billingResult, purchases)
    }
}

このクラスは PurchasesUpdatedListener を実装しつつ、実際の処理は後から setDelegate() で注入します。@Volatile を付けているのは、BillingClient のコールバックがバックグラウンドスレッドから来る可能性があるためです。

Hilt の Module ではこの ForwardingPurchasesUpdatedListenerBillingClient に渡します。

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideBillingClient(
        @ApplicationContext context: Context,
        listener: ForwardingPurchasesUpdatedListener,
    ): BillingClient = BillingClient.newBuilder(context)
        .setListener(listener)
        .enablePendingPurchases(
            PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
        )
        .build()
}

そして BillingRepositoryinit ブロックで、実際のハンドラを登録します。

@Singleton
class BillingRepository @Inject constructor(
    private val dataStore: DataStore<Preferences>,
    private val billingClient: BillingClient,
    forwardingListener: ForwardingPurchasesUpdatedListener,
    @Named("monthlyProductId") private val monthlyProductId: String,
    @Named("lifetimeProductId") private val lifetimeProductId: String,
) {

    init {
        forwardingListener.setDelegate(PurchasesUpdatedListener { billingResult, purchases ->
            when (billingResult.responseCode) {
                BillingClient.BillingResponseCode.OK -> {
                    coroutineScope.launch { handlePurchases(purchases.orEmpty()) }
                }
                BillingClient.BillingResponseCode.USER_CANCELED -> {
                    _isPurchasing.value = false
                }
                else -> {
                    _isPurchasing.value = false
                    _purchaseError.value = userFriendlyError(billingResult.responseCode)
                }
            }
        })
        // ...
    }
}

これで依存関係が一方向になります。

AppModule が ForwardingListener を生成

AppModule が ForwardingListener を使って BillingClient を生成

Hilt が BillingClient + ForwardingListener を BillingRepository に注入

BillingRepository.init で ForwardingListener に実ハンドラを登録

商品IDをハードコードしない

商品IDは build.gradle.ktsbuildConfigField で定義しています。

// app/build.gradle.kts
android {
    buildTypes {
        debug {
            buildConfigField("String", "PRO_MONTHLY_PRODUCT_ID", "\"pro_monthly\"")
            buildConfigField("String", "PRO_LIFETIME_PRODUCT_ID", "\"pro_lifetime\"")
        }
        release {
            buildConfigField("String", "PRO_MONTHLY_PRODUCT_ID", "\"pro_monthly\"")
            buildConfigField("String", "PRO_LIFETIME_PRODUCT_ID", "\"pro_lifetime\"")
        }
    }
}

Hilt の @Named で注入するので、Repository は商品IDを直接知りません。

@Provides
@Named("monthlyProductId")
fun provideMonthlyProductId(): String = BuildConfig.PRO_MONTHLY_PRODUCT_ID

@Provides
@Named("lifetimeProductId")
fun provideLifetimeProductId(): String = BuildConfig.PRO_LIFETIME_PRODUCT_ID

テスト時に別の商品IDに差し替えたり、将来的に商品構成を変更するときもこの1箇所を変えるだけです。

サブスクと買い切りを統一的に扱う

Billing Library v7 では、サブスクリプション(SUBS)と一回きりの購入(INAPP)で API が微妙に異なります。これを吸収するのが ProProduct です。

enum class ProPlan { MONTHLY, LIFETIME }

data class ProProduct(val plan: ProPlan, val productDetails: ProductDetails) {
    val formattedPrice: String?
        get() = when (plan) {
            ProPlan.MONTHLY -> productDetails.subscriptionOfferDetails
                ?.firstOrNull()
                ?.pricingPhases
                ?.pricingPhaseList
                ?.firstOrNull()
                ?.formattedPrice
            ProPlan.LIFETIME -> productDetails.oneTimePurchaseOfferDetails
                ?.formattedPrice
        }

    val formattedPriceWithPeriod: String?
        get() = when (plan) {
            ProPlan.MONTHLY -> formattedPrice?.let { "$it/月" }
            ProPlan.LIFETIME -> formattedPrice?.let { "$it(買い切り)" }
        }
}

サブスクの価格取得は subscriptionOfferDetails → pricingPhases → pricingPhaseList と3階層潜る必要があります。買い切りは oneTimePurchaseOfferDetails から直接取れます。この差を ProProduct に閉じ込めることで、UI 側は product.formattedPriceWithPeriod を呼ぶだけで済みます。

商品情報のクエリ

BillingClient の接続が完了したら、商品情報を取得します。サブスクと買い切りは productType が異なるので、別々にクエリして結合しています。

fun queryProducts() {
    coroutineScope.launch {
        val monthlyDetails = queryProductDetailsAsync(
            productId = monthlyProductId,
            productType = BillingClient.ProductType.SUBS,
        )
        val lifetimeDetails = queryProductDetailsAsync(
            productId = lifetimeProductId,
            productType = BillingClient.ProductType.INAPP,
        )
        val result = buildList {
            monthlyDetails?.let { add(ProProduct(ProPlan.MONTHLY, it)) }
            lifetimeDetails?.let { add(ProProduct(ProPlan.LIFETIME, it)) }
        }
        _products.value = result
    }
}

queryProductDetailsAsync は Billing Library のコールバック API を suspendCancellableCoroutine でラップし、さらに withTimeoutOrNull で15秒のタイムアウトを設けています。

private suspend fun queryProductDetailsAsync(
    productId: String,
    productType: String,
): ProductDetails? = withTimeoutOrNull(QUERY_TIMEOUT_MS) {
    suspendCancellableCoroutine { cont ->
        val product = QueryProductDetailsParams.Product.newBuilder()
            .setProductId(productId)
            .setProductType(productType)
            .build()
        val params = QueryProductDetailsParams.newBuilder()
            .setProductList(listOf(product))
            .build()
        billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
            if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                Log.w("BillingRepository", "queryProductDetails failed: ${billingResult.debugMessage}")
            }
            cont.resume(productDetailsList.firstOrNull())
        }
    }
}

タイムアウトを入れているのは、ネットワーク不良時にコールバックが返ってこないケースがあるためです。タイムアウトした場合は null を返し、その商品は一覧に表示しないようにしています。

購入フロー

購入処理では、サブスクリプションの場合だけ offerToken の設定が必要です。

suspend fun purchase(activity: Activity, product: ProProduct) {
    _isPurchasing.value = true
    val builder = BillingFlowParams.ProductDetailsParams.newBuilder()
        .setProductDetails(product.productDetails)
    // サブスクリプション購入には offerToken が必須 (Play Billing Library v5+)
    product.productDetails.subscriptionOfferDetails
        ?.firstOrNull()?.offerToken
        ?.let { builder.setOfferToken(it) }
    val productDetailsParams = builder.build()
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(listOf(productDetailsParams))
        .build()
    val result = billingClient.launchBillingFlow(activity, billingFlowParams)
    if (result.responseCode != BillingClient.BillingResponseCode.OK) {
        _isPurchasing.value = false
        _purchaseError.value = userFriendlyError(result.responseCode)
    }
}

launchBillingFlow は非同期で、購入結果は PurchasesUpdatedListener(= ForwardingPurchasesUpdatedListener 経由)に通知されます。ここで isPurchasing をリセットしないのがポイントです。

購入の確認(Acknowledge)

Google Play では、購入後3日以内に Acknowledge しないと自動で払い戻しされます。handlePurchases で全ての購入を処理し、未確認のものを Acknowledge しています。

private suspend fun handlePurchases(purchases: List<Purchase>) {
    var hasPro = false
    for (purchase in purchases) {
        if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) continue
        if (!purchase.isAcknowledged) {
            val ackParams = AcknowledgePurchaseParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()
            suspendCancellableCoroutine { cont ->
                billingClient.acknowledgePurchase(ackParams) { billingResult ->
                    if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                        _purchaseError.value = "購入の確認に失敗しました。再度お試しください。"
                    }
                    cont.resume(Unit)
                }
            }
        }
        val isProPurchase = purchase.products.any { productId ->
            productId == monthlyProductId || productId == lifetimeProductId
        }
        if (isProPurchase) hasPro = true
    }
    _isPro.value = hasPro
    _isPurchasing.value = false
    dataStore.edit { it[IS_PRO_KEY] = hasPro }
}

最後に DataStoreisPro を永続化しています。これにより、次回起動時に Billing Client の接続完了を待たなくても、直前の課金状態を復元できます。オフラインでも Pro 機能が使えるのはこのおかげです。

制限値の一元管理

無料/Pro で何がどう変わるかは ProLimits に集約しています。

object ProLimits {
    fun maxDailyAssessments(isPro: Boolean): Int = if (isPro) Int.MAX_VALUE else 1
    fun maxTowels(isPro: Boolean): Int = if (isPro) Int.MAX_VALUE else 4
    fun maxGroupMembers(isPro: Boolean): Int = if (isPro) 10 else 3
    fun maxVisibleConditionChecks(isPro: Boolean): Int = if (isPro) Int.MAX_VALUE else 5

    const val AD_BONUS_ASSESSMENTS = 1
}

各 ViewModel から ProLimits.maxTowels(isPro) のように呼び出すだけです。制限値を変更したくなったら1箇所を変えるだけで済みます。

エラーメッセージのユーザーフレンドリー化

Billing Library のエラーコードは debugMessage で返ってきますが、英語の技術的なメッセージなのでそのままユーザーに見せられません。userFriendlyError で日本語に変換しています。

private fun userFriendlyError(responseCode: Int): String = when (responseCode) {
    BillingClient.BillingResponseCode.SERVICE_TIMEOUT ->
        "接続がタイムアウトしました。しばらくしてから再度お試しください。"
    BillingClient.BillingResponseCode.SERVICE_DISCONNECTED ->
        "Google Playとの接続が切断されました。再度お試しください。"
    BillingClient.BillingResponseCode.BILLING_UNAVAILABLE ->
        "購入機能が利用できません。Google Playアプリを更新してください。"
    BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED ->
        "この商品はすでに購入済みです。"
    BillingClient.BillingResponseCode.NETWORK_ERROR ->
        "ネットワークエラーが発生しました。通信環境を確認してください。"
    else ->
        "購入処理でエラーが発生しました。再度お試しください。"
}

これは Google Play のポリシー審査でも重要です。エラー時に何が起きたかユーザーに伝えないと、審査でリジェクトされる可能性があります。

Google Play ポリシー準拠のポイント

Google Play ではサブスクリプションに関して厳しいポリシーがあります。実際に対応した点を紹介します。

1. 自動更新の説明と解約方法の明示

ペイウォール画面に以下を明記する必要があります。

  • サブスクリプションは自動更新されること
  • 解約方法(Google Play の定期購入管理画面へのリンク)
  • 買い切りプランとの違い

2. 価格に請求期間を付ける

「¥100」ではなく「¥100/月」のように、請求期間を明示する必要があります。ProProduct.formattedPriceWithPeriod でこれを実現しています。Google Play から返ってくる formattedPrice はローカライズ済み(例: ¥100)なので、それに期間を付与するだけです。

3. 購入の復元

機種変更やアプリの再インストール後に、以前の購入を復元できる機能が必要です。

suspend fun restorePurchases() {
    try {
        val subsPurchases = queryPurchasesAsync(BillingClient.ProductType.SUBS)
        val inappPurchases = queryPurchasesAsync(BillingClient.ProductType.INAPP)
        handlePurchases(subsPurchases + inappPurchases)
    } catch (e: BillingQueryException) {
        _purchaseError.value = e.message
    }
}

SUBSINAPP の両方をクエリするのを忘れないようにしましょう。サブスクだけ復元して買い切りを忘れると、買い切りユーザーが困ります。

課金テストの方法

内部テストトラック

Google Play Console の内部テストトラックに APK をアップロードし、テスターとして登録した Google アカウントでテストします。

内部テストではテストカードが使えるので、実際に課金されることはありません。テストカードでの購入は以下の特殊な挙動をします:

  • サブスクリプションは 5分ごとに更新される
  • 30分後に自動キャンセルされる
  • 買い切りは Play Console の「注文管理」から手動で払い戻しできる

テスト時の注意点

  • テスターの Google アカウントを Play Console のライセンステスターに追加する必要がある
  • 内部テストの反映には 数時間かかることがある(初回は特に遅い)
  • BillingClient の接続先が内部テストのサーバーに向くまで、アプリの再インストールが必要な場合がある

まとめ

  • ForwardingPurchasesUpdatedListener パターンで Hilt の循環依存を解消できる
  • サブスクと買い切りは ProductType が異なるので、別々にクエリして ProProduct で統一する
  • DataStore に isPro を永続化することで、オフラインでも課金状態を維持
  • Acknowledge を忘れると3日後に自動払い戻しされる。handlePurchases で確実に処理すること
  • userFriendlyError でエラーコードを日本語に変換し、ユーザーとポリシー審査の両方に対応
  • ProLimits に制限値を集約することで、無料/Pro の差分管理が楽になる

Google Play Billing Library は API のネストが深く、サブスクと買い切りで微妙に異なるインターフェースを持つので、最初は戸惑うかもしれません。しかし、一度 Repository 層で吸収してしまえば、UI 側はシンプルに保てます。

アプリ「かえたお」は Google Play / App Store で公開中です。
https://10half.jp/kaetao.html

Discussion