Android cookpadLiveで採用してる技術 2019夏

メディアプロダクト開発部の安部(@STAR_ZERO)です。

Android cookpadLiveで採用してる技術について紹介したいと思います。

cookpadLiveとは

cookpadLive は、料理上手な有名人や料理家がクッキングLiveを生配信しています。一緒に、Live配信でリアルタイムに料理が楽しめるアプリです。

ダウンロード: Android アプリ iOS アプリ

ぜひ、ダウンロードしてLive配信を見てください!

基本環境

基本となる環境です

  • Kotlin
  • minSdkVersion 21
  • targetSdkVersion 28
  • AndroidX

特別な箇所はないですが、最新に追随するように努めています。

比較的新しいアプリなので、最初からすべてKotlinで記述されています。

targetSdkVersionについてはそろそろ29に対応する予定です。29にすることでの影響を調査している状況です。

Android Studio 3.5

Android Studio 3.5はbetaの段階から導入しています。理由としてはIncremental annotation processingを使いたかったためです。

cookpadLiveでは全面的にDataBindingを採用しているため、これの恩恵は非常に大きいものになります。 これまでは、レイアウトファイルを変更してビルドし直さなければコードが生成されず効率がよくありませんでした。3.5からはレイアウトファイルを変更すると同時にコード生成も行われるのでビルドによる待ち時間が減り効率よく開発できるようになりました。

Jetpack

現在、cookpadLiveではJetpackを積極的に採用しています。

DataBinding、LiveData、ViewModelについては、最初は使用されていなかったのですが、徐々に導入を進めて今では全面的に使用しています。

意外と便利だったのが、ViewModelをActivityに関連付けることでActivityとFragment間、それぞれのFragment間でのデータやイベントのやりとりが可能になる機能です。この機能のおかげ実装がだいぶ楽になったこともありました。 例えば、以下のようにActivityのイベントをFragmentでも受け取ることが簡単にできます。

class HogeActivity: AppCompatActivity() {
    private val viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }
    fun someEvent() {
        // イベント発行
        viewModel.somaEvent.value = someValue // someEventはLiveData
    }
}

class HogeFragment: Fragment() {
    private val activityViewModel by lazy {
        // thisではなく、Activityを指定することで共通のViewModelを使用できる
       ViewModelProviders.of(requireActivity()).get(HogeViewModel::class.java)
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        activityViewModel.somaEvent.observe(viewLifecycleOwner) {
            // Activityのイベントを受信
        }
    }
}

Pagingはだいぶ癖があるライブラリですが、ちゃんと理解して使う非常に便利です。これも早い段階から導入しています。現状ではネットワークにはライブラリ側で対応はされてないですが、今後される予定らしいので、楽しみにしています。

Navigationについてはそこまで活用できないですが、部分的に使っています。全面的にSingle Activityにすることは考えてないですが、出来る箇所はFragmentに移行していきたいと考えています。また、SafeArgsは画面間の値の受け渡しが便利になるので積極的に使っていこうと思います。

RoomはDBまわりの処理には欠かせないくらい便利です。RoomはLivaData、RxJavaと簡単に連携することができるため、既存の実装に組み合わせることが簡単にできました。SQLも補完とシンタックスハイライトが効くので非常に助かります。

DI

DIについて Dagger を使用しています。こちらも最初は使用されてなかったのですが、徐々に導入をすすめました。

Daggerについては非常に難しい印象がある人が多いと思いますが、一度使うと便利すぎて手放せません。 DaggerでRepositoryクラスなど生成するようにして、あとは使いた箇所でInjectするだけです。ViewModelなどで必要なRepositoryが増えた場合も、生成するコードを意識せずパラメータに追加するだけ済みます。 最初の設定さえうまくやってしまえば、あとは楽になるはずです。

以前、部内でやったDagger勉強会のチュートリアルコードのリンクを貼っておきます。(まだ@Component.Factoryには対応してないです…)

STAR-ZERO/dagger-tutorial

AppSync

cookpadLiveではライブ中のコメントやハートなどのリアルタイム通信にAWSの AppSync を使用しています。

f:id:STAR_ZERO:20190826153800p:plain:w300

この部分が一番特徴的かもしれません。

AppSyncはAWSのマネージド型GraphQLサービスです。 ユーザーが送信したコメントやハートをAppSync(GraphQL)のSubscriptionの機能を使い受信するようにしています。

