Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Billing Library v4.0.0 への更新

こんにちは、モバイルクライアントグループの中島です。 最近、ディスプレイの上に引っ掛けて物を置けるようにするボードを買いました。思ったより便利に小物を置けるので良さげです。

さて、今回は Google Play Billing Library (以下Billing Library) を、2021年5月にリリースされたv4.0.0へ更新したことについてお話します。 また関連事項として、以前のブログでお話ししました定期購入のグレード切り替えについても、新しいものが追加されていましたので軽く触れておきます。

tech.studyplus.co.jp

Google Play Billing Library 4.0.0

今年の5月に開催されましたGoogle I/O 2021において、もはや毎年恒例となったと言うべきでしょうか、Billing Libraryの最新バージョンであるv4.0.0が公開されました。

www.youtube.com

紹介された新機能

リリースはまだのようですが、発表された新機能について軽く触れておきます。

  • Multi-quantity Purchase
    • 一度に複数のアイテムを購入できるようになる機能
    • 動画の9:42辺りから、いわゆる買い切りアイテムについて同じものを複数まとめて買えるようになるものと想定
  • Multi-line Subscription
    • 複数の商品を単一の定期購入として販売できる機能
    • 動画の9:49辺りから、ユーザーがこれとこれというように纏めるのではなく、サービス側でセットを定義するものと想定
  • Prepaid Plans
    • ユーザーが希望する一定期間のみコンテンツへアクセスできるようにする機能
    • 動画の9:53辺りから、1タームだけ買うというような形で自動解約のできる定期購入方法と想定

I/Oでの発表以来続報が見当たらないので半分は想定ですが、今後も発表があればまた追っていきたいです。 *1

コード上の変更点

本題です。

最初に参考資料として公式のサンプルコードから、対応コミットのリンクを置いておきます。

github.com

では改めまして、Studyplus Androidがどう対応したのかコードを交えて説明していきます。

v2.0.0 -> v3.0.0 の時は一部のNullable->NonNullだったり、MutableList->ImmutableListの変更程度で済んでいました。 しかし、v3.0.0 -> v4.0.0 の変更はもう少しクリティカルな変更となっています。

対応が必要になった変更点は大まかに3つです。 リリースノートからの引用文と共に見ていきます。

1. Purchase#getSku() -> Purchase#getSkus()

Added Purchase#getSkus() and PurchaseHistoryRecord#getSkus(). These replace Purchase#getSku and PurchaseHistoryRecord#getSku which have been removed.

一度の購入についての情報が入った Purchase クラスには、購入したアイテムのSku(実態はID文字列)を取得するメソッドが用意されています。 *2

v3.0.0まででは1回の購入で1つのアイテムしか買えませんでしたので、取得するメソッドも当然Sku(String)単体を返すものでした。

@zzb
@NonNull
public String getSku() {
    return this.zzc.optString("productId");
}

しかし、前節で紹介したMulti-quantityやMulti-lineといった新機能の対応なのか、v4.0.0ではArrayList<String>に返り値が変更されています。 メソッド名も複数形に変更されていますね。

@zzc
@NonNull
public ArrayList<String> getSkus() {
    ArrayList var1 = new ArrayList();
    if (this.zzc.has("productIds")) {
        JSONArray var2 = this.zzc.optJSONArray("productIds");
        if (var2 != null) {
            for(int var3 = 0; var3 < var2.length(); ++var3) {
                var1.add(var2.optString(var3));
            }
        }
    } else if (this.zzc.has("productId")) {
        var1.add(this.zzc.optString("productId"));
    }

    return var1;
}

変更自体は単純ですが、当然ビルドは通らなくなるので対応は必要です。 まだ機能がリリースされていない以上複数入ってくることは事実上ありませんし、基本的には単純にfirst()またはfirstOrNull()などで取得するだけですね。

// v3.0.3
val sku = purchase.sku
// v4.0.0
val sku = purchase.skus.first()

