Google Play アプリ内定期購入を実装する

技術部モバイル基盤グループの宇津(@)です。

今年3月、クックパッドのAndroidアプリはこれまでサポートしていたクレジットカード、キャリア決済に加えて、Google Playアプリ内定期購入によるプレミアムサービス登録機能を追加しました。

f:id:himeatball:20180313181401p:plain

Google Playのアプリ内定期購入機能は個人的に気に入っているので早速乗り換えました。

良い機会なので、この記事ではIn-app Billing version 3(以下IABv3)以降のアプリ内課金の実装を振り返りつつ、Google Play Billing Libraryの紹介も交えながら開発TIPSを紹介します。

TIPS1. Google Play Billing Libraryの採用

IABv3以降のアプリ内課金を実施するためには(ざっくりですが)以下のフローを実装する必要がありました。

  1. ServiceConnection を生成し、 IInAppBillingService にバインドする
  2. 購入可能な商品情報を IInAppBillingService から取得する
  3. 購入する商品のパラメータを含む Bundle (以下 BuyIntentBundle )を IInAppBillingService 経由で生成する
  4. BuyIntentBundle に含まれる PendingIntent を取得する
  5. PendingIntent を用いて Activity#startIntentSenderForResult を呼び出し、Google Play決済画面を立ち上げる
  6. Google Play決済画面上での精算結果は Activity#onActivityResult にて返却される
  7. 返却された精算結果を元に(レシート検証等も実施しつつ)よしなに処理する

より詳細なフローは公式ドキュメントを参照ください。これに加えて、IabHelper等のサンプル実装クラスが提供されており これらを元に各位アプリ内課金を実装していたと思いますが、その内容もそこそこにAndroid開発経験がないと難しい内容となっており、ただでさえ難易度の高い決済周りの実装にさらに多くのドメイン知識を求められるものとなっていました。

さらに、素直にこれを実装した場合、処理フロー5,6 がある以上、既存の画面表示処理にアプリ内課金処理実装が混ざってしまう事になってしまいます。 決済が絡んでくる箇所においてはメンテナンス容易性の側面から避けたい所です。 その対策として、購入用のActivityを1つ用意し、そちらに処理フロー5, 6を移譲する、といった工夫がされてきたと思います。

2017年9月、GoogleからGoogle Play Billing Libraryがリリースされました。

このライブラリは前述の購入処理の分離がなされており、比較的アプリ内課金処理の分離がしやすい状況になりました。 私自身、 IInAppBillingService を直接取り扱う事から卒業したい思いがあり、こちらのライブラリを利用する事にしました。

ライブラリ自体にこれといった問題もなく使えているのですが、いくつか懸念点がありました。そちらについてはTIPS2, 3にて紹介します。

TIPS2. Developer Payload非推奨に対する対処

Developer PayloadとはIABv3時代からある、「購入レシートの中に含める事のできる文字列フィールド」を指します。

多くの開発者はこのフィールドに、サーバ上で発行した購入トランザクションに対して一意な識別子を入れる事で、Google Playアプリ内課金に対する問い合わせ対応等に役立てていたかと思います。

しかし、このフィールドは現在非推奨となっており、Google Play Billing LibraryにおいてもDeveloper Payloadフィールドを指定する事が出来ません。

クックパッドのAndroidアプリでは、購入/復元(ユーザ様のアプリ内定期購入の購入情報を元にプレミアムサービス登録を再開する)時に都度識別子をサーバ上で発行し、レシート検証が必要なタイミングで復元するレシートとセットで送信するようにして代替しています。

こうする事で、Developer Payloadが存在していた時代では購入時に一度だけサーバ上で発行すれば良かった識別子が、無駄に発行されてしまう事が懸念されますが、これは許容する方向に倒しています。

TIPS3. ラッパーライブラリによるドメイン知識のさらなる吸収

アプリ内定期購入に限らずですが、決済処理というのは想定以上に実装が複雑になりがちです。 クックパッドのようにプレミアムサービスという商品を1つだけ取り扱うにしても、自サービス内のアカウント種別に加え、決済手段も複数あり、それらの整合性を取りながら決済処理を実装しなければならないとなると サーバサイドはもちろんの事、アプリ単体でも中々に複雑な実装になる事が予想できます。