ライブによって非常に多くのコメントやハートを受信することになりますが、受信のたびに画面に描画するのではなくRxJavaのbufferを使ってある程度まとめて画面に描画するようにしています。このあたりはうまくRxJavaと組み合わせて実装しています。

AppSyncの話は以下の記事の後半部分を見ていただければと思います。

CookpadTVのCTOが語る、料理動画サービス開発の課題と実装 - ログミーTech

その他ライブラリ

このあたりよく使われてるライブラリですね。これらももちろん活用しています。

設計

MVVM + Repositoryパターンを採用しています。Googleが公開してる Guide to app architecture とほぼ同じです。

元々はVIPERだったのですが、DataBindingやLiveDataとViewModelを導入していくと同時にMVVMに移行していきました。今ではすべてMVVMで実装されています。 私個人の経験からもJetpackを導入することで、開発効率と品質に大きく貢献することは明確だったので、これらを導入しました。

VIPERはAndroidではあまりの馴染みがないかもしれませんが、MVPパターンのようにInterfaceを使って各レイヤー間の処理を呼び出します。 すごく簡単な例ですが、以下のような感じです。(例ではViewとPresenterしか登場してないです)

// HogeView.kt
interface HogeView {
    fun show()
    fun hide()
}

// HogeFragment.kt
class HogeFragment: Fragment(), HogeView {

    private val presenter by lazy { HogePresenter(this) }

    override fun onCreateView(/** */): View? {
        // ...
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        presenter.fetch()
    }
    override fun show() {
        // View.VISIBLEにする
    }
    override fun hide() {
        // View.GONEにする
    }
}

// HogePresenter.kt
class HogePresenter(private val view: HogeView) {
    fun fetch() {
        val data == // ...
        if (data != null) {
            view.show()
        } else {
            view.hide()
        }
    }
}

このようにViewへの処理を呼び出すのにInterfaceを使ってPresenterからViewへの処理を呼び出しています。

一見、問題がなさそうですが、この時点で既に問題があります。例えば、Presenterでデータ取得中にActiivty/Fragmentが破棄された場合はどうなるでしょう。破棄されてるオブジェクトにアクセスすることになり、場合によってはクラッシュします。これはPresenterがActivity/FragmentのLifecycleについて何も知らないからです。これを解決するにはPresenter側に破棄されたことを教えてあげる必要があります。

では、これを今の実装で書き換えた場合です。

// HogeFragment.kt
class HogeFragment: Fragment() {

    private val viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }

    private lateinit var binding: FragmentHogeBinding

    override fun onCreateView(/** */): View? {
        // ...
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        binding.viewModel = viewModel
        binding.lifecycleOwner = viewLifecycleOwner
        viewModel.fetch()
    }
}

// HogeViewModel.kt
class HogeViewModel: ViewModel() {
    val isShow = MutableLiveData<Boolean>()

    fun fetch() {
        val data == // ...
        isShow.value = data != null
    }
}
<!-- fragment_hoge.xml -->
<layout>
    <data>
        <variable name="viewModel" type="...HogeViewModel" />
        <import type="android.view.View" />
    </data>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="..."
        android:visibility="@{viewModel.isShow ? View.VISIBLE : View.GONE}" />
</layout>

ここではDataBindingを使用していて、ViewModelが保持しているLiveDataとバインドしています。このLiveDataの値を変更すると自動的にView側にも反映される仕組みになっています。 ここで重要なのは、LiveDataはLifecycleのことを知っているので、Activity/Fragmentがアクティブな状態のときしかデータを流しません。そのため、さきほど説明したActivity/Fragmentが破棄されたときの対応を特別にせずとも問題が起こることがありません。またViewModelおいては回転でActivityが再生成されたときもViewModelは状態をもっているため、データ取得を中断することなく処理を継続することができます。

この設計にすることでActivity/FragmentはViewModelの状態を反映すれば良いだけになり、責務もしっかり分かれて見通しが良くなりました。また単純にVIPERはファイル数が多くなるため、コードを追う時にコードジャンプであちこち飛ばなければならず、個人的にはコードが追いにくい感じでした。

他にも様々な面で効率・品質を向上させるのに貢献してくれています。その他便利なJetpackライブラリも簡単に導入できるようになっています。

今ではすべての画面が同じような感じになってるので、どういう処理をしてるのかを理解しやすくなっています。

この設計変更ですが、新規画面については新しい設計でやり、既存については少しずつ進めていました。またUIを大きく変更するタイミングもあったので、その時に一気に直した箇所もありました。出来ることからコツコツやってこともあり、大きくコストをかけることなく変更できました。

