コネヒト開発者ブログ

コネヒト開発者ブログ

Android版ママリアプリのリファクタ事情 ~ Google Play Billing Library 4導入編 ~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。前回のエントリーから、髪の毛はアップデートされておりません。

さて今回は、Android版ママリアプリの課金機能にまつわるお話をお伝えできればと思います。

はじめに

2021 年 11 月 1 日にGoogle Playのアップデートにより、アプリ内課金機能ライブラリの Billing Library で v3 以降が必須となりました。

Google Play Billing Library のバージョンのサポート終了  |  Google Play の課金システム  |  Android Developers

ママリアプリではアプリ内課金の定期購入機能で、プレミアムサービスを提供していますが、Billing Library v2に依存していたため、期日までにライブラリのアップデートをする必要がありました。 今回は、BillingLibrary v4の紹介をしつつ、ライブラリのアップデート時の課金機能のリファクタリングの事例を紹介します。

Billing Library v4の紹介

まず初めにアプリ内課金を実装するための、最新のライブラリであるBilling Library v4の使い方をを紹介をします。

接続と破棄

アプリ内課金機能を利用するには、Google Playと接続し課金処理を実行する必要があるため、まずはその準備が必要となります。

BillingClientを生成し、Google Playに接続を行います。課金アイテムの取得、購入/リストアといった課金操作は、基本的にBilingClientのメソッドを使用します。また接続を残し続けると、メモリリークが発生するため、不要になったら破棄が必要になります。

// BillingClientの生成
private val billingClient by lazy {
        BillingClient.newBuilder(context)
          .setListener(object : PurchasesUpdatedListener {
              override fun onPurchasesUpdated(result: BillingResult, list: MutableList<Purchase>?) {
                  // 課金状態が更新されると呼び出される。
              }
          })
          .enablePendingPurchases()
          .build()
}

// Google Playに接続し、課金機能の呼び出し準備をする
fun initialize() {
        billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingServiceDisconnected() {
                    // 切断が切れたら呼び出される。必要に応じて再接続処理など
        }

        override fun onBillingSetupFinished(result: BillingResult) {
                    // 初期化が完了すると呼び出される。ここが呼ばれないと課金処理が始められない。
        }
    })
}

// 不要になったら接続を切る
fun release() {
    billingClient.endConnection()
}

課金情報の取得

課金を開始する前に、現在の課金状態を確認する必要がある場合、queryPurchasesAsyncメソッドを利用して現在の課金情報を取得します。

queryPurchasesAsyncメソッドは、Google Playから現時点の課金情報をリクエストします。 契約が存在する場合、Purchaseクラスのリストが返却されるので、Purchaseクラスが提供する購読情報を参照することができます。

private fun queryPurchases() {
      billingClient.queryPurchasesAsync(
                    BillingClient.SkuType.SUBS // 課金種別の指定
            ) { result, list ->
          val responseCode = result.responseCode
          if (responseCode == BillingClient.BillingResponseCode.OK) {
                            // 成功の場合は、Purchase型がリストで返される
          } else {
                            // OK以外はエラー
          }
      }
  }

ママリアプリでは、重複課金を防ぐために、現在の課金情報を取得してバリデーションを行なっています。

課金履歴の取得

過去の課金履歴が必要な場合は、queryPurchaseHistoryAsyncメソッドを呼び出します。購入の有効期限が切れていたり、キャンセルされていても、最後に購入した購読情報が返却されます。

billingClient.queryPurchaseHistoryAsync(
        BillingClient.SkuType.SUBS
) { result, list ->
    val responseCode = result.responseCode
    if (responseCode == BillingClient.BillingResponseCode.OK) {
        // 成功の場合は、PurchaseHistoryRecordがリストで返される
    } else {
                // OK以外はエラー
    }
}

ママリアプリでは、過去の課金状態に応じて、ユーザーにお得なキャンペーンを訴求することをしています。

課金処理の実行

実際に課金を行うためには、2つの作業が必要になります。

  1. 購読に必要なSkuDetailsを取得する
  2. SkuDetailsを元に課金処理を起動する

このクラスをlaunchBillingFlowメソッドに渡すことで、課金処理が開始されます。画面操作を伴うためか、activityを渡す必要があります。