その為、Google Play Billing Libraryでも吸収できていない、アプリ内課金処理固有のドメイン知識をもっと吸収して、利用者側のソースコードのメンテナンス容易性を上げたい気持ちがありました。 そこで tsuruhashi という名のラッパーライブラリを(社内向けに)開発し、クックパッドのAndroidアプリではこれを使用しています。

tsuruhashiでは以下の3点をうまく吸収し、関心事を分離しています。

TIPS3-1. BillingClientをいい感じにpoolingして ServiceConnection を意識させない

BillingClient はGoogle Play Billing Libraryに含まれる、 IInAppBillingService とのやり取りや決済Activityの起動を行うクラスです。

BillingClient はいつ ServiceConnection が切断されるか分からないので、利用者側でそのライフサイクルをしっかり管理する前提の実装にするのが無難です。 でも、そもそもこういった事はそもそも意識したくないですね。

tsuruhashiでは BillingClient インスタンスの生成を常に1つに抑えるようpoolingを行い、 ServiceConnection が切断されたら自動的にpoolから破棄するような機構を設けています。

ついでではありますが、 BillingClient は 内部的に Handler インスタンスを生成する為にメインスレッドでの生成が必須ですが、これも意識したくないのでライブラリ側で吸収しています。

TIPS3-2. BillingClient の全 interface を Rx friendly にして記述しやすく

クックパッドのAndroidアプリではRxJavaが導入されているので、アプリ内定期購入においてもRxなinterfaceで実装したい需要がありました。 BillingClient そのままの状態でもRx化は可能ですが、1つのメソッドだけ実現方法に悩むかもしれません。

それは BillingClient#launchBillingFlow です。

このメソッドは返却値としては決済Activityの起動等に成功したか否かが返却され、実際の購入結果は BillingClient の生成時に引数として渡した PurchasesUpdatedListener に渡されます。 なので、メソッドの呼び出し元と購入結果の受け取り口が離れてしまいます。

tsuruhashiでは以下のように対応しました。まず、 PurchasesUpdatedListener を実装します。

class CompositePurchasesUpdatedListener : PurchasesUpdatedListener {
    private val listeners: MutableList<PurchasesUpdatedListener> = mutableListOf()
    private val lock:ReentrantReadWriteLock = ReentrantReadWriteLock()

    fun add(listener: PurchasesUpdatedListener): () -> Unit {
        lock.write {
            if (!listeners.contains(listener)) {
                listeners.add(listener)
            } }
        return { lock.write { listeners.remove(listener) } }
    }

    fun remove(listener: PurchasesUpdatedListener): Boolean = lock.write { listeners.remove(listener) }

    fun clear() = lock.write { listeners.clear() }

    override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) {
        val list = lock.read { listeners.toList() }
        list.forEach { it.onPurchasesUpdated(responseCode, purchases) }
    }
}

これを BillingClient 生成時に listener として登録します。

val listener = CompositePurchasesUpdatedListener()
val client = BillingClient
        .newBuilder(context)
        .setListener(listener)
        .build()
val wrapper = BillingClientWrapperImpl(client, listener)

wrapper内での BillingClient#launchBillingflow の呼び出しは以下のように行います。(説明の為、一部省略しています)

var removeListenerFunc:(() -> Unit)? = null
removeListenerFunc = compositeListener.add(PurchasesUpdatedListener { responseCode, purchases ->

    if (/* 長いので省略 `PurchaseUpdatedListener#onPurchasesUpdated` がこのブロック内で捌ける時にlistenerを破棄 */) {
        removeListenerFunc?.invoke()
    }

    when (responseCode) {
        BillingResponse.OK -> {
            if (purchases.any { it.sku == params.sku }) {
                /* 省略 */
            }
        }
        /* 省略 */
    }
})

val launchResponseCode = client.launchBillingFlow(activity, params)
if (launchResponseCode != BillingResponse.OK) {

    // BillingClient#launchBillingFlowに失敗したらその時点でlistenerを破棄
    removeListenerFunc?.invoke()
    when (launchResponseCode) {
       /* 省略 */
    }
}