CI環境

CIに関しては、Jenkinsを使っています。やってることは以下になります。

  • Pull Request
    • テスト、lintを実行
    • 社内テスト環境にAPKをアップロード
  • masterマージ後
    • 社内テスト環境にAPKをアップロード
    • Google Play内部テストへアップロード

可能な限り早い段階でリリース版をビルドして触ることで、問題があったときに早めに気づくことができるようにしています。特にProguardまわりは見落としがちになるを防げます。

リリースするときは内部テスト版を製品版へ昇格するだけになっています。今ここは手作業でやってるのですが、ChatOps等で出来るようにしたいと考えています。

マルチモジュール

現状では、スマホ、AndroidTV、FireTVで共有するようなモジュールと、featureモジュールをいくつか分割しています。

図にすると以下のような感じです。

f:id:STAR_ZERO:20190826154302p:plain:w300

  • core
    • 全モジュールで共通処理
    • APIアクセス、Repository、データモデルなど
  • appcore
    • スマホアプリ共通処理
    • 共通View、ログ、リソースなど
  • feature
    • 各機能を分割したモジュール
  • app
    • スマホアプリメイン
  • smarttv
    • TVアプリ共通処理
    • 共通View、ログ、リソースなど
  • androidtv
    • AndroidTVメイン
  • firetv
    • FireTVメイン

まだfetureモジュールは分割できる箇所があるので、少しずつでも進めていきたいと思います。

課題と今後

テスト

正直、まだそこまでうまく書けてる状況ではないので、なんとかしていきたいと思っています。 せっかくなので、ライブラリの選定から考えようとも思っています。Truth 良さそうですね。

StyleとTheme

StyleとThemeについては結構ちらかってる状態なので、整理したい思っています。画面数もそこそこあるので、だいぶ大変な作業になる気配がしています。まずは、どういうふうに整理するかを検討してから少しずつやっていく感じになりそうです。

Navigation

前に書きましたが、まだまだ活用できる箇所があります。すべてSingleActivityとは考えてないですが、Fragmentでの遷移で良い箇所もあるので、そういった箇所に対応していきたいと考えています。

Coroutines

Coroutinesについては、どうするかを検討している段階です。現状でCoroutinesじゃないと困るような場面は出てきていませんが、JetpackもCoroutinesの対応が進んでいて実装するのに困ることはないと考えています。また、今後Coroutinesによって実装コストが下がるような機能なんかも出てくる可能性ありそうです。 メンバーと会話して、導入する気持ちはありますが、進め方やどこから導入するのかを考えています。

まとめ

cookpadLiveでは積極的にJetpackを使っていき、Googleが推奨しているやり方にどんどん乗っかっていっています。 今後もJetpackも改善されていくと思うので、それにいつでも追随できるような状態を保つようにしています。

これからもcookpadLiveでは新しい技術を積極的に取り入れていきますし、やりたいこともまだまだたくさんあります。

興味がある方いらっしゃいましたら、気軽にお声がけください。一緒に色々チャレンジしていきましょう。

info.cookpad.com

UICollectionViewでページングスクロールを実装する

 こんにちは。新規サービス開発部の中村です。普段は「たべドリ」アプリの開発をしています。「たべドリ」は料理の学習アプリです。詳細はこちらの記事をご覧ください。本記事では UICollectionView でページングスクロールを実装する方法について解説します。

概要

f:id:nkmrh:20190807175935p:plain f:id:nkmrh:20190807175941p:plain f:id:nkmrh:20190807175952p:plain

 上記画像が今回解説する iOS アプリのUIです。左右のコンテンツが少し見えているカルーセルUIで、以下の要件を満たすものです。

  • 先頭にヘッダーを表示する
  • セルが水平方向にページングスクロールする

色々な実装方法があると思いますが、今回はヘッダーがあるため複数の異なる幅のViewを表示させながら、ページングスクロールを実現する方法を解説します。実装のポイントは以下の2点です。

  • UICollectionViewFlowLayoutのサブクラスを作成しtargetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドをオーバーライドしてUICollectionViewcontentOffsetを計算する
  • UICollectionViewdecelerationRateプロパティに.fastを指定する

以降、実装方法の詳細を解説していきます。

ベースとなる画面の作成

 まずベースとなる画面を作成します。UICollectionViewController UICollectionViewFlowLayoutを使いヘッダーとセルを表示します。UICollectionViewFlowLayoutscrollDirectionプロパティは.horizontalを指定し横スクロールさせます。ヘッダーとセルのサイズ、セクションとセルのマージンは任意の値を指定します。

