masaibarの雑記

胃腸は弱いが肉は好き

Play Billing LibraryのCodeLabをなぞってみた

はじめに

開発しているアプリに課金要素を入れたいと思い、そのために新しいPlayBillingLibraryを覚えたかったのでチュートリアルとしてCodeLabをなぞってみた覚書。

ちなみにCodeLabだけ全部やっても本番投入するには不充分であるというのが結論。

Buy and Subscribe: Monetize your app on Google Play

目次

手順

セットアップ

まずはチェックアウトしてくる。

$ git clone https://github.com/googlecodelabs/play-billing-codelab

このままAndroidStudioで開くのではなくworkのフォルダを開くことに注意。

開くと、サンプルコードが一部古いようなのでupdateしてやる。 f:id:masaibar-dev:20171112123704p:plain

ベースとなるアプリの確認

完成図としてはこんな感じになるらしい。 f:id:masaibar-dev:20171112124754p:plain

とりあえず何も考えずビルドしてみるとこんな画面が出てくる。 f:id:masaibar-dev:20171112125103p:plain

画面に表示されているのは下記の要素。

  • 車と燃料の残量を表示
  • 燃料を消費して運転ゲームをプレイできるボタン
  • 空の購入画面(これから実装していく)を開くための購入ボタン

ここから下記のようにアプリ機能を拡張していく。

  • Googleデベロッパーコンソールに定義した商品詳細を表示
  • GooglePlay料金確認ダイアログ経由での購入

Play Billing Libraryとの統合

build.gradleファイルにBillingライブラリを追加

app/build.gradleにBillingライブラリの定義を追加してSyncする。

compile 'com.android.billingclient:billing:1.0'

BillingClientの作成

既存のBillingManagerのコードを編集していく。 PurchasesUpdatedListenerをimplementsするのを忘れないように。

public class BillingManager implements PurchasesUpdatedListener {

    private static final String TAG = "BillingManager";

    private final BillingClient mBillingClient;
    private final Activity mActivity;

    public BillingManager(Activity activity) {
        mActivity = activity;
        mBillingClient = BillingClient.newBuilder(mActivity).setListener(this).build();
        mBillingClient.startConnection(new BillingClientStateListener() {
            @Override
            public void onBillingSetupFinished(int responseCode) {
                if (responseCode == BillingClient.BillingResponse.OK) {
                    Log.i(TAG, "onBillingSetupFinished() response: " + responseCode);
                } else {
                    Log.i(TAG, "onBillingSetupFinished() error code: " + responseCode);
                }
            }

            @Override
            public void onBillingServiceDisconnected() {
                Log.w(TAG, "onBillingServiceDisconnected()");
            }
        });
    }

    public void startPurchaseFlow(String skuId, String billingType) {
        // TODO: Implement launch billing flow here
    }

    @Override
    public void onPurchasesUpdated(int responseCode, @Nullable List<Purchase> purchases) {
        Log.d(TAG, "onPurchaseUpdated() response: " + responseCode);
    }
}

テスト

書き換えが完了した状態で、Runボタンを押すとGamePlayActivityのonCreateの中でBillingManagerの初期化が行われ、logcatに以下のログが出力される。

onBillingSetupFinished() response: 0

SKU詳細の取得

SKUとは在庫管理を行う場合の単位。アイテムは商品の種類を指すが、SKUは同じ商品でもパッケージの違いや値段の違いなど、アイテムより小さい単位で分類される。http://www.e-logit.com/words/sku.php

クエリの開始

一度onBillingSetupFinishedが呼ばれると、BillingClinetが使用できるようになる。

まず、SKU詳細を問い合わせてみる。

CodeLabのアプリには既に2つのアプリ内アイテム

  • gas
  • premium

と、2つの定期購読アイテム

  • gold_monthly
  • gold_yearly

が用意されている*1ので、それらの詳細を問い合わせるためにクエリを実行する。

まずはGooglePlayデベロッパーコンソールから特定SKUタイプの全てのSKU IDリストを取得するためのデータ構造を定義する。

static {
    SKUS = new HashMap<>();
    SKUS.put(BillingClient.SkuType.INAPP, Arrays.asList("gas", "premium"));
    SKUS.put(BillingClient.SkuType.SUBS, Arrays.asList("gold_monthly", "gold_yearly"));
}

