Play Billing Library 5を試してみる

この記事はAndroid Advent Calendar 2022の18日目の記事です。

今回はPlay Billing Library 5 (PBL5)について色々試したので書いてみようと思います。特にOfferを設定した場合にどんな SubscriptionOfferDetails が得られるのか気になっていたので調べました。

ひょっとしたら実行環境などによって挙動が変わる可能性がありますのでご了承くださいmm

雑なおさらい

Google I/O 2022で定期購入機能の変更が発表されました。

今までプランの名前、期間、金額、割引条件など1つにまとまっていた設定がSubscription, Base Plan, Offerというツリー構造に変わりました。

ツリー構造になることで1つのSubscriptionの下に複数のBase Plan(例: 月額プラン、年間プランなど)を設定できたり、1つのBase Planの下に複数のOfferを設定できるようになりました。

syarihuさんのDroidKaigiのセッションで詳しく解説されているので一度ご覧になることをお勧めします。

Play Billing Library 5

上の変更に対応したのがPBL5です。5.1.0が最新版です。

定期購入のアイテム情報を取得するにはqueryProductDetailsAsync) 関数またはktxライブラリの queryProductDetails 関数を使います。1

query すると ProductDetails やその中に含まれる SubscriptionOfferDetailsPricingPhases が得られます。

以下のサンプルコードのようにqueryした結果 SubscriptionOfferDetails を探してきてその中の offerToken を購入のパラメーターとして設定します。

class BillingSample {
    private val billingClient: BillingClient = TODO()

    fun launchPurchase(productId: String) {
        val products = listOf(
            QueryProductDetailsParams.Product.newBuilder()
                .setProductId(productId)
                .setProductType(BillingClient.ProductType.SUBS)
                .build()
        )
        val params = QueryProductDetailsParams.newBuilder()
            .setProductList(products)
            .build()

        billingClient.queryProductDetailsAsync(
            params,
            this::handleQueryProductDetailsResult
        )
    }

    /**
     * queryProductDetailsAsyncのコールバック関数
     */
    private fun handleQueryProductDetailsResult(
        result: BillingResult,
        productDetailsList: List<ProductDetails>
    ) {
        if (result.responseCode != BillingResponseCode.OK) {
            return
        }

        val productDetails = productDetailsList.firstOrNull() ?: return
        val subscriptionOfferDetails = productDetails.subscriptionOfferDetails ?: return
        val purchaseOfferToken = findSuitableOfferToken(subscriptionOfferDetails) ?: return

        // 購入フローに渡すパラメーターの構築
        val productDetailsParamsList = listOf(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(productDetails)
                .setOfferToken(purchaseOfferToken)
                .build()
        )

        val billingFlowParams = BillingFlowParams.newBuilder()
            .setProductDetailsParamsList(productDetailsParamsList)
            .build()
        TODO("購入フローに渡す")
    }

    private fun findSuitableOfferToken(
        subscriptionOfferDetailsList: List<SubscriptionOfferDetails>
    ): String? {
        TODO("subscriptionOfferDetailsListから適したofferTokenを探して返す")
        // For example, please refer retrieveEligibleOffers and leastPricedOfferToken function in
        // https://github.com/android/play-billing-samples/blob/main/PlayBillingCodelab/finished/src/main/java/com/sample/subscriptionscodelab/ui/MainViewModel.kt
    }
}

PBL5を試してみる

PBL5を試したい場合、Codelabを使うのが一番お手軽です。2

releaseビルドを作ってGoogle Play Consoleから内部テスト(internal-testing)にアップロードすれば一般公開しなくてもテストできます。

今回はこのアプリを使ってOfferの設定を変えながらどのようなsubscriptionOfferDetailsが得られるのか実験したいと思います。

このアプリではbasicとpremiumの2つのsubscriptionの下にそれぞれmonthly, yearly, prepaidの3つのbase planを設定しています。

また、subscriptionOfferDetailsの内容をlogcatで確認するために MainViewModelbuy 関数にログを仕込みました

試した結果

Offer無しの場合

Offerが設定されていない場合、 subscriptionOfferDetails はBase Plan 1つにつき1つしかありません。

subscriptionOfferDetails = [
  {
    basePlanId=monthlybasic,
    offerId=null,
    offerTags=[monthlybasic],
    offerToken=(deleted),
    pricingPhases=[...]
  },
  {
    basePlanId=yearlybasic,
    offerId=null,
    offerTags=[yearlybasic],
    offerToken=(deleted),
    pricingPhases=[...]
  },
  {
    basePlanId=prepaidbasic,
    offerId=null,
    offerTags=[prepaidbasic],
    offerToken=(deleted),
    pricingPhases=[...]
  }
]

初回購入限定のOfferを設定した場合

monthlybasicに初回購入限定でお試し期間1ヶ月のOffer(下の画像のmonthlybasic-freetrial)を設定してみます。

初回購入かどうかはGoogle Play側でチェックしているのでサービス側では特にチェックをする必要はないです。

初回購入時はbasePlanIdがmonthlybasicのsubscriptionOfferDetails は2つ受け取れます。 片方は通常の購入、もう片方はお試し期間のOffer付きの購入です。offerIdやofferTagsを見ると区別がつくと思います。