ViewController.swift

override func viewDidLoad() {
    ... (省略)
    flowLayout.scrollDirection = .horizontal
    flowLayout.itemSize = cellSize
    flowLayout.minimumInteritemSpacing = collectionView.bounds.height
    flowLayout.minimumLineSpacing = 20
    flowLayout.sectionInset = UIEdgeInsets(top: 0, left: 40, bottom: 0, right: 40)
}
    
... (以下 UICollectionViewDataSource は省略)

f:id:nkmrh:20190807174701g:plain

ここまではUICollectionViewControllerの基本的な実装です。

isPagingEnabled プロパティ

 ページングスクロールさせたい場合、最初に試したくなるのがUIScrollViewisPagingEnabledプロパティをtrueに指定することですが、この方法ではセルが画面の中途半端な位置に表示されてしまいます。これはcollectionViewの横幅の単位でスクロールされるためです。この方法でもセルの幅をcollectionViewの横幅と同じ値に設定し、セルのマージンを0に指定することで画面中央にセルを表示させることが可能です。しかし、今回はセルの幅と異なる幅のヘッダーも表示させる必要があるためこの方法では実現できません。

f:id:nkmrh:20190807174812g:plain

セルを画面中央に表示する

 そこでUICollectionViewFlowLayoutのサブクラスを作成しtargetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドをオーバーライドします。このメソッドはユーザーが画面をスクロールして指を離した後に呼ばれます。メソッドの戻り値はスクロールアニメーションが終わった後のcollectionViewcontentOffsetの値となります。このメソッドを以下のように実装します。

FlowLayout.swift

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else { return proposedContentOffset }

    // sectionInset を考慮して表示領域を拡大する
    let expansionMargin = sectionInset.left + sectionInset.right
    let expandedVisibleRect = CGRect(x: collectionView.contentOffset.x - expansionMargin,
                                      y: 0,
                                      width: collectionView.bounds.width + (expansionMargin * 2),
                                      height: collectionView.bounds.height)

    // 表示領域の layoutAttributes を取得し、X座標でソートする
    guard let targetAttributes = layoutAttributesForElements(in: expandedVisibleRect)?
        .sorted(by: { $0.frame.minX < $1.frame.minX }) else { return proposedContentOffset }

    let nextAttributes: UICollectionViewLayoutAttributes?
    if velocity.x == 0 {
        // スワイプせずに指を離した場合は、画面中央から一番近い要素を取得する
        nextAttributes = layoutAttributesForNearbyCenterX(in: targetAttributes, collectionView: collectionView)
    } else if velocity.x > 0 {
        // 左スワイプの場合は、最後の要素を取得する
        nextAttributes = targetAttributes.last
    } else {
        // 右スワイプの場合は、先頭の要素を取得する
        nextAttributes = targetAttributes.first
    }
    guard let attributes = nextAttributes else { return proposedContentOffset }

    if attributes.representedElementKind == UICollectionView.elementKindSectionHeader {
        // ヘッダーの場合は先頭の座標を返す
        return CGPoint(x: 0, y: collectionView.contentOffset.y)
    } else {
        // 画面左端からセルのマージンを引いた座標を返して画面中央に表示されるようにする
        let cellLeftMargin = (collectionView.bounds.width - attributes.bounds.width) * 0.5
        return CGPoint(x: attributes.frame.minX - cellLeftMargin, y: collectionView.contentOffset.y)
    }
}

// 画面中央に一番近いセルの attributes を取得する
private func layoutAttributesForNearbyCenterX(in attributes: [UICollectionViewLayoutAttributes], collectionView: UICollectionView) -> UICollectionViewLayoutAttributes? {
    ... (省略)
}

velocity引数をもとにユーザーが左右どちらにスワイプしたか、またはスワイプせずに指を離したかの判定をしています。スワイプしていない場合は画面中央に近いUICollectionViewLyaoutAttributesをもとに座標を計算します。ユーザーが左にスワイプした場合は取得したUICollectionViewLayoutAttributes配列の最後の要素、右スワイプの場合は最初の要素をもとに座標を計算します。これでセルを画面中央に表示できます。

f:id:nkmrh:20190807174938g:plain

 セルの位置は期待通りになりましたが、スクロールの速度が緩やかなのでスナップが効いた動きにします。UIScrollViewdecelerationRateプロパティを.fastに指定するとスクロールの減速が通常より速くなりスナップの効いた動作となります。