※SkuDetailsには課金アイテムの名称や価格の情報が含まれているため、ユーザーに見せるために使うことも可能です。

var skuDetails: List<SkuDetails> = emptyList()

private fun querySkuDetails() {
        val params = SkuDetailsParams.newBuilder()
            .setType(BillingClient.SkuType.SUBS)
            .setSkusList(listOf(itemType.id)) // 課金アイテムのIDリスト
            .build()
        
    billingClient.querySkuDetailsAsync(params) { result, list ->
        val responseCode = result.responseCode
        if (responseCode == BillingClient.BillingResponseCode.OK) {
            skuDetails = list // 成功するとSkuDetailsがリストで返される。このリストを元に購読処理を起動。
        } else {
        }
    }
}

private fun launchBilling() {
        val builder = BillingFlowParams.newBuilder()
        builder.setSkuDetails(skuDetails).build()
        // 購読処理にはactivityが必要
        billingClient.launchBillingFlow(activity, builder.build()) 
}

リファクタリングの方向性

冒頭で書いた通りママリアプリではv2に依存したライブラリで、定期購読の機能を実現していましたが、複数の画面や機能から購読機能を利用したいというビジネス要件や自社APIやBillingClientとの連携でステータスを管理する必要があるといった技術的な制約を実現するために、既存のコードは非常にファットな状態でした。今回はv4への置き換えと並行して課金ロジックのリファクタリングも進めていました。

これらの事情を踏まえて、最終的にBillingActivity、BillingViewModel、BillingManagerの3つのクラスに責務を分離した設計となりました。

f:id:katsutomu0124:20220304185704p:plain 処理の流れは以下の通りです。

  1. プレミアム会員登録画面で、課金ボタンをタップするとBillingActivityが起動する
    1. 半透明なActivityなため元の画面にオーバーレイされる
  2. ActivityのonCreateをトリガーにBillingViewModelがBillingManagerを呼び出しGooglePlayの課金処理を開始する
  3. BillingManagerでBillingClientのメソッドを呼び出しGoogle Playの課金処理を開始する
  4. BillingManagerが結果を元にBillingViewModelが自社APIをコール、ステータスに応じて、BillingActivityのUIを更新する

 

それぞれの役割と処理の流れをソースコードと合わせて紹介します。

BillingManager

このクラスはGooglePlayの課金機能を呼び出すことと、BillingClientの結果を元にしたステータス管理を担当しています。課金機能の初期化の成否や、Google Playの操作、課金結果の待ち受けなど複数の状態を監視する必要があるため、RxJavaのCombineLatestを利用し、複数の状態を集約してステータスを更新しています。

処理の流れは以下の通りです。

  1. BillingClientとの接続と現在の課金情報の取得を開始
  2. Activityを引渡し、GooglePlay課金の開始
  3. Google Play課金のステータス監視。最後まで成功したら社内APIを呼び出すステータスに移行する
// 1. BillingClientとの接続と現在の課金情報の取得を開始
fun initialize(billingType: BillingType) {
    billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(billingResult: BillingResult) {
            val responseCode = billingResult.responseCode
            if (responseCode == BillingClient.BillingResponseCode.OK) {
                when (billingType) {
                    is BillingType.Purchase, BillingType.Restore -> {
                        querySkuDetails()
                        queryPurchases()
                    }
                }
            }
        }
    })
}

・・・・・

// 2. Activityを引渡し、GooglePlay課金の開始
fun doSubscribe(activity: AppCompatActivity, readyToPurchased: ReadyToPurchased) {
    val type = readyToPurchased.billingType
      val builder = BillingFlowParams.newBuilder()
    builder.setSubscriptionUpdateParams(
        BillingFlowParams.SubscriptionUpdateParams.newBuilder()
            .setOldSkuPurchaseToken(readyToPurchased.purchases[0].purchaseToken)
            .build()
    )
    builder.setSkuDetails(readyToPurchased.skuDetails).build()
    billingClient.launchBillingFlow(activity, builder.build())
    _purchasing.onNext(true)
}

・・・・・

