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エンジニアの方、あるいは決済処理に熱い思いのあるエンジニアの方いらっしゃいましたら、是非遊びに来てください。 ご連絡をお待ちしています!

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

firebase.yebisu #2 の開催報告

こんにちは。事業開発部で新規事業に取り組んでいる高田です。

Cookpad の新規事業と Firebase でもご紹介したとおりクックパッドでは Firebase を活用しはじめています。そのような流れもあり2018年2月20日に Firebase.yebisu #2 を開催しましたのでご報告いたします。

クックパッドからは3名が発表し、LT枠として3名社外のかたに発表していただきました。

発表

Firebase Cloud Messaging 入門編 by 三浦

Komerco 事業部の三浦から Firebase Cloud Messaging (以下 FCM) についての発表です。

通知対象を柔軟に指定できる Topic 機能などについてデモを通しての説明がありました。また FCM を使用する際に毎回実装する処理をまとめたライブラリ Tsuchi の紹介がありました。

speakerdeck.com

料理ショートライブアプリ Cookin' の開発 by 森川

投稿開発部の森川からは新規事業で Firebase を検討し採用するまでの経緯や、どのように Firebase を利用してサービス開発をしたかの話などがありました。

新しく Firebase を検討している人には参考になる話だったのではないかと思います。

speakerdeck.com

実践 Cloud Functions for Firebase by 星川

Komerco 事業部の星川からは在庫管理や決済処理で Cloud Functions を利用して得た知見を元に実践的な話がありました。トリガーイベントの多重起動対策やデプロイの話など参考になる話があったのではないかと思います。

speakerdeck.com

発表のなかで紹介のあったライブラリは次の通りです。

Firestore rules tips by 岸本

Komerco 事業部の岸本は当日インフルエンザにより発表できなかったため、発表予定内容を以下で共有させていただきました。 qiita.com

LT枠

LT枠では3人のかたに発表していただきました。

Firebase関連をCIでデプロイするときのTips by yamacraft さん

speakerdeck.com

スマートなcronを考案した by Yatima さん

speakerdeck.com

Firebase Auth with GAE & Cloud Endpoints by take_e10 さん

www.slideshare.net

まとめ

いずれの発表も Firebase 利用者の現場の知見があり参考になったという声を多くお聞きしました。

クックパッドでは引き続きエンジニアを募集していますので、Firebase を利用した開発や新規事業開発に興味あるかたは採用ページを是非ご覧ください。ご連絡をお待ちしております。

Swift.Decodable + Int64 / iOS 10 = 要注意

モバイル基盤グループのヴァンサン(@vincentisambart)です。

Swift 4 で JSON を読み込むための仕組みとして Swift.Decodable が追加されました。

iOS クックパッドアプリでは、 Swift での JSON の読込は以前 Himotoki が使われていましたが、新規コードでは Swift.Decodable が使われています。依存関係を減らすために、 Himotoki を使っているコードが少しずつ Swift.Decodable に移行されています。

ただし、この間、ユーザーの報告で分かったのですが、最近 Himotoki から Swift.Decodable に移行したコード辺りに一部のユーザーにエラーが出ています。 iOS 10 に限りますが。

調査

調べてみた結果、以下のコードでエラーを再現できました。

struct MyDecodable: Decodable {
    var id: Int64
}

let str = "{\"id\":1000000000000000070}"
let data = str.data(using: .utf8)!
do {
    let decodable = try JSONDecoder().decode(MyDecodable.self, from: data)
    print("id: \(decodable.id)")
} catch {
    print("error: \(error)")
}

iOS 10 で実行してみると Parsed JSON number <1000000000000000070> does not fit in Int64. というエラーが出ます。 1000000000000000080 でも起きますが、 1000000000000000071 では起きません。

このエラーって何だろう… Swift がオープンソースなので、コードに grep してみましょう。これっぽい。エラーが発生する条件をもう少し見てみましょう。

guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else {
    throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value)
}

let int64 = number.int64Value
guard NSNumber(value: int64) == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number <\(number)> does not fit in \(type)."))
}

numberNSNumber なのに NSNumber(value: number.int64Value) == number を満たさない!?

JSON の解読は実は JSONDecoder が Foundation の JSONSerialization を使っているので、 JSONSerialization を直接使ってみましょう。

let str = "{\"id\":1000000000000000070}"
let data = str.data(using: .utf8)!
let jsonObject = try! JSONSerialization.jsonObject(with: data) as! [NSString: Any]
let number = jsonObject["id"] as! NSNumber
print("number: \(number)")
print("type: \(type(of: number))")
print("comparison: \(NSNumber(value: number.int64Value) == number)")

iOS 10 で実行してみた結果は以下の通りです。

number: 1000000000000000070
type: NSDecimalNumber
comparison: false

iOS 11 では以下のように表示されます。

number: 1000000000000000070
type: __NSCFNumber
comparison: true

結果がかなり違いますね。 iOS 10 でもっと小さい数字を使ってみると、 iOS 11 と同じ結果になります。

number: 10000070
type: __NSCFNumber
comparison: true

__NSCFNumber というクラス名は不思議に見えるかもしれませんが、一番見かける NSNumber のサブクラスです。 type(of: NSNumber(value: 1))__NSCFNumber です。

iOS 11 で JSONSerialization が数字に使っているクラスの条件が変わったようですね。実際 iOS 11 でも、 64-bit に入りきらない大きい数字だと NSDecimalNumber になります。

解決方法

では、原因があの NSDecimalNumber にあるのは分かりましたが、問題はどう解決すればいいのでしょうか。

iOS 10 の JSONSerialization は流石に直せません。

NSDecimalNumber と遊んでみると挙動が分かりにくいところがありますが、上記の大きい数字でも NSDecimalNumber(value: int64) == number が満たされるので、 Swift 本体は条件を以下のにすれば直りそうです。

let int64 = number.int64Value
let recreatedNumber = number is NSDecimalNumber ? NSDecimalNumber(value: int64) : NSNumber(value: int64)
guard recreatedNumber == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number <\(number)> does not fit in \(type)."))
}

iOS クックパッドアプリはどうしたかと言いますと、Swift.Decodable を使っていて、サーバーからとても大きい ID が来そうな箇所だけを Himotoki に戻すことにしました。 iOS 10 対応をやめたら、再度 Swift.Decodable に戻す予定です。

まとめ

iOS 10 にまだ対応しているアプリは Swift.Decodable を準拠している classstruct 内に Int64 を使っている場合、要注意です。一部のとても大きい数字では読込中にエラーが起きる可能性があります。その場合、すぐできる対応は対象の classstructSwift.Decodable を使うのをやめる必要あるかもしれません。

バグを報告したので、修正が行われたら追記します。