collectionView.decelerationRate = .fast

f:id:nkmrh:20190807175325g:plain

1ページずつのページング

 これで完成したように見えますが、スクロールの仕方によっては1ページずつではなくページを飛ばしてスクロールしてしまうことがあります。これを防ぎたい場合targetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドで取得しているUICollectionViewLayoutAttributes配列の取得タイミングを、スクロールする直前に変更し、それをもとに座標を計算することで解決できます。以下のように実装を追加・変更します。

FlowLayout.swift

... (省略)

private var layoutAttributesForPaging: [UICollectionViewLayoutAttributes]?

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else { return proposedContentOffset }
    guard let targetAttributes = layoutAttributesForPaging else { return proposedContentOffset }

    ... (省略)
}

... (省略)

// UIScrollViewDelegate scrollViewWillBeginDragging から呼ぶ
func prepareForPaging() {
    // 1ページずつページングさせるために、あらかじめ表示されている attributes の配列を取得しておく
    guard let collectionView = collectionView else { return }
    let expansionMargin = sectionInset.left + sectionInset.right
    let expandedVisibleRect = CGRect(x: collectionView.contentOffset.x - expansionMargin,
                                      y: 0,
                                      width: collectionView.bounds.width + (expansionMargin * 2),
                                      height: collectionView.bounds.height)
    layoutAttributesForPaging = layoutAttributesForElements(in: expandedVisibleRect)?.sorted { $0.frame.minX < $1.frame.minX }
}
ViewController.swift

... (省略)

extension ViewController: UICollectionViewDelegateFlowLayout {
    override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        let collectionView = scrollView as! UICollectionView
        (collectionView.collectionViewLayout as! FlowLayout).prepareForPaging()
    }
    ... (省略)
}

UIScrollViewDelegatescrollViewWillBeginDragging(_:)が呼ばれたタイミングでprepareForPaging()メソッドを呼びます。このメソッドでスクロール直前のUICollectionViewLayoutAttributes配列をlayoutAttributesForPagingプロパティに保存しておき、targetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドの中で保存した配列をもとに座標を計算するように変更します。これで1ページずつページングできるようになりました。

おわりに

 本記事では UICollectionView でページングスクロールを実装する方法を解説しました。このようなUIを実装することは稀だとは思いますが、何かの参考になれば幸いです。

サンプルプロジェクトはこちらhttps://github.com/nkmrh/PagingCollectionViewです。

料理のやり方を1から学んでみたいという方は、ぜひ「たべドリ」を使ってみて下さい!!

apps.apple.com

クックパッドでは新規サービス開発もやりたい、UI・UXにこだわりたいエンジニア・UXエンジニアを募集しています!!!

info.cookpad.com

Google Play Billing Client 2.0における消費型商品の決済の承認(acknowledgement)について

ユーザ・決済基盤部の宇津(@uzzu)です。

クックパッドでは複数のAndroidアプリでGoogle Play決済(定期購読、消費型商品)を利用しており、 ユーザ・決済基盤部ではそれらのアプリの決済情報を取り扱う共通決済基盤サービスクライアントライブラリを日々開発しています。 直近ではGoogle I/O 2019にて発表されたGoogle Play Billing Client 2.0にも対応し、Cookpad.apk #3のLT枠にてどのように対応していったか発表させて頂きました。

speakerdeck.com

本記事では同発表にて時間が足りず深堀りできなかった、消費型商品における決済の承認(acknowledgement)対応について解説します。 スライドと合わせて読んで頂ければ幸いです。

消費型商品における2.0とそれ以前との違い

2.0以前の消費型商品の購入フローは概ね以下の図のようになっていたかと思います。

f:id:himeatball:20190815060223p:plain
2.0以前の購入フロー

2.0からはこれに加えて、決済の承認が必要になります。 Google Play決済自体は決済処理時に走る(Pending Purchaseを除く)のですが、3日以内に開発者が決済の承認を行わない場合返金されます。 通信断や障害等で購入フローが正常に完了せず商品が付与されなかったユーザが自動的に救済されるようになるのは、サポートコスト削減の面でも非常に良いですね。

f:id:himeatball:20190815060447p:plain
2.0での購入フロー

一見、購入フローに処理が1ステップ追加されただけのように見えます。加えてリリースノートにも

For consumable products, use consumeAsync(), found in the client API.