Rxな表現でいえばSubject(hot-observable)を内包する形ですね。 このような呼び出し方にする事で、非Rxの世界ではcallback interfaceで、Rxの世界においてもRx friendlyなinterfaceで購入結果を取り扱えるようになっています。

fun launchBillingFlow(activity: Activity, params: BillingFlowParams, listener:LaunchBillingFlowListener) // 非Rx
fun launchBillingFlow(activity: Activity, params: BillingFlowParams): Single<BillingResult>              // Rx

TIPS3-3. 汎用的に必要となるinterfaceを追加して利用者側のコードの理解可読性の向上を図る

アプリ内定期購入の実装においては多くの場合、以下の2つのFeatureTypeに対応しているかを確認します。

  • FeatureType.SUBSCRIPTIONS
  • FeatureType.SUBSCRIPTIONS_UPDATE

そうなった際に FeatureType をまとめて確認できないと流石に利用者側のコードが冗長になってしまうので、複数の FeatureType をまとめて確認できるようなinterfaceを追加しています。

fun verifyFeaturesSupported(features: List<String>, listener: VerifyFeaturesSupportedResponseListener) // 非Rx 
fun verifyFeaturesSupported(features: List<String>): Completable                                       // Rx

上記のような、汎用的に必要となる機能についてはライブラリ機能として提供し、利用者側のソースコードの冗長化を抑止しています。

TIPS4. レシート情報は多少過剰にでもサーバサイドで保存する

決済系の格言として「まずは保存」というのがあります。

サーバサイド寄りの話になりますが、アプリからレシートを送信するようなエンドポイントを用意する場合、まず第一にアプリから送信されたレシートをDBに保存し、その後レシート検証や定期購入の開始処理といったエンドポイントの本来求められている処理を実施するようにしています。

これは、レシート検証や定期購入の開始処理に万が一不具合がありレシートがDB上に保存されず、その状態でユーザ様から問い合わせがあった場合、DB上にはレシートが存在しないため、どうしても煩わしいやり取りが発生してしまい、ユーザ様に悪い印象を与えてしまう恐れがある為です。

レシートの保存さえ出来てしまえば、Google Play Developer APIを利用する事で、ユーザ様の問い合わせに対して購入のキャンセルや払い戻し対応も可能になるので、この格言に習う事に越した事はありません。

TIPS5. 購入トランザクション上のログを細かく取る

決済系に限らず「まずはログ」という所で、TIPS4に関連して、ユーザ様が商品の購入に失敗した際、どの処理中に購入に失敗してしまったのかを追跡可能な状態にする必要があります。

クックパッドのAndroidアプリでは、よりユーザ様の購入状況が追跡できるよう、購入/復元時に実施する処理のログをサーバログとは別にアプリから送信・集計し、 問い合わせがあったユーザ様に対し適切にサポート対応が実施できるよう環境を整えています。

まとめ

クックパッドのAndroidアプリでは、Google Playアプリ内課金実装に対するドメイン知識をラッパーライブラリ上でいい感じに吸収した甲斐もあって、アプリケーション上の実装はクックパッドのプレミアムサービスのドメイン知識のみで満たされた実装に仕上がっています。[TIPS1, 3]

Developer Payloadは非推奨になってしまいましたが、それでもなんとかうまくやっています。[TIPS2]

ユーザ様に対し適切にサポート対応が実施できるよう、レシートの取扱い方とログ集計を中心に環境を整備しています。[TIPS4, 5]

今後の展望としては、まずはTIPS3で紹介した開発した社内ライブラリをオープンソース化したいという所と、まだまだ運用面を見据えて改善したい箇所が沢山あります。例えば、

  • テスト購入の為のAndroid端末上の設定の自動化
  • 購入/復元処理のE2Eな自動テスト
  • 決済処理実装に対する敷居を下げる為のあれこれ

といった事があげられます。 今後も大きな進歩があればtechlifeで報告していきます。

最後に、クックパッドではより良いサービスを提供し続ける為にエンジニアを募集しています。 もしこの記事を読んで興味を持たれたAndroidエンジニアの方、あるいは決済処理に熱い思いのあるエンジニアの方いらっしゃいましたら、是非遊びに来てください。 ご連絡をお待ちしています!

クックパッド株式会社 採用情報

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/