expo-in-app-purchases の使い方

はじめに

最近の Expo の進化は素晴らしく、EAS Build の登場(正確には prebuild コマンドですかね)により、Bare Workflow のようにネイティブコードを直接管理せずとも、ネイティブコードに影響のあるようなライブラリを簡単に利用できるようになってきました。

つい先日、Expo InAppPurchases ライブラリを使って Managed workflow のまま Android と iOS の課金処理を実装したのですが、ハマりポイントもあったのでまとめておきます。

前提

各種バージョン

  • Expo SDK 42
  • expo-in-app-purchases 11.0.1

expo-in-app-purchases のバージョンですが、Google Play Billing Library のバージョンが古いせいで Google Play Console からビルドをアップロードできなかったので、Expo SDK 42 標準のバージョンからちょっと上げています(いくつかの API のインターフェースが変わっていますが、問題なく動きました)。

ポイント

finishTransactionAsync() は完了をしっかり待つ

まず、ドキュメントにおける例では下記のような記述です。

results.forEach(purchase => {
  if (!purchase.acknowledged) {
    console.log(`Successfully purchased ${purchase.productId}`);
    // Process transaction here and unlock content...

    // Then when you're done
    finishTransactionAsync(purchase, true);
  }
});

ただ、finishTransactionAsync() の返り値は Promise<void> なのです。落ち着いて名前見たら分かるんですが、とりあえずコピペから作成し始めてしまったせいで、そんな意識が一切抜けてしまいました・・・。 さらには、それでも該当コードだけ見たら(並列処理になって)動くような気がしてたのですが、finishTransactionAsync() を await して待ってあげないと(少なくても iOS では)トランザクション完了にならない現象が発生してしまいました。await してあげたほうが確実かと思います(あと、本題とはズレますが、forEach で await して思わぬ挙動になってしまうミスも避けましょう)。

未処理トランザクションを扱うためにアプリ起動直後に初期化

Expo 側のドキュメントには記載が見当たりませんでしたが、iOS においては、アプリ起動直後に通知される未処理トランザクション(前回課金トランザクション中に失敗)をキャッチするために、アプリ起動直後に connectAsync()setPurchaseListener() を呼ぶ必要があります。しかし、サーバーと連携している場合等は特にですが、起動直後にトランザクションを通知されても、そのタイミングでは諸々の準備ができておらずで処理ができないケースも多いと思います。

そんなことを踏まえ、まずはキューに突っ込んでおいて、

// コールバック設定。
InAppPurchases.setPurchaseListener(async ({ responseCode, results, errorCode }) => {
  enqueuePurchaseResult({ responseCode, results, errorCode });
});
// IAP 接続。
try {
  await InAppPurchases.connectAsync();
} catch (error) {
  // 再接続でもエラー発生。
}

認証、データロード等が終わり、準備ができたらコールバックを設定し直しつつ、それ以前にキューに突っ込まれたトランザクションを処理すれば良いと思います。

// コールバック設定し直し。
InAppPurchases.setPurchaseListener(async ({ responseCode, results, errorCode }) => {
  await onPurchase({ responseCode, results, errorCode });
});
// コールバックを設定し直す前にキューに詰めたトランザクション対応(iOS)。
const purchaseResults = dequeuePurchaseResults();
for (const result of purchaseResults) {
  await onPurchase(result);
}

setPurchaseListener() を2回読んだ場合、1回目にセットされたコールバックは解除されるようです。

ちなみに、上記は全て iOS の話ですが、Android で未処理トランザクションを扱う場合は、勝手にコールバックを呼んではくれないので、キュー処理後くらいに getPurchaseHistoryAsync({ useGooglePlayCache: true }) して未処理トランザクションを探した上で処理する形が良いかと思います。

Expo におけるアプリ起動直後

アプリ起動直後に通知される未処理トランザクションをキャッチし損ねると、未処理トランザクションが未処理のまま残り続けるというバグを引き起こします。それを回避するためには、未処理トランザクションが通知される前に初期化処理を完了させておく必要があるわけですが、Expo 特有のアレコレがあるため、処理の起点である App.tsxuseEffect() 使って初期化処理をすれば完璧・・・とはなりません。App.tsx が呼ばれる前に未処理トランザクションが通知されるケースがあるからです。

まず、分かり易い所では、expo-dev-client を使っていると駄目です。未処理トランザクション関連デバッグの効率が一気に落ちることは甘んじて受け入れましょう。

さらには、OTA アップデート設定次第では初期化が間に合いません。仮にアップデートがなくても更新確認処理で起動がブロックされてしまうと駄目になるみたいです(通信速度にもよるのかもしれませんが)。なので、app.jsonupdates.fallbackToCacheTimeout0 に設定し、アップデート待ち時間をゼロにすることで初期化が間に合うようになりました。

おわりに

今回は Expo InAppPurchases ライブラリ固有の内容ですが、それに限らず、IAP(特にサブスクリプション)にはハマりどころが満載です。どうかお気を付けて。


Product

「いつやるかは決めてないけれど、必ずやらなければいけないタスク」を忘れないためのアプリ