public List<String> getSkus(@BillingClient.SkuType String type) {
    return SKUS.get(type);
}

次に、BillingManagerに新しいメソッドを追加し、GooglePlayデベロッパーコンソール上で定義された商品(SKU)に関する全ての詳細情報を取得出来るようにする。 このメソッドは情報を取得するための、SKUタイプとSKUのリストであるSkuDetailsParamsを受け取る。

    public void querySkuDetailsAsync(@BillingClient.SkuType final String type,
                                     final List<String> stringList,
                                     final SkuDetailsResponseListener listener) {

        SkuDetailsParams skuDetailsParams = SkuDetailsParams.newBuilder()
                        .setSkusList(stringList)
                        .setType(type)
                        .build();

        mBillingClient.querySkuDetailsAsync(skuDetailsParams, new SkuDetailsResponseListener() {
            @Override
            public void onSkuDetailsResponse(int responseCode, List<SkuDetails> skuDetailsList) {
                listener.onSkuDetailsResponse(responseCode, skuDetailsList);
            }
        });
    }

次にデータをUI上に表示する。

AcquireFragmentにhandleManagerAndReadyメソッドが予め定義されており、フラグメントを表示しBillingManagerがアクセス可能になった際に一度だけ呼ばれる。その為、アプリ内アイテムのSKU詳細を取得するためにこのメソッドをqueryで拡張する。

private void handleManagerAndUiReady() {
    // Start querying for SKUs
    List<String> inAppSkus = mBillingProvider.getBillingManager()
            .getSkus(BillingClient.SkuType.INAPP);
    mBillingProvider.getBillingManager().querySkuDetailsAsync(BillingClient.SkuType.INAPP,
            inAppSkus,
            new SkuDetailsResponseListener() {
                @Override
                public void onSkuDetailsResponse(int responseCode, List<SkuDetails> skuDetailsList) {
                    if (responseCode == BillingClient.BillingResponse.OK &&
                            skuDetailsList != null) {
                        for (SkuDetails details : skuDetailsList) {
                            Log.w(TAG, "Got a SKU: " + details);
                        }
                    }
                }
            });

    displayAnErrorIfNeeded();
}

テスト

アプリを起動し、Purchaseボタンを一度押すとAcquireFragmentが表示され、handleManagerAndUiReady()が呼び出される。 logcatには以下のログが出力される。

Got a SKU: SkuDetails: {"productId":"gas","type":"inapp","price":"¥113","price_amount_micros":113484176,"price_currency_code":"JPY","title":"Gas (Play Billing Codelab)","description":"Buy gasoline to ride!"}
Got a SKU: SkuDetails: {"productId":"premium","type":"inapp","price":"¥170","price_amount_micros":170226264,"price_currency_code":"JPY","title":"Upgrade your car (Play Billing Codelab)","description":"Buy a premium outfit for your car!"}

SKU情報の描画

アプリ内アイテムの表示

このサンプルでは、描画のためのUIが予め用意されているためクエリの結果をadapterに繋ぐだけで良い。 最初に、AcquireFragment#handleManagerAndUiReady()でローカルのSkuRowDataの配列に入れ直すため、onSkuDetailsResponseを次のようにする。

if (responseCode == BillingClient.BillingResponse.OK &&
        skuDetailsList != null) {
    List<SkuRowData> inList = new ArrayList<>();
    for (SkuDetails details : skuDetailsList) {
        inList.add(new SkuRowData(
                details.getSku(), details.getTitle(), details.getPrice(),
                details.getDescription(), details.getType()));
    }
    if (inList.size() < 1) {
        displayAnErrorIfNeeded();
    } else {
        mAdapter.updateData(inList);
        setWaitScreen(false);
    }
}

apkをビルドして実機に入れ、Purchaseボタン押下後に表示されるAcquireFragmentが次のように表示されていることを確認する。

f:id:masaibar-dev:20171112204959p:plain

ちなみにCodeLabのサンプル画像だと値段が円表記ではなく$表記になっている。 恐らく端末の言語設定によって表示が違うのではないかと思われる。

f:id:masaibar-dev:20171112205102p:plain

定期購読アイテムの表示

CodeLabでGoogleデベロッパーコンソール上に用意されているのは定期購読アイテムもあるので、それらも同様に表示できるようにする。