subscriptionOfferDetails = [
↓ここからmonthlybasic
  {
    basePlanId=monthlybasic,
    offerId=monthlybasic-freetrial,
    offerTags=[freetrial, monthlybasic],
    offerToken=(deleted),
    pricingPhases=[
      {
        formattedPrice = Free,
        priceCurrencyCode = JPY,
        billingPeriod = P1M,
        billingCycleCount = 1,
        recurrenceMode = 2
      },
      {
        formattedPrice = ¥100,
        priceCurrencyCode = JPY,
        billingPeriod = P1M,
        billingCycleCount = 0,
        recurrenceMode = 1
      }
    ]
  },
  {
    basePlanId=monthlybasic,
    offerId=null,
    offerTags=[monthlybasic],
    offerToken=(deleted),
    pricingPhases=[
      {
        formattedPrice = ¥100,
        priceCurrencyCode = JPY,
        billingPeriod = P1M,
        billingCycleCount = 0,
        recurrenceMode = 1
      }
    ]
  },
↑ここまで

{basePlanId=yearlybasic, offerId=null, offerTags=[yearlybasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥1,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]},
{basePlanId=prepaidbasic, offerId=null, offerTags=[prepaidbasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥120, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]}
]

二回目の購入時は初回購入限定のOfferは渡されません

subscriptionOfferDetails = [
↓ここからmonthlybasic
  {
    basePlanId=monthlybasic,
    offerId=null,
    offerTags=[monthlybasic],
    offerToken=(deleted),
    pricingPhases=[
      {
        formattedPrice = ¥100,
        priceCurrencyCode = JPY,
        billingPeriod = P1M,
        billingCycleCount = 0,
        recurrenceMode = 1
      }
    ]
  }, 
↑ここまで

{basePlanId=yearlybasic, offerId=null, offerTags=[yearlybasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥1,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]}, 
{basePlanId=prepaidbasic, offerId=null, offerTags=[prepaidbasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥120, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]}
]

デベロッパー指定のOfferを設定した場合

今度はmonthlypremiumにデベロッパー指定のOffer(下の画像のmonthlypremium-custom)を設定してみます。

デベロッパー指定の場合、サービス側でユーザーがこのOfferを適用できるかどうかチェックする必要があります。

初回購入時はmonthlypremiumのsubscriptionOfferDetails は2つあります。 片方は通常の購入、もう片方はお試し期間のOffer付きの購入です。

subscriptionOfferDetails = [
↓ここからmonthlypremium
{basePlanId=monthlypremium, offerId=monthlypremium-custom, offerTags=[custom, monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = Free, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 1, recurrenceMode = 2}, {formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, 
{basePlanId=monthlypremium, offerId=null, offerTags=[monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, 
↑ここまで

{basePlanId=yearlypremium, offerId=null, offerTags=[yearlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥5,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]}, 
{basePlanId=prepaidpremium, offerId=null, offerTags=[prepaidpremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥550, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]}
]

二回目の購入時も初回と同様にmonthlypremiumのsubscriptionOfferDetails は2つあります。

subscriptionOfferDetails = [
↓ここからmonthlypremium
{basePlanId=monthlypremium, offerId=monthlypremium-custom, offerTags=[custom, monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = Free, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 1, recurrenceMode = 2}, {formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, 
{basePlanId=monthlypremium, offerId=null, offerTags=[monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, 
↑ここまで

{basePlanId=yearlypremium, offerId=null, offerTags=[yearlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥5,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]}, 
{basePlanId=prepaidpremium, offerId=null, offerTags=[prepaidpremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥550, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]}
]

まとめ

個人的には Offer (特にデベロッパー指定 Offer)を使うことでサービス側で柔軟に割引やお試しをユーザーに提供できるのに魅力を感じています。

一方で ProductDetailsの SubscriptionOfferDetails のリストの中から意図したもの(購入フローに渡したいもの)を選ぶロジックがどうしても複雑になりそう3なのがネックになっています。

個人的にはアプリ側ではなくサーバー側でロジックを実装してアプリ側はサーバー側から指定されたofferTokenをそのまま使うパターンがシンプルでいいかなと感じています。

余談

定期購入アイテムを登録するときにこれまで inappproducts APIを使っていた場合は注意が必要です。

Google Play Consoleから編集しようとしてもread only状態になっていると思います。編集可能にするボタンがありますがクリックして編集可能にすると inappproducts APIでは編集できなくなり、新しいSubscriptions Publishing APIを使わなければならない状態になります。

Googleの公式ドキュメントでも下のように記載されています。

Google Play Console で定期購入の編集を行うと、それ以降は inappproducts API を定期購入に使用できなくなります。 2022 年 5 月より前に Publishing API を使用している場合、問題を避けるために、既存の定期購入は Google Play Console ですべて読み取り専用として表示されます。変更しようとすると、この制限に関する警告が表示される場合があります。Google Play Console で定期購入を編集する場合は、新しい Subscription Publishing エンドポイントを使用できるよう、バックエンドの統合を事前に更新してください。

https://developer.android.com/google/play/billing/compatibility?hl=ja#managing-subscriptions

余談2

追記(2024-08-16): Google の中の人が見てくれたのか分かりませんが Play Billing Lab という便利なテストアプリが最近リリースされました。これを使えば下のめんどくささは解消できると思います!

Test your Google Play Billing Library integration  |  Google Play's billing system  |  Android Developers

初回限定Offerの挙動のテストは地味に面倒くさく、一度購入するたびに新しいProduct + Base Plan + Offerを作り直すか新しいGoogleアカウントを作る必要がありました。

この辺のテストが簡単になると大変ありがたいです...4

参考資料


  1. 既存のquerySkuDetailsAsync関数も残っているのでそちらを使い続けることも可能ですがdeprecatedに指定されているのでいずれは移行する必要があります。
  2. ただし、Codelabの日本語版は微妙に内容が古いので英語版を使いましょう。githubのリンクが間違っていて小一時間費やしました...
  3. offerTagでマッチングをしたり最小の価格になるSubscriptionOfferDetailsを探したり実装のやり方は色々あります。
  4. 購入カウントをリセットができるようになるとか?