とあるように、アプリ上でconsumeAsyncを呼び出す事で消費(consume)しつつ決済の承認も行われるので、図に追加した⑤については特にやる事はないのでは?と思われた方もいるかと思います。 しかしながら、商品付与が行われるタイミングにおける決済の承認状態は2.0では未承認、それ以前では承認済という違いがあり、 この違いによってアプリ改ざんに対するリスクを考慮する必要性がでてきます。

consumeAsyncを呼び出さないようにアプリを改ざんされる事を想定した場合、購入処理を実施すると以下のように処理されます。

  1. 消費型商品の購入ボタンを押す
  2. 購入フローに則り商品が付与される
  3. consumeAsyncを呼び出さない為消費が行われないが、商品は既に付与されている(決済が未承認の間、商品は消費されない為、再購入はできない)
  4. 3日後、決済が未承認の為返金される
  5. 返金されると消費型商品が再度購入可能になる
  6. 1に戻る

つまり、2.0以前の購入フローの実装のまま愚直に2.0対応してしまうと、アプリ改ざんによって3日毎に消費型商品を無料で取得する事ができてしまいます。

対策A: サーバサイドで決済を承認する

決済の承認はレシート検証同様にサーバサイドで行いたいという需要に応えるように、Purchases.products: acknowledge
が用意されています。 クックパッドの共通決済基盤サービスではこれを利用して決済を承認しユーザと決済情報の紐付けが正常に行われた上で、各サービスで商品の付与ができる状態とするようにしています。 商品付与後、アプリ上でconsumeAsyncします。

この対策方法はアプリ改ざんに対するリスク、及び決済の承認に関連するアプリ上での購入フローの実装が2.0とそれ以前とで変わらないのが利点です。 ただし、クックパッドのような決済サービスと商品を販売しているサービスが分離されている環境下においては、 決済状態と商品付与状態の整合性の担保ができている前提での対策方法になると考えています。 クックパッドの共通決済基盤サービスにおける整合性についての取り組みは以下の記事を参考にしてください。

https://techlife.cookpad.com/entry/2016/06/01/070000

加えて決済を承認するタイミングについて、商品を付与した上で決済を承認するか、あるいはレシート検証を終えた段階で一旦決済を承認した後に商品を付与するかを検討するかと思います。

クックパッドの共通決済基盤サービスではどうしているかというと、消費型商品においては購入フローの完了処理である所の消費処理がアプリ上でしか実施できない為、購入処理の冪等性を担保できるよう後者を選択しています。 定期購読においてはGoogle Play決済を終えた以降の購入フローをサーバサイドで完結できる為、前者で且つレスポンスタイムを上げる為にJob Queueで非同期に決済の承認を実施しています。

対策B: アプリ上で決済の承認を実施してからサーバにレシートを送信し、サーバ間通信で決済の承認状態を検証する

Billing Client Libraryに決済の承認をするためのメソッド(BillingClient#acknowledgePurchase)が用意されています。 Google Play決済を実施後にこれを呼び出してまず承認してしまい、その上でサーバにレシートを送信し、サーバサイドでPurchases.products: getを呼び出してacknowledgementStateを確認し、 承認済か否かを検証した上で商品を付与した後、アプリ上でconsumeAsyncするような購入フローにします。

この対策方法ではアプリ上に実装されている購入フローはもちろん、通信断等で滞留した決済の再開処理にも手を入れる必要がある為、対策Aよりは手がかかるものの、 サーバ間通信で決済の承認状態を検証する為、対策A同様に介入される余地はないと考えています。

その他の対策方法

例えばdeveloper notificationを頼りに商品を付与する方法があるかと思いますが、developer notificationは現在定期購読のみサポートしているのと、 仮にサポートされるようになったとしても、消費型商品において大半のユーザは購入完了したら遅延なくすぐに商品を使用したい為、 その仕組みを整えるのはそれなりに開発コストがかかりそうです。

決済処理フローはそのままにアプリ改ざん対策に本腰を入れていくとしても、アプリ改ざん対策はいたちごっこになってしまう為、運用コストの増大が予想されます。 素直に前述の対策Aないし対策Bを適用するのが良さそうです。

まとめ

本記事ではGoogle Play Billing Client 2.0における消費型商品の決済の承認(acknowledgement)について解説しました。 弊社において利用していない機能もあり(定期購読のupgrade/downgrade等)、決済の承認に関する網羅的な解説とまではなっていないですが、 Google Play Billing Client 2.0導入の手助けとなれば幸いです。

クックパッドではアプリ内課金をやっていくエンジニアを募集しています