ArrayListがempty時のハンドリングですが、ユースケースとして「購入していない」ならそもそもPurchase自体がなく、「PurchaseがSkuを持っていない」ことも考えづらいです。 *3

v3.0.0の返り値に@NonNullアノテーションがついていたこともありますし、emptyは異常系エラーと考えて、first()で取得できます。

2. BillingClient#queryPurchasesAsync() の追加と BillingClient#queryPurchases() の削除予告

Added BillingClient.queryPurchasesAsync() to replace BillingClient.queryPurchases() which will be removed in a future release.

ユーザーが現在購入済みのPurchaseをGooglePlayへ確認、取得するためのメソッドです。 v4.0.0では非同期取得のメソッドが追加され、既存のものには@Deprecatedアノテーションが付与されています。 *4

@Deprecated
@NonNull
public abstract PurchasesResult queryPurchases(@NonNull String var1);

@zze
@AnyThread
public abstract void queryPurchasesAsync(@NonNull String var1, @NonNull PurchasesResponseListener var2);

この対応ですが、公式サンプルでは以下のようにPurchasesResponseListenerをクラスに実装してoverrideしています。

// 公式サンプルから抜粋、実装しているクラスでListenerをoverrideしている
class BillingClientLifecycle private constructor(
    private val app: Application
) : LifecycleObserver, PurchasesUpdatedListener, BillingClientStateListener,
        SkuDetailsResponseListener, PurchasesResponseListener {

    fun queryPurchases() {
        if (!billingClient.isReady) {
            Log.e(TAG, "queryPurchases: BillingClient is not ready")
        }
        Log.d(TAG, "queryPurchases: SUBS")
        billingClient.queryPurchasesAsync(BillingClient.SkuType.SUBS, this);
    }

    /**
     * Callback from the billing library when queryPurchasesAsync is called.
     */
    override fun onQueryPurchasesResponse(billingResult: BillingResult,
                                          purchasesList: MutableList<Purchase>) {
        processPurchases(purchasesList);
    }

これでも全く問題ありませんが、実はKotlin Coroutinesを利用したktxが用意されていますので、Kotlinのプロダクトであればこちらも利用できます。

// BillingClientKotlinKt.class
public suspend fun @receiver:androidx.annotation.RecentlyNonNull com.android.billingclient.api.BillingClient.queryPurchasesAsync(skuType: kotlin.String): com.android.billingclient.api.PurchasesResult { /* compiled code */ }
suspend fun queryPurchases() {
    val result = billingClient.queryPurchasesAsync(skuType = BillingClient.SkuType.SUBS)
    processPurchases(result.purchasesList)
}

Studyplus Androidでは後者で実装しており、queryPurchasesAsync()を含むメソッドを呼ぶ際に、呼び出し側が自分に適したScopeで呼び出すように変更しました。

// Fragment側
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
    val ownedPurchaseList: List<Purchase> = billingClientLifecycle.fetchAllPurchases()
    // 以下色々処理
}

// BillingClientLifecycle
suspend fun fetchAllPurchases(): List<Purchase> =
    billingClient
        .queryPurchasesAsync(skuType = BillingClient.SkuType.SUBS)
        .purchasesList

Kotlin Coroutines利用に関しては特にBilling専用処理などが必要になるわけでもないので、元々利用されているプロダクトならばそのまま利用できます。

3. 定期購入のグレード切り替え時のパラメータがBuilderパターン化

Added BillingFlowParams.Builder.setSubscriptionUpdateParams() as a new way to initiate subscription updates. This replaces BillingFlowParams#getReplaceSkusProrationMode, BillingFlowParams#getOldSkuPurchaseToken, BillingFlowParams#getOldSku, BillingFlowParams.Builder#setReplaceSkusProrationMode, BillingFlowParams.Builder#setOldSku which have been removed.

購入処理を開始する際にBuilderで作成するBillingFlowParamsですが、元々定期購入のグレード切り替え用パラメータも個別にセットする方式でした。