このタスクはアプリ内アイテム追加とよく似ているため、コードのコピペを避けるためにAcquireFragment.handleManagerAndUiReady()内のgetSkuDetailsメソッドで利用する、再利用可能なリスナーを定義する。

final List<SkuRowData> inList = new ArrayList<>();
SkuDetailsResponseListener responseListener = new SkuDetailsResponseListener() {
    @Override
    public void onSkuDetailsResponse(int responseCode, List<SkuDetails> skuDetailsList) {
        if (responseCode == BillingClient.BillingResponse.OK && skuDetailsList != null) {
            for (SkuDetails details : skuDetailsList) {
                Log.i(TAG, "Found sku: " + details);
                inList.add(new SkuRowData(details.getSku(), details.getTitle(),
                        details.getPrice(), details.getDescription(),
                        details.getType()));
            }

            if (inList.size() == 0) {
                displayAnErrorIfNeeded();
            } else {
                mAdapter.updateData(inList);
                setWaitScreen(false);
            }
        }
    }
};

この再利用可能なリスナーと拡張可能なリストを準備したら、アダプター内にアプリ内アイテムと定期購入アイテムを簡単に入れることが出来る。 どちらのタイプのSKUに対しても、同じSkuDetailsResponseListenerでクエリを投げることが出来る。

// Start querying for in-app SKUs
List<String> skus = mBillingProvider.getBillingManager().getSkus(SkuType.INAPP);
mBillingProvider.getBillingManager().querySkuDetailsAsync(SkuType.INAPP, skus, responseListener);
// Start querying for subscriptions SKUs
skus = mBillingProvider.getBillingManager().getSkus(SkuType.SUBS);
mBillingProvider.getBillingManager().querySkuDetailsAsync(SkuType.SUBS, skus, responseListener);

これらの変更後、handleManagerAndUiReadyは次のようになる。

private void handleManagerAndUiReady() {
    final List<SkuRowData> inList = new ArrayList<>();
    SkuDetailsResponseListener responseListener = new SkuDetailsResponseListener() {
        @Override
        public void onSkuDetailsResponse(int responseCode, List<SkuDetails> skuDetailsList) {
            if (responseCode == BillingClient.BillingResponse.OK && skuDetailsList != null) {
                for (SkuDetails details : skuDetailsList) {
                    Log.i(TAG, "Found sku: " + details);
                    inList.add(new SkuRowData(details.getSku(), details.getTitle(),
                            details.getPrice(), details.getDescription(),
                            details.getType()));
                }

                if (inList.size() == 0) {
                    displayAnErrorIfNeeded();
                } else {
                    mAdapter.updateData(inList);
                    setWaitScreen(false);
                }
            }
        }
    };

    //// Start querying for in-app SKUs
    List<String> skus = mBillingProvider.getBillingManager().getSkus(BillingClient.SkuType.INAPP);
    mBillingProvider.getBillingManager().querySkuDetailsAsync(BillingClient.SkuType.INAPP, skus, responseListener);

    // Start querying for subscriptions SKUs
    skus = mBillingProvider.getBillingManager().getSkus(BillingClient.SkuType.SUBS);
    mBillingProvider.getBillingManager().querySkuDetailsAsync(BillingClient.SkuType.SUBS, skus, responseListener);
}

テスト

ビルドしてPurchaseボタンを押すと次のように表示されるはずである。 f:id:masaibar-dev:20171112211905p:plain

購入フローの開始

GooglePlayの料金設定ダイアログを表示

下記のコードをBillingManager#startPurchaseFlow()に実装すると、AcquireFragmentのボタンを押された時に呼び出される。 また商品のSKUと、ボタンを押された商品の請求タイプも知っている。

※デモンストレーションなのでクレジットカードを追加しないこと。

BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder()
        .setType(billingType).setSku(skuId).build();
mBillingClient.launchBillingFlow(mActivity, billingFlowParams);

テスト

デバイスにインストールして、1番上のGasのカードのBUYボタンを押すと次のような画面が表示される。 f:id:masaibar-dev:20171112212153p:plain

最後の仕上げ

基本的な再試行のポリシーを実装

例えばPlayStoreアプリがバックグラウンドでアップデート中だった場合など、何らかの理由でPlayStoreサービスが切断された場合は再接続しようとするのが道理である。