// 3. Google Play課金のステータス監視。最後まで成功したら社内APIを呼び出すステータスに移行する
Observables.combineLatest(
    _initialized, // 課金手続き準備完了のステータスの監視
    _purchasing, // 課金手続き中ステータスの監視
    _billingResult // 課金結果の監視
) { initializeStatus, purchasing, billingResult ->
    var nextStatus = _status.value
    when (_status.value) {
                
                ・・・・・             

        is ReadyToPurchased -> {
                        nextStatus = // 一度Activityに準備完了を通して、課金手続き中ステータスに移行
        }
        is Purchasing -> {
                        nextStatus = // 課金完了後に社内APIのコールを行うReadyToRegisterのステータスに移行
        }
    }
    _status.onNext(nextStatus)
}.subscribe().addTo(compositeDisposable)

BillingViewModel

このクラスはGooglePlayの課金処理の呼び出し社内APIの登録と、その間の状態変化をActivityに伝える責務を担当しています。処理の流れは以下の通りです。

  1. BillingManagerの初期化完了を待ち、課金機能の開始を指示する.
  2. BillingManagerのステータスを監視し、UI表示をActivityに通知する。
  3. GooglePlay課金完了後、社内APIに課金情報を送信し、ユーザーをプレミアム会員に昇格する。
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun onCreate() {
        billingManager.status.distinctUntilChanged().subscribe { status ->
            when (status) {
                        // 1. BillingManagerの初期化完了を待ち、課金機能の開始を指示する
                is BillingManger.ReadyToPurchased -> { startPurchase.postValue(status) }
                        // 3. GooglePlay課金完了後、社内APIに課金情報を送信し、ユーザーをプレミアム会員に昇格する。
                is BillingManger.ReadyToRegister -> { registerReceipt(status.purchasedData) }
            }

                // 2. BillingManagerのステータスを監視し、UI表示をActivitiyに通知する。
        // ※ユーザー操作ブロックのためのローディング表示
            showProgress.postValue(
                status is BillingManger.Initializing ||
                    status is BillingManger.ReadyToPurchased ||
                    status is BillingManger.ReadyToRegister
            )
        
        }.addTo(compositeDisposable)
}

 // 不要になったら解放
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
    billingManager.release()
    compositeDisposable.clear()
}

BillingActivity

このクラスはUI操作をViewModelに伝えることと、状態に応じたUI操作を担当しています。処理の流れは以下の通りです。

  1. 課金機能の画面で課金ボタンやリストアボタンのタップをトリガーに、Activityが起動する。
  2. GooglePlay課金の準備が完了したら、BillingActivity自身を渡して課金処理を開始する
  3. 課金処理が終了するまでBillingViewModelのステータス更新を監視し、状況に応じてUIの更新を行う。
// 1. 課金機能の画面で課金ボタンやリストアボタンをタップをトリガーに、Activityが起動する。
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

                // 2. GooglePlay課金の準備が完了したら、自分自身を渡して課金処理を開始する
                viewModel.startPurchase.observe(this) {
            viewModel.onSubscribe(this, it)
        }

                // 3. 課金処理が終了するまでViewModelのステータス更新を監視し、状況に応じてUIの更新を行う。
                // ローディング表示
        viewModel.showProgress.observe(this) {
            if (it) {
                binding.progress.toVisible()
            } else {
                binding.progress.toGone()
            }
        }
                // プレミアムユーザー昇格の通知
                viewModel.purchaseRegistered.observe(this) {
            Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG)
                .show()
        }
    }

抜粋した内容となりますが、以上のように、3つのクラスに分けて責務を分離し、それぞれの役割を明確にすることで、見通しの良いコードになるようにリファクタリングを進めました。大規模なリファクタリングとなったため、アップデート後の不具合が懸念されましたが、アップデート以降課金に関係したトラブルは発生せず、数ヶ月が経過したので安心しています。

※もちろん事前に全ての課金機能の動作確認は実施しています。

おわりに

今回はライブラリアップデートのきっかけに、課金機能のリファクタリングを行なった事例をお伝えいたしました。前回までに紹介したリファクタリングの方針が、課金機能のリファクタリング後に決まったので、一部既存の理想系と外れる部分もありますが、今後も継続的に改善を進めていく予定です。最後までお読みいただきありがとうございました!

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co