Studyplus Androidでも、グレード切り替えは未対応なのですが、準備としてコード上は切り替え用の分岐が実装されております。

// v3.0.3

fun launchBillingFlow(activity: Activity, skuDetails: SkuDetails) {
    val builder = BillingFlowParams
        .newBuilder()
        .setSkuDetails(skuDetails)

    // 現状の購入済み定期購入アイテムを確認
    val ownedPurchaseList: List<Purchase> = billingClient
        .queryPurchases(BillingClient.SkuType.SUBS)
        .purchasesList
        .orEmpty()

    val billingParams = if (
        ownedPurchaseList.isNotEmpty()
        && ownedPurchaseList[0].sku != skuDetails.sku
    ) {
        // region ここの話!
        // 定期購入をすでにしているならBillingFlowParamsに定期購入グレード切り替え用のパラメータを個別にセット
        val ownedPurchase = ownedPurchaseList[0]
        builder
            .setOldSku(ownedPurchase.sku, ownedPurchase.purchaseToken) // 切り替え前の定期購入
            .setReplaceSkusProrationMode(BillingFlowParams.ProrationMode.IMMEDIATE_WITHOUT_PRORATION) // 切り替えに利用する比例配分モード
            .build()
        // endregion
    } else {
        builder.build()
    }

    billingClient.launchBillingFlow(activity, billingParams)
}

この定期購入グレード切り替え用のパラメータを1つにまとめたSubscriptionUpdateParamsが追加され、これ自身もBuilderパターンで作成してセットする方式になりました。

// v4.0.0

suspend fun launchBillingFlow(activity: Activity, skuDetails: SkuDetails) {

    // 現状の購入済み定期購入アイテムを確認
    // 先述の queryPurchasesAsync() を含むため、メソッドがsuspend functionになっています
    val ownedPurchase = billingClient
        .queryPurchasesAsync(skuType = BillingClient.SkuType.SUBS)
        .purchasesList
        .firstOrNull()

    val billingParams = if (
        // 先述の getSkus()
        ownedPurchase != null
        && ownedPurchase.skus.first() != skuDetails.sku // 保有しているアイテムと購入しようとしているアイテムのSkuを比較
    ) {
        // region ここの話!
        // 定期購入をすでにしているなら SubscriptionUpdateParams を作成、パラメータをセット
        val updateParams = BillingFlowParams.SubscriptionUpdateParams
            .newBuilder()
            .setOldSkuPurchaseToken(ownedPurchase.purchaseToken) // 切り替え前の定期購入
            .setReplaceSkusProrationMode(BillingFlowParams.ProrationMode.IMMEDIATE_WITHOUT_PRORATION) // 切り替えに利用する比例配分モード
            .build()

        BillingFlowParams
            .newBuilder()
            .setSkuDetails(skuDetails)
            .setSubscriptionUpdateParams(updateParams) // ここでまとめてセット
            .build()
        // endregion
    } else {
        // 新規購入
        BillingFlowParams
            .newBuilder()
            .setSkuDetails(skuDetails)
            .build()
    }

    billingClient.launchBillingFlow(activity, billingParams)
}

Studyplusでは、プロダクトとして細かいグレード切り替えを想定していないのでワンパターンのパラメータで済んでいました。 一方、色々な定期購入プランがあって複数の切り替えパターンが存在するようなプロダクトの場合、専用のParamsクラスとして分離されることで場合分けを管理しやすくなるでしょう。

新しい比例配分モード

以前のブログでは、この比例配分モードについて4つを紹介しました。

Billing Library v4.0.0リリースノートには、その比例配分モードに新しい仲間が加わったと書かれています。

Added new subscription replacement mode IMMEDIATE_AND_CHARGE_FULL_PRICE.

IMMEDIATE_AND_CHARGE_FULL_PRICE

以前のブログと同様に、公式ドキュメントから抜粋しつつ紹介します。 また、既存のモードに関しても公式ドキュメントの翻訳が更新されているため、改めて併記しています。