切断後にBillingClient#startConnection()を一度だけ実行するような基本的なリトライ機構をBillingManagerに実装する。

これを実現するために、BillingClient#isReady()メソッドを利用する。

そして、BillingClinetの接続開始処理を再利用可能にするために、BillingManagerのコンストラクタから別メソッドに移動する。

また、クライアントが接続されていなかった場合か、接続成功時に一度だけ呼ばれるようなRunnableな引数を追加する。

private void startServiceConnectionIfNeeded(final Runnable executeOnSuccess) {
   if (mBillingClient.isReady()) {
       if (executeOnSuccess != null) {
           executeOnSuccess.run();
       }
   } else {
       mBillingClient.startConnection(new BillingClientStateListener() {
           @Override
           public void onBillingSetupFinished(@BillingResponse int billingResponse) {
               if (billingResponse == BillingResponse.OK) {
                   Log.i(TAG, "onBillingSetupFinished() response: " + billingResponse);
                   if (executeOnSuccess != null) {
                       executeOnSuccess.run();
                   }
               } else {
                   Log.w(TAG, "onBillingSetupFinished() error code: " + billingResponse);
               }
           }
           @Override
           public void onBillingServiceDisconnected() {
               Log.w(TAG, "onBillingServiceDisconnected()");
           }
       });
   }
}

何らかの理由でクライアントが切断された場合、このメソッドが再接続を一度試みる。 例えば、上記再試行ポリシーを使用してSKUの詳細クエリを実装するなら次のようにする。

public void querySkuDetailsAsync(@BillingClient.SkuType final String itemType,
            final List<String> skuList, final SkuDetailsResponseListener listener) {
        // Specify a runnable to start when connection to Billing client is established
        Runnable executeOnConnectedService = new Runnable() {
            @Override
            public void run() {
                SkuDetailsParams skuDetailsParams = SkuDetailsParams.newBuilder()
                        .setSkusList(skuList).setType(itemType).build();
                mBillingClient.querySkuDetailsAsync(skuDetailsParams,
                        new SkuDetailsResponseListener() {
                            @Override
                            public void onSkuDetailsResponse(int responseCode,
                                    List<SkuDetails> skuDetailsList) {
                                listener.onSkuDetailsResponse(responseCode, skuDetailsList);
                            }
                        });
            }
        };

        // If Billing client was disconnected, we retry 1 time
        // and if success, execute the query
        startServiceConnectionIfNeeded(executeOnConnectedService);
}

startPurchaseFlow()メソッドはこのようになる。

public void startPurchaseFlow(final String skuId, final String billingType) {
        // Specify a runnable to start when connection to Billing client is established
        Runnable executeOnConnectedService = new Runnable() {
            @Override
            public void run() {
                BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder()
                        .setType(billingType)
                        .setSku(skuId)
                        .build();
                mBillingClient.launchBillingFlow(mActivity, billingFlowParams);
            }
        };

        // If Billing client was disconnected, we retry 1 time
        // and if success, execute the query
        startServiceConnectionIfNeeded(executeOnConnectedService);
}

リソースの掃除

Javaではリソースを綺麗にし、メモリリークを避けることが大切なので、この部分も抜かりなく。

全てのリソースを消去し、Observerを登録解除するのはBillingClient#endConnection()を呼び出すだけである。 BillingManagerの内部に下記メソッドを実装し、GamePlayActivity#onDestroy()から呼び出す。

public void destroy() {
   mBillingClient.endConnection();
}

終わりに

一連の実装を体験することによって、大体の概念と流れは把握できたように思う。

しかし、試しにGasを買ってみたはずが燃料に反映されていなかったり、再度GasのBUYボタンを押しても反応しない状態になっている。

複数購入できるようなアイテムであればローカルで消費したことにして、再度購入が出来るようにするべきだと思うし、買い切りのアイテムであれば購入された時点でそれを検知してボタンをdisableにするなどCodeLabの内容だけでは実用に耐えうるクオリティではなさそうだ。

Play Billing Library | Android Developers

公式のドキュメントを読んで更に理解を深める必要があるが、予め必要な部品などは用意されており入門としてやる分にはCodeLabは良い物だと思う。

*1:通常、GooglePlayデベロッパーコンソールからSKUを設定する。 変更が反映されるまでに数時間かかるため、ここでは予め用意されたSKUを利用する。