Flutter(Android) 定期購入詳細なテスト
前提
- flutterのソース
- functionsのソース
キャンセルしても有効期限までは使える
Integrate the Google Play Billing Library into your app
払った分のお金を日割りで返すわけじゃないので、正しいと言える。
日割りで返す場合はどうすればいいのか?今回の要件にはないからとりあえずは考えない。
価格がいきなり変更になったらどうするのか?アイテムが増えたら?
価格変更でpurchaseStreamが動いてる様子はなし
価格を変更するときにplay consoleで表示される
細かいステータスの変更はpub/subで通知されるようです
https://developer.android.com/google/play/billing/rtdn-reference
既存システムとの連携での懸念
今回の要件で、既存システムの会員はそのシステムのID, passを使ってログインできるというのがあるのですが、 既存のシステムで使ってるメールアドレスでAndroidにログインしたくない人もいそう。。。
verifyPurchaseが実行される前にhandlePlayStoreServerEventが実行されることがある
初回登録時は必ずそうなります。
予想ですが、購入ボタンでbuyNonConsumable()するときに、 handlePlayStoreServerEventに通知が行き、purchaseStreamが動き出してverifyが実行されているような気がします。
で、handlePlayStoreServerEventはverifyした直後にまた通知(pub/sub)がいくので問題ないようです。
また、
Google Play Billing Library をアプリに統合する | Google Play の課金システム
この辺りの実装を見ても、listenerにverifyするための処理を登録している(or する)ようなので、 同じ結果になりそう。
ストアからの再度定期購入
定期購入を販売する | Google Play の課金システム | Android Developers
「アプリ外での購入と見なされる」のでかなり厄介
この場合は、アプリを開かないと、verifyができない(= purchaseStreamが動かない)のでfirestoreのdocumentは作られません。 それでも、その間ユーザには定期購入が続きます。
ただ、ユーザは明示的に再度定期購入ボタンを押しているので、 問題なさそうですが、これを人に説明するのが厄介そう。
そして、本当はこの場合は、 https://developer.android.com/google/play/billing/integrate?hl=ja#fetch で書かれている通り、queryPurchasesAsync()を実行するみたいですが、 v4から追加されたメソッドになるようで、
https://developer.android.com/google/play/billing/release-notes?hl=ja#4-0
でもflutterのpluginはv3
plugins/build.gradle at master · flutter/plugins · GitHub
ということで使えませんが、purchaseStream(裏はPurchasesUpdatedListener?) でも自分が検証した感じ(今回の要件内)だと、問題なく同期は取れてるみたいです。
そもそも有料コンテンツを実際にアプリ上で使えるかどうかの判定を、
firestoreで行う場合はそこまで気にする必要がないかもしれません。
この場合は、functionsでusersの様なcollectionを更新して最新のsubscriptionの状態を記録しておくというような方法になると思います。
InAppPurchase.purchaseStreamってそもそも裏側はどうなってるの?
ソースを見たところ、StreamControllerに適宜addしている様に見えて、
インスタンス化するとき
plugins/in_app_purchase_android_platform.dart at 1a4cee78a1fd2eb9eb7377239399678115de437a · flutter/plugins · GitHub
restorePurchases()した時
plugins/in_app_purchase_android_platform.dart at 1a4cee78a1fd2eb9eb7377239399678115de437a · flutter/plugins · GitHub
の二箇所しか見つけられない。
また、restorePurchases()は内部ではqueryPurchases()を実行している様子 https://github.com/flutter/plugins/blob/1a4cee78a1fd2eb9eb7377239399678115de437a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart#L184
_inAppPurchase.restorePurchases()するボタンをつけてみたけど、 解約してから押しても変化なし(定期購読中のまま)。 単純にアプリを起動しなおした方がいい。
(その他)アプリで定期購入してすぐアプリを閉じるとplay storeでエラーが表示される
次の更新までにアプリを開かないと期限切れになる。この辺りもお問い合わせのポイントとしてはそこそこありそう。 この時、google playの購入履歴は「払い戻し済み」になる。
Flutter stream周りをもう一度ちゃんと理解する
2秒ごとに無限に数字が出力し続ける
import 'dart:async'; void main() { // 最後にtake()を追加すると指定した回数で止まる var counterStream = Stream<int>.periodic(const Duration(seconds: 2), (x) => x); counterStream.forEach(print); }
上記とほぼ同等のコードがawait forで書ける
import 'dart:async'; Future<void> main() async { var counterStream = Stream<int>.periodic(const Duration(seconds: 2), (x) => x); await for(int n in counterStream){ print(n); } }
https://dart.dev/guides/libraries/library-tour#stream
じゃあ2つのstreamがある場合は
import 'dart:async'; void main() { var counterStream = Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2); counterStream.forEach(print); var counterStream2 = Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2-1); counterStream2.forEach(print); }
2つのストリームが非同期的に出力される
上記をawait forで書き換えても同じ結果にはならない。 counterStreamがendless streamsなのでいつまで経っても二つ目のストリームが実行されない
import 'dart:async'; Future<void> main() async { var counterStream = Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2); await for(int n in counterStream) { print(n); } var counterStream2 = Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2-1); await for(int n in counterStream2) { print(n); } }
yieldを理解する
import 'dart:async'; void main() { func1().listen((e)=>print(e)); // 2 // 4 // 6 } Stream<int> func1() async* { // *をつけると別のgenerator functionに委譲できる // 再帰処理はこれで書くとパフォーマンスが良くなるらしい yield* func2(); } Stream<int> func2() async* { yield 2; yield 4; yield 6; }
https://dart.dev/guides/language/language-tour#generators
StreamSubscription
この辺からが本題
in_app_purchaseで登場するので使い方を押さえたい
https://pub.dev/packages/in_app_purchase#listening-to-purchase-updates
いろいろテストしてもonDoneの部分は通らないが、どういう場面実行されるのだろうか。
This stream will never close as long as the app is active.
と書いてあるけども。onDoneのリファレンスにも
onDone method - StreamSubscription class - dart:async library - Dart API
The handleDone function is called when the stream closes.
と書いてある。
StreamSubscription自体のリファレンスは下記
StreamSubscription class - dart:async library - Dart API
リファレンスを読むとpause, resume, cancelなどができる。cancelしたものはresumeできないそうです。
firestoreでサンプル作ってみました。
import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key? key, required this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { late StreamSubscription<DocumentSnapshot<Map<String, dynamic>>> _subscription; Map<String, dynamic> _public = {}; @override void initState() { final firestore = FirebaseFirestore.instance; _subscription = firestore .collection('publics') .doc('763fznZQsyE0DmiUzeMZ') .snapshots() .listen((event) { print('on data.'); setState(() { _public = event.data() ?? {}; }); }, onDone: () => print('on done.')); // firestoreだとdoneが実行されることはなさそう // onDoneが実行された後の再復活みたいなことはあるのだろうか? super.initState(); } @override void dispose() { _subscription.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'description: ${_public['description']}', style: Theme.of(context).textTheme.headline4, ), ElevatedButton( onPressed: _subscription.cancel, child: Text('subscription cancel')), ElevatedButton( onPressed: _subscription.pause, child: Text('subscription pause')), ElevatedButton( onPressed: _subscription.resume, child: Text('subscription resume')), ], ), ), ); } }
cancelもしくはpauseするとconsole.firebaseから変更しても画面には反映されない。 pauseした場合はresumeすると最新の情報が同期される。
flutter DartPad埋め込んでみた
https://api.flutter.dev/flutter/widgets/Dismissible-class.html
スワイプでListTileを削除する上のサンプルにアイコンを追加したもの。iframeを使えば簡単にできる。
それと
Sharing Guide · dart-lang/dart-pad Wiki · GitHub
を読むとgist IDをdartpadのURLに指定すると自動で読み込んでくれることがわかる。
Closureとは?kotlin, swiftエンジニアとflutter導入することの難しい点
始まり
メンバーの一人がこんなflutterのソースを書きました。
import 'package:flutter/material.dart'; // ツッコミどころ。 // 引数も戻り値もないからVoidCallbackでいいと思うし、 // グローバルに宣言するほどでもない // それよりもこれを利用している箇所のメンバ名をわかりやすくすればいいと思う typedef RegisterCallback = void Function(); typedef FinishCallback = void Function(); class RegisterWidget extends StatelessWidget { final RegisterCallback callback; const RegisterWidget({Key? key, required this.callback}) : super(key: key); @override Widget build(BuildContext context) { return ElevatedButton( child: Text( "登録する", ), onPressed: callback, ); } } class FinishWidget extends StatelessWidget { final FinishCallback callback; const FinishWidget({Key? key, required this.callback}) : super(key: key); @override Widget build(BuildContext context) { return ElevatedButton( child: Text( "完了する", ), onPressed: callback, ); } }
それでそのメンバーは普段はswiftを書いてるのですが、
typedef RegisterCallback = void Function();
の部分をクロージャと呼んでいました。
RegisterWidgetを初期化するときの引数にクロージャを入れるのは確かだと思いますが。。。
そこで、swiftに詳しくなかったので調べてみました。
swiftのリファレンス
https://docs.swift.org/swift-book/LanguageGuide/Closures.html
クロージャならボディ部分がないとダメっすね。 冷静に考えればそんなに難しいことでもありませんでした。
swiftにtypedefと同等の機能あるのか?
typealiasが似ているみたいですが、functionには使えないみたいです。
https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#grammar_typealias-declaration
余談
flutter導入すると、kotlin使い、swift使い、typescript使いなんかがプロジェクトに集められると思いますが、
共通認識を持つのが少し苦労します。
dartのこの部分のソースはkotlinではこうです。みたいな会話をswift使いの人に言っても通じません。
逆に、それぞれの言語でのやり方をdartにも持ち込もうとしてしまいがちです。
それぞれのエンジニアがまずはdartの勉強から始めてプロジェクトに参加できればいいですが、 そうもいかないことも時々あると思います。
kotlin、swift両方の文法に詳しい人がFlutterのリードエンジニアで参加できたらいいんでしょうね。
Assertion failed: Duplicate bundled template New Kotlin Property Initializer.kt
java.lang.Throwable: Assertion failed: Duplicate bundled template New Kotlin Property Initializer.kt [jar:file:/Applications/Android%20Studio%20Preview.app/Contents/plugins/Kotlin/lib/kotlin-idea.jar!/fileTemplates/code/New Kotlin Property Initializer.kt.ft, jar:file:/Users/takayuki/Library/Application%20Support/Google/AndroidStudio2020.3/plugins/Kotlin/lib/kotlin-idea.jar!/fileTemplates/code/New Kotlin Property Initializer.kt.ft] at com.intellij.openapi.diagnostic.Logger.assertTrue(Logger.java:201) at com.intellij.ide.fileTemplates.impl.FTManager.createAndStoreBundledTemplate(FTManager.java:210) at com.intellij.ide.fileTemplates.impl.FTManager.setDefaultTemplates(FTManager.java:199) at com.intellij.ide.fileTemplates.impl.FileTemplatesLoader.loadConfiguration(FileTemplatesLoader.java:170) at com.intellij.ide.fileTemplates.impl.FileTemplatesLoader.lambda$new$0(FileTemplatesLoader.java:64) at com.intellij.openapi.util.ClearableLazyValue$2.compute(ClearableLazyValue.java:26) at com.intellij.openapi.util.ClearableLazyValue.getValue(ClearableLazyValue.java:39) at com.intellij.openapi.util.AtomicClearableLazyValue.getValue(AtomicClearableLazyValue.java:9) at com.intellij.ide.fileTemplates.impl.FileTemplatesLoader.getAllManagers(FileTemplatesLoader.java:118) at com.intellij.ide.fileTemplates.impl.FileTemplateSettings.getState(FileTemplateSettings.java:43) at com.intellij.ide.fileTemplates.impl.FileTemplateSettings.getState(FileTemplateSettings.java:20) at com.intellij.configurationStore.ComponentStoreImpl.commitComponent(ComponentStoreImpl.kt:326) at com.intellij.configurationStore.ComponentStoreImpl.commitComponents$intellij_platform_configurationStore_impl(ComponentStoreImpl.kt:233) at com.intellij.configurationStore.ComponentStoreWithExtraComponents.commitComponents$intellij_platform_configurationStore_impl(ComponentStoreWithExtraComponents.kt:96) at com.intellij.configurationStore.ComponentStoreImpl$commitComponentsOnEdt$$inlined$withEdtContext$intellij_platform_configurationStore_impl$1.invokeSuspend(ComponentStoreImpl.kt:709) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) at com.intellij.openapi.application.constraints.BaseConstrainedExecution$Companion$scheduleWithinConstraints$1.invoke(BaseConstrainedExecution.kt:68) at com.intellij.openapi.application.constraints.BaseConstrainedExecution$Companion.scheduleWithinConstraints(BaseConstrainedExecution.kt:71) at com.intellij.openapi.application.constraints.BaseConstrainedExecution.scheduleWithinConstraints(BaseConstrainedExecution.kt:38) at com.intellij.openapi.application.impl.BaseExpirableExecutorMixinImpl.access$scheduleWithinConstraints$s1153900543(BaseExpirableExecutorMixinImpl.kt:12) at com.intellij.openapi.application.impl.BaseExpirableExecutorMixinImpl$scheduleWithinConstraints$$inlined$Runnable$1.run(Runnable.kt:19) at com.intellij.openapi.application.TransactionGuardImpl.runWithWritingAllowed(TransactionGuardImpl.java:216) at com.intellij.openapi.application.TransactionGuardImpl.access$200(TransactionGuardImpl.java:24) at com.intellij.openapi.application.TransactionGuardImpl$2.run(TransactionGuardImpl.java:199) at com.intellij.openapi.application.impl.ApplicationImpl.runIntendedWriteActionOnCurrentThread(ApplicationImpl.java:828) at com.intellij.openapi.application.impl.ApplicationImpl.lambda$invokeLater$4(ApplicationImpl.java:330) at com.intellij.openapi.application.impl.FlushQueue.doRun(FlushQueue.java:85) at com.intellij.openapi.application.impl.FlushQueue.runNextEvent(FlushQueue.java:134) at com.intellij.openapi.application.impl.FlushQueue.flushNow(FlushQueue.java:47) at com.intellij.openapi.application.impl.FlushQueue$FlushNow.run(FlushQueue.java:190) at java.desktop/java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:313) at java.desktop/java.awt.EventQueue.dispatchEventImpl(EventQueue.java:776) at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:727) at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:721) at java.base/java.security.AccessController.doPrivileged(Native Method) at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:85) at java.desktop/java.awt.EventQueue.dispatchEvent(EventQueue.java:746) at com.intellij.ide.IdeEventQueue.defaultDispatchEvent(IdeEventQueue.java:976) at com.intellij.ide.IdeEventQueue._dispatchEvent(IdeEventQueue.java:843) at com.intellij.ide.IdeEventQueue.lambda$dispatchEvent$8(IdeEventQueue.java:454) at com.intellij.openapi.progress.impl.CoreProgressManager.computePrioritized(CoreProgressManager.java:773) at com.intellij.ide.IdeEventQueue.lambda$dispatchEvent$9(IdeEventQueue.java:453) at com.intellij.openapi.application.impl.ApplicationImpl.runIntendedWriteActionOnCurrentThread(ApplicationImpl.java:828) at com.intellij.ide.IdeEventQueue.dispatchEvent(IdeEventQueue.java:501) at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:203) at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:124) at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:113) at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:109) at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:101) at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:90)
kotlinのplugin 203-1.5.30-M1-release-140-AS7717.8
をうっかりインストールするたびにエラーが起こって削除してます。
plugin uninstall後は203-1.5.20-release-289-AS7717.8
に戻ります。
uninstallしても完全に消えてなくなるわけじゃなく1つ前?に戻るようです。
1.5.30ってなんだ?と思ったらこういうことみたいです。
Preview of Kotlin 1.5.30 With Native Apple Silicon Support, Improved Kotlin DSL for the CocoaPods Gradle Plugin, and More | The Kotlin Blog
環境
Android Studio Arctic Fox | 2020.3.1 Build #AI-203.7717.56.2031.7583922, built on July 27, 2021 Runtime version: 11.0.10+0-b96-7281165 x86_64 VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o. macOS 11.5.1 GC: G1 Young Generation, G1 Old Generation Memory: 2560M Cores: 8 Registry: external.system.auto.import.disabled=true Non-Bundled Plugins: Dart, com.thoughtworks.gauge, org.jetbrains.kotlin, io.flutter, org.intellij.plugins.markdown
Firestore purchasesはtopレベルのコレクションにするかusersの配下か
purchasesが一番上のコレクションの場合
DocumentId=orderIdにできる
サーバ通知(pubsub or iosはonRequest)でFunctionsにアクセスがあった場合は、UIDで判別はできないのでこのやり方が便利
ドキュメントにUIDを持つ必要がある
アプリで有料コンテンツにアクセスできるかどうかをwhereで判別する
db.collection("purchases").where('uid' , "==", uid);
セキュリティルールも追加する
users配下の場合
サーバ通知の場合はcollectionGroupでpurchasesを検索
db.collectionGroup("purchases").where('orderId' , "==", orderId);
セキュリティルールとcollectionGroupは別でインデックスを追加する必要がある。 そして、インデックスにはお金がかかる
有料コンテンツ判定
ログインしてればuidは取得できてるので
db.collection("users").doc(uid).collection("purchases");
結論
インデックスの分だけ後者が不利
番外編 usersドキュメントにpurchasesをmapで持つ
- purchasesが不要な場面でも取得される
- classとか型を作らずにmapでそのままアクセスするメンバー(エンジニア)が出てきそう
- フィールドに対するセキュリティルールは少し面倒だったはず
https://firebase.google.com/docs/firestore/manage-data/structure-data
上記も読み直してみたが、mapのメリットが見当たらない。。。
basic-android-kotlin-training-intro-room-flow#7 Cannot access database on the main thread
https://developer.android.com/codelabs/basic-android-kotlin-training-intro-room-flow#7
上のページの最後でアプリを起動させることになってるが、起動しない。原因は下記。
なので、次のページのFlowを導入するところまでやり切ってから起動させた方が慣れてない人にはいいと思います。 それと上のissueを見ると、このcodelabをやる前にやった方がいいcodelabがあるようです。
Roomのcodelabsはsqlについても少し解説してくれてるので、企業の新人教育にも使えるくらいだと 思っていたけど、問題にぶつかった時にgithubのissuesを検索する手順も教えておかないといけないかもしれない。
ただ、そんなことは実際の開発ではよくあることなので、このタイミングで教えてもいいのかもしれない。