developer.android.com

フラグ名 挙動
IMMEDIATE_WITH_TIME_PRORATION 定期購入は直ちにアップグレードまたはダウングレードされます。残りの期間は価格の差に応じて調整され、次回の請求日との差分が新しい定期購入に充当されます。これがデフォルト設定です。
IMMEDIATE_AND_CHARGE_PRORATED_PRICE 定期購入は直ちにアップグレードされますが、請求期間は変わりません。ユーザーには残りの期間の差額が請求されます。
IMMEDIATE_WITHOUT_PRORATION 定期購入は直ちにアップグレードまたはダウングレードされ、定期購入の更新時に新しい価格が請求されます。請求期間は変わりません。
DEFERRED 定期購入は、更新時にのみアップグレードまたはダウングレードされます。
IMMEDIATE_AND_CHARGE_FULL_PRICE 定期購入がアップグレードまたはダウングレードされると、ユーザーには新しい利用資格の全額が直ちに課金されます。以前の定期購入の残額は、同じ利用資格に引き継がれるか、別の利用資格への切り替え時に期間内で比例配分されます。

筆者なりのまとめです。

  • IMMEDIATE_AND_CHARGE_FULL_PRICE
    • ストア上における所有定期購入は即変更
    • 日割計算して新しい定期購入の残り日数を変更する
    • 変更時に新しい方の定期購入の購入金額を満額支払う
    • 月額->年額の場合、月額分の600円がすでに払われているので、4,800円/年で換算して1ヶ月半分が先払いされた換算になる。そのため次回更新日が新しく払った満額分1年+日割り換算済みの先払い分1ヶ月半後となる
    • 年額->月額の場合、年額分の4,800円がすでに払われているので、600円/月で換算して8ヶ月分が先払いされた換算になる。そのため次回更新日が新しく払った満額分1ヶ月+日割り換算済みの先払い分8ヶ月後となる

他のIMMEDIATE系のモード(操作直後に所有プランが切り替わるモード)と違う点として、「切り替え時に満額を絶対支払う」ことが挙げられますね。

また、前回は見当たらなかった記憶なのですが、合わせて更新されたのか切り替えケース別の推奨表が公開されていました。 こういったものが公式から公表されるのは心強いですね。

さまざまな定期購入が存在し複数の切り替えパターンを必要とするようなプロダクトの場合は、この表を基にプロダクトの事情を鑑みてパーソナライズした切り替え表を作ると良さそうな認識です。

終わりに

今回は、Google Play Billing Libraryの v4.0.0 について、Studyplus Androidで対応した変更点についてまとめてみました。 また、定期購入のグレード切り替えについて、以前のブログで紹介した情報からの更新があった点についてもご紹介しました。

Billing Libraryは毎年更新されることと、旧バージョンのサポートは2年であることが公言されています。 お金に関するセンシティブな部分であることも含めて、Studyplus Androidではこれからも慎重かつ早めに余裕を持って更新していきたい所存です。

スタディプラスではAndroidに限らず、挑戦からの失敗を尊ぶ意識でもって開発を行なっています。 Studyplus事業部、For School事業部それぞれ一緒に働いてみたいと思ってくださるメンバーを募っておりますので、ご興味持たれました方はぜひご一読お願いいたします。

open.talentio.com

open.talentio.com

最後までご覧いただき、ありがとうございました! 内容に関する訂正、ツッコミ等あればご一報いただけると幸いです!

*1:こういう情報公開されてるよってご存知の方いらっしゃれば、ご指摘くださると嬉しいです!

*2:ちなみに「Stock Keeping Unit」の略で、物流における最小管理単位の意味とのことです 参考:物流現場通信

*3:レシートに買った商品名が書いてなかったらおかしいですよね

*4:BillingClient自体はabstract classなので今回は実装部の引用を割愛しますが、中の処理が見たければBillingClientImplをご参照ください