- 投稿日:2019-08-21T22:41:18+09:00
React Native + React Navigation で、 Android の画面遷移アニメーションを iOS と同様にする
React Navigation の画面遷移のアニメーション、 Android だけよくわからない。
デフォルトだと、モーダルが重なるようになるが、 iOS のように左から右に進んでいくようにしたい。
めっちゃ簡単にできた。
import { createAppContainer, createStackNavigator, StackViewTransitionConfigs, } from 'react-navigation' const Navigation = createStackNavigator({ screenA: ComponentA, screenB: ComponentB, }, { mode: 'card', transitionConfig: () => StackViewTransitionConfigs.SlideFromRightIOS, } export const AppNavigation = createAppContainer(Navigation)なんでこれがデフォルトじゃないんだ・・・笑
- 投稿日:2019-08-21T22:41:00+09:00
onWindowFocusChanged()を使ってViewのサイズを取得していたらマルチウィンドウ対応で詰んだ話
初めまして。
Java歴実働約2年、Android開発およびKotlin歴約2週間の駆け出しエンジニアです。
お盆休暇を使ってKotlinでAndroid開発の勉強をした際に、表題の件で躓いてしまったため、ここに記録しようかと思います。
中級者以上の方からしたら既知のことしか書いてない記事ですが、同じような初級者の方の参考になれば幸いです。
コードの記述は少ないですがKotlinで書いています。もともとやろうとしていたこと
以下のようなHorizonalなLinearLayout(in ScrollView)の中に画像を並べたVerticalなLinearLayoutを動的に追加する処理を行おうとしました。
私が作ったのは縦に並んだ画像の列を追加していくというものでしたが、縦横が逆の場合でも大差ないと思います。電子書籍アプリの本棚なども縦横を逆にした感じのレイアウトになっているのでは?と思っているので、それをイメージしてもらえるとわかりやすいと思います。
これを実装するにあたって、「タイトルバーやメニューバーを除いた画面のサイズを取得し、そのパラメータを使ってViewを生成し配置したい」と思いました。その場合、あらかじめ配置したViewのサイズを取得して利用するという方法があります。
しかし、onCreate()などが呼び出されるタイミングではViewの描画がなされていないため、ここでViewの幅や高さを取得しようとしても0が帰ってきてしまいます。
画面のサイズを動的に取得する方法としては、onWindowFocusChanged()を利用するという手法の紹介がなされている記事をしばしば見かけます。
ですが、このメソッドで画面サイズを取得しその値を用いてViewを作成するという処理を実装すると、マルチウィンドウ(SplitScreen)を考慮した場合に少し問題が発生します。以下に詳細を記述します。Freedomの場合は未確認です。
マルチウィンドウ(SplitScreen)実行時のonWindowFocusChanged()の呼び出され方
onWindowFocusChanged()はonCreate()よりも後に呼び出されます。Boolean値を引数にとり、画面にフォーカスされた場合はtrueが、画面からフォーカスが外れた場合はfalseを引数として実行されます。
このonWindowFocusChanged()ですが、マルチウィンドウ(Split Screen)を起動、画面サイズ変更、終了したときには以下のようになります。起動する時
onCreate()から新たに呼び出されonWindowFocusChanged(false)が実行されます。上画面(実装したアプリ側)をタッチしない限りonWindowFocusChanged(true)は呼び出されません。
上画面(実装したアプリ側)にフォーカスが当たっている状態で、中央のバー(正式名称不明)でサイズを変更する時
onCreate()から新たに呼び出されますが、そのままでも上画面をタッチしてもonWindowFocusChanged()が呼び出されません。下画面をタッチすることでonWindowFocusChanged(false)が呼び出され、その後上画面をタッチすることでonWindowFocusChanged(true)が呼び出されます。
上画面(実装したアプリ側)にフォーカスが当たっている状態でマルチウィンドウを解除する時
onCreate()から新たに呼び出されますが、画面をタッチしてもonWindowFocusChanged()が呼び出されません。そのため、右下のボタンを押すなどでアプリから一度遷移するなどしてonWindowFocusChanged(false)を呼び出さないとonWindowFocusChanged()で実装した処理を行うことができません。
マルチウィンドウ機能自体を不可にしてしまえば問題はないのですが、マルチウィンドウでも使用したかったため、別の方法を考える必要が出てきました。
改めて、私がやりたいことは、画面起動時およびマルチウィンドウ起動時に「ViewGroupの正確なサイズを取得すること」と「ViewGroupの値を使用して子ViewGroupを新たに作成し追加すること」です。考えたり調べたりして色々解決策を探しました。
- LinearLayoutを継承したカスタムビューを作成し、onMeasure()とonLayout()とonDraw()で処理をする。
- ViewTreeObserverを使用する。
結論としては、2で実装しました。1でかなり時間をかけたのですがうまくいきませんでした。
解決方法
ViewTreeObserverを使用する。
こちらを参考に
view_target.viewTreeObserver.addOnGlobalLayoutListener(object: ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { makeView(view_target.width,view_target.height) } })を実装したところ、正確にViewのサイズが取得できました。
ただしこのままだと何度も呼び出されてしまうため、removeOnGlobalLayoutListener()を実行したいのですが、Kotlinでの書き方がよくわからず…。
ですので、こちらに書いてあった拡張関数を用いた書き方を使わせてもらいました。override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 〜 view_target.afterMeasured { makeView(view_target.width,view_target.height) } } inline fun <T : View> T.afterMeasured(crossinline f: T.() -> Unit) { viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { if (width > 0 && height > 0) { viewTreeObserver.removeOnGlobalLayoutListener(this) f() } } }) }ちなみに上記のサイトではif文にmeasuredWidth,measuredHeightを使っていますが、私の環境では上手く行かなかったためwidth,heightを使っています。
【蛇足】上手く行かなかったやり方:カスタムビュー内で描画処理
onMeasure()で描画
Viewの生成時に呼び出されるメソッドの一つである、onMeasure()をオーバーライドする方法です。
主にView自身のサイズを決定するために用いられる処理、と認識しています。
ここでwidthSize = MeasureSpec.getSize(widthMeasureSpec) heightSize = MeasureSpec.getSize(heightMeasureSpec)をつかって以下のように画面サイズを取得しているサンプルをよく見かけます。
ただし、この処理は複数回呼び出されるため、重たい処理を行うのは不適切のようです。あくまで画面サイズの取得に限定した方がよさそうです。
後述しますが、onLayout()に記述する場合でもうまくいかなかったため、フラグを立ててここで一度だけ描画処理をする実装をしてみたことがあります。ですが、最初に呼び出されるMeasureSpec.getSize(widthMeasureSpec)、MeasureSpec.getSize(heightMeasureSpec)で取得できる画面サイズは、実際のViewよりも小さい間違ったものでした。これが仕様なのかバグなのか、ScrollViewを使用しているからなのかは、私が調べた範囲ではわかりませんでした。1,2回実行されたのち(ライフサイクルの特定の処理まで終わったのち?)には正確な値を取得するようになるのですが、よくわかりませんでした…。onLayout()で描画
Viewの生成時に呼び出されるメソッドの一つである、onLayout()をオーバーライドする方法です。
主に子ビューの配置を行う際に呼び出す処理、と認識しています。
onMeasure()で画面サイズを取得し、ここに描画処理を実装した場合には、エミュレータ・実機ともに想定通りの動きをしてくれました。override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) widthSize = MeasureSpec.getSize(widthMeasureSpec) heightSize = MeasureSpec.getSize(heightMeasureSpec) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { super.onLayout(changed, l, t, r, b) makeView(widthSize,heightSize) }ただし、
requestlayout() improperly called by LinearLayout... ... requestlayout() improperly called by LinearLayout...という警告文が、追加した子LinearLayoutの数だけ表示されてしまっていました。
色々ググってみたのですが、原因がわからず警告文を消すことができませんでした。
ただ、onLyaout()は一度しか呼ばれない処理のようですが、ログを見ると複数回呼び出されているように思います。起動時に一度実行された後にどこかでrequestlayout()でonLayout()が呼び出されており、その際にonLayout()に書かれている描画処理が再び呼び出されることに対して警告を発しているのではないか?と思っていますが、実際のところはわかりません…。onDraw()で描画
Viewの生成時に呼び出されるメソッドの一つである、onDraw()をオーバーライドする方法です。
onLayout()でダメなのだからここでもダメなのでは?と思いながらダメ元で実装してみたところ、そもそもこの処理を呼び出してもらえませんでした。
本題とは逸れますが、呼び出されない理由としては
- カスタムビューではonDraw()がデフォルトで呼び出されないようになっている。呼び出すにはsetWillNotDraw(false)が必要。
- ViewGroupではonDraw()ではなくdispatchDraw()が呼び出される。
とのことでした。
そこで、setWillNotDraw(false)をコンストラクタに追加し、dispatchDraw()をオーバーライドして実装しましたが、呼び出されることはありませんでした。念のためonDraw()もオーバーライドしましたが変わらずでした。現時点ではこのせいで困っていることはありませんが、もしかしたら躓く日が来てしまうかもしれないと思うと心配です。
あとがき
終わってみると「なんだこれだけでいいのか…。」という感じですが、検索に使えるワードにたどり着くまでに随分と苦労しました。このあたりの技術も身に着けていかないと、開発を続けていけないんでしょうね。
API_27だと画像が表示されるのにAPI_24だとなされない(onLayoutに記述した場合はできた)という新たな問題に直面したのでデバッグはまだまだ続きそうです…。
- 投稿日:2019-08-21T20:26:26+09:00
【Android】NumberPickerでフォーカスが当たらないようにする
解決法
xmlに
android:descendantFocusability="blocksDescendants"
を追加してあげればOK。<NumberPicker android:id="@+id/num_picker_1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:descendantFocusability="blocksDescendants" //追加 app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="parent"/>android - Disable soft keyboard on NumberPicker - Stack Overflow
- 投稿日:2019-08-21T20:26:26+09:00
【Android】NumberPickerでFocusが当たらないようにする
解決法
xmlに
android:descendantFocusability="blocksDescendants"
を追加してあげればOK。<NumberPicker android:id="@+id/num_picker_1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:descendantFocusability="blocksDescendants" //追加 app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="parent"/>android - Disable soft keyboard on NumberPicker - Stack Overflow
- 投稿日:2019-08-21T17:54:44+09:00
Ionicアプリをストア通さずにアップデートしてみた
MicrosoftのCodePushに感動した話。
背景
ハイブリッドアプリのjs/ts部分をちょこっと修正する度にストアに申請するのは手間である!!
(特にiOSの場合は審査に時間を要するしね。)そこで「ゆーてNativeの部分を書き換えないんだし、jsだけ更新する仕組みないかなぁ...」と思ったら、ありました。
CodePushとの運命的な出会いです。CodePush
CodePushとは、IonicやReact Nativeなどハイブリッドアプリの生産性向上のために生み出された魔法のようなツールです。冒頭での紹介の通り、公式ストアでのアップデートを介さずにHTML,CSS,JSなどを更新する機能を持ちます。
ネットの評判読んだ感じ「Ionic4ではうまく動かないよ!」という感触だったのですが、いざ使ってみるとちゃんと稼働したので記事にしたためました。
(未検証ですがReact Nativeでの実装も同様にできそうです。)実装
今回の環境
- macOS Mojave(v10.14.6)
- Node.js v10.15.0
- Ionic: v5.2.5
- cordova v9.0.0
- [実機]Google Pixel 3a(Android 9)
途中の処理でcordovaバージョン9.0.0を必須としてきているので、お手元のバージョンを必ずご確認ください。
1. Ionic プロジェクトの作成
Blankで作成しますが、ご自身の環境にあわせて適宜変更してください。詳細は割愛します。
2. Microsoft AppCenterでの作業
2-1. ログイン
CodePushは現在Microsoft AppCenter内の一機能のようですので、まずはこちらにログインしましょう。Microsoft・Googleアカウントでの認証以外にも、先日買収したことで話題になったGithubでの認証も実装されています。2-2. アプリの作成
画面中央の【Add app】を押して、アプリを作成しましょう。
OSはAndroid/iOSのいずれかを選択します。
またIonicで作成する場合には、PlatformはCorodvaを選択します。
【Getting started】の画面に遷移したら、[2]Link the SDKの項にあるとおり
<platform name="[OS name]"> <preference name="APP_SECRET" value="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" /> </platform>を、先ほど作成したプロジェクトの
config.xml
に適用します。3. プロジェクトの設定
3-1. プラグインの導入
CodePushプラグインを導入します。公式サイトにあるとおり
$ ionic cordova plugin add cordova-plugin-code-push $ npm install @ionic-native/code-pushで問題ありません。(
src/app/app.module.ts
への追記をお忘れ無きよう。)3-2. CodePushの利用
アプリ起動時にこっそり同期していて欲しいので、
src/app/app.component.ts
に所定の処理を記述します。src/app/app.component.tsimport { Component } from "@angular/core"; import { Platform } from "@ionic/angular"; import { SplashScreen } from "@ionic-native/splash-screen/ngx"; import { StatusBar } from "@ionic-native/status-bar/ngx"; // 追記 import { CodePush } from "@ionic-native/code-push/ngx"; @Component({ selector: "app-root", templateUrl: "app.component.html", styleUrls: ["app.component.scss"] }) export class AppComponent { constructor( private platform: Platform, private splashScreen: SplashScreen, private statusBar: StatusBar, private codePush: CodePush, // 追記 ) { this.initializeApp(); } initializeApp() { // ここから追記 this.codePush.sync().subscribe(syncStatus => console.log(syncStatus)); const downloadProgress = progress => { console.log( `Downloaded ${progress.receivedBytes} of ${progress.totalBytes}` ); }; this.codePush .sync({}, downloadProgress) .subscribe(syncStatus => console.log(syncStatus)); // ここまで追記 this.platform.ready().then(() => { this.statusBar.styleDefault(); this.splashScreen.hide(); }); } }4. 使ってみる
4-1. CodePushでの操作
まずCodePushを再び開きます。アプリの左メニューに【Distribute/CodePush】があるのでこちらに移動しましょう。
「デプロイを設置してね」と言われるので青いボタンを押します。おそらく
Staging
リリース とProduction
リリースが設置されると思います。
Everything is ready.と言われたらあと一歩です!
右上の工具マークを押してデプロイキーを表示しましょう。
このうち使いたいリリースのデプロイキーをコピーします。(今回はProduction)そしてプロジェクトの
config.xml
の対応するOSの位置に下記の設定を記述します。<preference name="CodePushDeploymentKey" value="[先ほどのデプロイキー]" />また、このページの【Release an update】のコマンドをどこかに控えておいてください。
4-2. 実機へのインストールその1
開発端末に実機を接続し
ionic cordova run android
を実行します。すると初期版のアプリが実機にインストールされます。4-3. CodePushへデプロイ
その後実機を切断して、ソースコードを改変します。
src/app/home/home.page.html
に追記するのがわかりやすいかと思います。src/app/home/home.page.html<ion-header> <ion-toolbar> <ion-title> Ionic Blank </ion-title> </ion-toolbar> </ion-header> <ion-content> <div class="ion-padding"> The world is your oyster. <p>If you get lost, the <a target="_blank" rel="noopener" href="https://ionicframework.com/docs/">docs</a> will be your guide.</p> <!--ここから下 --> <br> <br> <p><b>This is the new Version</b></p> </div> </ion-content>次に、CLI上でAppCenterにログインします。下記のコマンドを実行しましょう。
$ npm i -g appcenter $ appcenter loginそしてプロジェクトルートで先ほどのRelease an updateのコマンドを実行します。
$ appcenter codepush release-cordova -a 【プロジェクト名】 -d 【リリース名】その後実機のアプリを一度閉じて開くと...無事に実機の表示が更新されているはずです。
更新されないときは...
AppCenterのCodePushページで適当なリリースにデプロイされているか確認できます。
また、デプロイ一覧からデプロイを押すと詳細を確認できます。(アップデートのリーチ状況など)万が一それでもダメなときは、デプロイ設定(右上の工具マーク)から【Required Update】をオンにするとイケそうです。
まとめ
- Ionicがますます便利になりそう
- AppCenter、実はアクティブユーザー等統計や、Push通知なども実装している
- そのうち検証したいです。
- 投稿日:2019-08-21T17:54:44+09:00
【CodePush】Ionicアプリをストアを経由せずに更新する術
MicrosoftのCodePushに感動した話。
背景
ハイブリッドアプリのjs/ts部分をちょこっと修正する度にストアに申請するのは手間である!!
(特にiOSの場合は審査に時間を要するしね。)そこで「ゆーてNativeの部分を書き換えないんだし、jsだけ更新する仕組みないかなぁ...」と思ったら、ありました。
CodePushとの運命的な出会いです。CodePush
CodePushとは、IonicやReact Nativeなどハイブリッドアプリの生産性向上のために生み出された魔法のようなツールです。冒頭での紹介の通り、公式ストアでのアップデートを介さずにHTML,CSS,JSなどを更新する機能を持ちます。
ネットの評判読んだ感じ「Ionic4ではうまく動かないよ!」という感触だったのですが、いざ使ってみるとちゃんと稼働したので記事にしたためました。
(未検証ですがReact Nativeでの実装も同様にできそうです。)実装
今回の環境
- macOS Mojave(v10.14.6)
- Node.js v10.15.0
- Ionic: v5.2.5
- cordova v9.0.0
- [実機]Google Pixel 3a(Android 9)
途中の処理でcordovaバージョン9.0.0を必須としてきているので、お手元のバージョンを必ずご確認ください。
1. Ionic プロジェクトの作成
Blankで作成しますが、ご自身の環境にあわせて適宜変更してください。詳細は割愛します。
2. Microsoft AppCenterでの作業
2-1. ログイン
CodePushは現在Microsoft AppCenter内の一機能のようですので、まずはこちらにログインしましょう。Microsoft・Googleアカウントでの認証以外にも、先日買収したことで話題になったGithubでの認証も実装されています。2-2. アプリの作成
画面中央の【Add app】を押して、アプリを作成しましょう。
OSはAndroid/iOSのいずれかを選択します。
またIonicで作成する場合には、PlatformはCorodvaを選択します。
【Getting started】の画面に遷移したら、[2]Link the SDKの項にあるとおり
<platform name="[OS name]"> <preference name="APP_SECRET" value="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" /> </platform>を、先ほど作成したプロジェクトの
config.xml
に適用します。3. プロジェクトの設定
3-1. プラグインの導入
CodePushプラグインを導入します。公式サイトにあるとおり
$ ionic cordova plugin add cordova-plugin-code-push $ npm install @ionic-native/code-pushで問題ありません。(
src/app/app.module.ts
への追記をお忘れ無きよう。)3-2. CodePushの利用
アプリ起動時にこっそり同期していて欲しいので、
src/app/app.component.ts
に所定の処理を記述します。src/app/app.component.tsimport { Component } from "@angular/core"; import { Platform } from "@ionic/angular"; import { SplashScreen } from "@ionic-native/splash-screen/ngx"; import { StatusBar } from "@ionic-native/status-bar/ngx"; // 追記 import { CodePush } from "@ionic-native/code-push/ngx"; @Component({ selector: "app-root", templateUrl: "app.component.html", styleUrls: ["app.component.scss"] }) export class AppComponent { constructor( private platform: Platform, private splashScreen: SplashScreen, private statusBar: StatusBar, private codePush: CodePush, // 追記 ) { this.initializeApp(); } initializeApp() { // ここから追記 this.codePush.sync().subscribe(syncStatus => console.log(syncStatus)); const downloadProgress = progress => { console.log( `Downloaded ${progress.receivedBytes} of ${progress.totalBytes}` ); }; this.codePush .sync({}, downloadProgress) .subscribe(syncStatus => console.log(syncStatus)); // ここまで追記 this.platform.ready().then(() => { this.statusBar.styleDefault(); this.splashScreen.hide(); }); } }4. 使ってみる
4-1. CodePushでの操作
まずCodePushを再び開きます。アプリの左メニューに【Distribute/CodePush】があるのでこちらに移動しましょう。
「デプロイを設置してね」と言われるので青いボタンを押します。おそらく
Staging
リリース とProduction
リリースが設置されると思います。
Everything is ready.と言われたらあと一歩です!
右上の工具マークを押してデプロイキーを表示しましょう。
このうち使いたいリリースのデプロイキーをコピーします。(今回はProduction)そしてプロジェクトの
config.xml
の対応するOSの位置に下記の設定を記述します。<preference name="CodePushDeploymentKey" value="[先ほどのデプロイキー]" />また、このページの【Release an update】のコマンドをどこかに控えておいてください。
4-2. 実機へのインストールその1
開発端末に実機を接続し
ionic cordova run android
を実行します。すると初期版のアプリが実機にインストールされます。4-3. CodePushへデプロイ
その後実機を切断して、ソースコードを改変します。
src/app/home/home.page.html
に追記するのがわかりやすいかと思います。src/app/home/home.page.html<ion-header> <ion-toolbar> <ion-title> Ionic Blank </ion-title> </ion-toolbar> </ion-header> <ion-content> <div class="ion-padding"> The world is your oyster. <p>If you get lost, the <a target="_blank" rel="noopener" href="https://ionicframework.com/docs/">docs</a> will be your guide.</p> <!--ここから下 --> <br> <br> <p><b>This is the new Version</b></p> </div> </ion-content>次に、CLI上でAppCenterにログインします。下記のコマンドを実行しましょう。
$ npm i -g appcenter $ appcenter loginそしてプロジェクトルートで先ほどのRelease an updateのコマンドを実行します。
$ ionic cordova build android # wwwディレクトリを更新します $ appcenter codepush release-cordova -a 【プロジェクト名】 -d 【リリース名】その後実機のアプリを一度閉じて開くと...無事に実機の表示が更新されているはずです。
更新されないときは...
AppCenterのCodePushページで適当なリリースにデプロイされているか確認できます。
また、デプロイ一覧からデプロイを押すと詳細を確認できます。(アップデートのリーチ状況など)万が一それでもダメなときは、デプロイ設定(右上の工具マーク)から【Required Update】をオンにするとイケそうです。
欠点(...?)
あくまでwwwディレクトリの更新なので、Native APIは更新できない(
ionic cordova plugin add hoge
で導入するやつ)まとめ
- Ionicがますます便利になりそう
- AppCenter、実はアクティブユーザー等統計や、Push通知なども実装している
- そのうち検証したいです。
- 投稿日:2019-08-21T16:14:48+09:00
51歳からのプログラミング 備忘 バックグラウンド実行制限 OverView [写経]
https://developer.android.com/about/versions/oreo/background?hl=ja
[写経]:自分用
バックグラウンド制限
システム負荷などで、アプリの予期せぬシャットダウンを回避するため、アプリを直接操作してない時のアプリの実行動作を制限します。制限する方法は次の2つです。
バックグラウンドサービス制限
- アプリがアイドル状態となると、バックグラウンドサービスを制限
- フォアグラウンドサービスには制限は適用しない
ブロードキャスト制限
Android7.0(API24)以降で制限を課し、Android8.0(API26)で制限を強化している
限定的な例外を除き
- アプリはマニフェストを使用して、暗黙的なブロードキャストを登録できない。
- ただしアプリ実行時であれば暗黙的なブロードキャストを登録できる。
- アプリはマニフェストを使用して、明示的なブロードキャストを登録できる。
制限とAPIレベル
Note:
デフォルトでは、これらの制限はAndroid8.0(API26)以降を対象とするアプリにのみ適用。
ただしアプリがAPI26未満を対象としている場合でも、ユーザーは[Settings]画面でアプリに対してこれらの制限の大半を有効にできます。(制約を設けたほうがユーザーエクスペリエンスの質を担保するでしょう)多くの場合、アプリはJobSchedulerで処理することで制限を回避できます。
JobSchedulerは、アプリがアクティブでない時に、処理を実行するように調整させますが、ユーザーエクスペリエンスに影響しないように処理をスケジュールすることもできます。
Android 8.0 では、JobSchedulerにいくつかの改善が追加されており、サービスとブロードキャスト レシーバーをスケジュールされたジョブに簡単に置き替えることができます。詳細については、JobScheduler の改善をご覧ください。
JobScheduler:
いろんな処理を、効率よく実行するように、タスク管理するスケジュールサービス。バックグラウンドサービスの制限
フォアグランドとみなされる場合
- 可視アクティビティがある(一時停止されていたも可)
- フォアグラウンドサービスを利用
- アプリが他のフォアグランドアプリと接続されており、フォアグランドアプリのサービスのどれか一つにバインドしている
- アプリが他のフォアグランドアプリと接続されており、フォアグランドアプリのコンテントプロバイダを使っている
= 他のフォアグランドアプリのバインド例 =
- IME
- Wallpaper service
- Notification listener
- Voice or text service
これら以外の状態なら、アプリはバックグラウンド処理とみなされます。
Note:
上記のルールはバインドされたサービスには適用しません。アプリでバインドされたサービスを定義してる場合、アプリがフォアグランドにあろうとなかろうと、別のコンポーネントをそのサービスにバインドできます。システムによるバックグラウンドアプリの終了
アプリがフォアグランドで実行されている間は、自由にフォアグランドやバックグラウンドサービスを生成したり実行できます。アプリがバックグラウンドに移ると、数分間だけサービスを生成したり実行する余裕が生まれますが、それが過ぎるとシステムはアプリをアイドル状態と判定し、Service.stopSelf()メソッドを使った時のように、アプリのバックグラウンドサービスを停止します。
アプリのホワイトリストへの追加
アプリがホワイトリストに入る条件は、以下のようにユーザーが視認できる処理をしてる時です。
特定の条件のもと、バックグラウンドアプリはテンポラリーなホワイトリストに入り、数分間は自由にサービスを起動できて、実行も許可されます。
- 高い優先度のFirebase Cloud Messageing (FCM)メッセージの処理
- ブロードキャストの受信(SMS/MMSメッセージなど)
- 通知からのPendingIntentの実行
- VPNアプリが、自信をフォアグラウンドにプロモートする前の、VpnServiceの開始
IntentService/JobIntentServiceとAndroid8.0以降
IntentServiceでバックグラウンドサービスを扱っている場合
Note:
IntentServiceはサービスなので、バックグラウンドサービスに対する新しい制限事項の対象になります。ですので、IntentServiceに依存するアプリは、Android8.0以降では正常に動作しません。
こうした理由からAndroid Support Library 26.0.0では、新しくJobIntentServiceクラスを導入しました。このクラスはIntentServiceと同じ機能を提供しますが、Android8.0以降で実行されるとき、サービスの代わりにジョブを使用します。
JobScheduler
多くの場合、アプリはバックグラウンドサービスをJobSchedulerのジョブに置き換えられます(Android8.0:API26以降に移行する場合など)。スケジュールされたジョブは、定期的に起動され、サーバーに対してクエリを実行して終了します。
Android8.0前後でのバックグラウンドサービス生成
Android8.0以前では、バックグラウンドサービスを作成する方法として、バックグラウンドサービスを作成してから、そのサービスをフォアグラウンドにプロモートしてます。
Android8.0以降では、システムはバックグラウンドアプリによるバックグランドサービスの作成を許可しなくなってます。その代わりに、startForegroundService()メソッドが導入されており、このメソッドを使って、フォアグラウンドで新しいサービスを開始います。
startForegroundService()を実行したら、5秒以内にstartForeground()を呼びだし、新しいサービスの通知(notification)をユーザーに表示します。アプリが制限時間内にstartForeground()を呼ばないと、システムによってサービスは停止され、アプリがANR(application not respondeing)となります。
ブロードキャストの制限 省略
移行
API26未満を対象にバックグラウンド制限を適用する方法を紹介します。バックグラウンド制限を設けることは、ユーザーエクスペリエンスの質を保つことに繋がります。
API26未満でも[settings]画面でアプリに対して制限を有効にできます。新しい
制限に準拠するため、アプリをアップデートする必要があるケースがあります。置き換える必要がある場合
- アプリがアイドル状態のときに、バックグランドサービスの処理に依存してる
- マニフェストで暗黙的ブロードキャストに対してレシーバを宣言してる
アプリがアイドル状態のときに、そのアプリがバックグラウンドサービスの処理に依存してる場合、そのアプリは、これらのサービスを置き換える必要があります。
<置き換える方法>
アプリがバックグラウンドにあり、フォアグラウンドサービスを作成する場合
startForegroundServiceメソッドを、startService()の代わりに使用して、フォアグランドサービスを生成ユーザーに表示するサービスはフォアグラウンドサービスにする
startForegroundServiceメソッドを、startService()の代わりに使用して、フォアグランドサービスを生成スケジュールされたジョブを使ってサービス機能を複製
一般的に、ユーザーが認識できる状況で稼働してないサービスの代わりとして、スケジュールされたジョブを使うバックグラウンドでポーリングせず、FCMを使ってネットワークイベントが発生したときにアプリを選択的に起動する
ポーリング:一定間隔で他のシステムに問い合わせするアプリが自然にフォアグランドになるまでバックグランド動作を保留
アプリのマニフェストで定義されているブロードキャストレシーバーを確認。マニフェストで暗黙的なブロードキャストに対してレシーバーを宣言してる場合、それを置き換える必要があります。考えられる解決策は以下です。
<置き換える方法>
マニフェストでレシーバーを宣言するのではなく、Context.registerReceiver()を呼び出し、実行時にレシーバーを作成する
スケジュールされたジョブを使って、暗黙的なブロードキャストのきっかけ条件を確認します
- 投稿日:2019-08-21T14:41:50+09:00
Unity IAPを試してみた (Yet Another Purchaser.cs)
前提
- Unity 2018.4.5f1
- Unity IAP 1.22.0
- Apple App Store、Google Play Store
- この記事では、Unity IAPの一部機能を限定的に使用し、汎用性のない部分があります。
- サーバレス、消費/非消費タイプ使用、購読タイプ未使用
- この記事のソースは、実際のストアでテストしていますが、製品版での使用実績はありません。
- ソース中のIDは実際と異なります。
公式ドキュメント
- マニュアル
- スクリプトリファレンス
- 2019/08/02時点で、バージョンによって記述があったりなかったりします。
- 記述あり 5.6、2017.1、2017.2、2017.3、2018.1、2018.2
- 記述なし 2017.4、2018.3、2018.4、2019.1
ネームスペース
UnityEngine.Purchasing
- 必須のネームスペースです。
UnityEngine.Purchasing.Security
- レシートの検証で必要なネームスペースです。
- スクリプトリファレンスに記述が見つかりません。
UnityEngine.Purchasing.Extension
- この記事では扱いません。
初期化
初期化の開始
UnityPurchasing.Initialize ()
を呼ぶことで、初期化を開始します。UnityPurchasing.Initialize (Purchaser instance, ConfigurationBuilder builder);
- 初期化の要求はブロックされず、後に、結果に応じたコールバックがあります。
イベントハンドラ
- コールバックを受け取るために、
IStoreListener
を継承したクラスのインスタンスが必要です。
- 必ずしも
MonoBehaviour
を継承する必要はありません。- インターフェイス
IStoreListener
では、OnInitialized ()
、OnInitializeFailed ()
、OnPurchaseFailed ()
、ProcessPurchase ()
の4つのイベントハンドラが必要になります。準備
Initialize ()
を呼ぶためには、ConfigurationBuilder builder
のインスタンスを得る必要があります。ConfigurationBuilder.Instance ()
を呼ぶためには、IPurchasingModule
を継承したクラスのインスタンスが必要なようですが、この辺りを記載したドキュメントが見つかりません。- 付属のDemoでは、
StandardPurchasingModule
がそのクラスにあたるようで、そのインスタンスを得て使われています。- 得られたインスタンス
module
にストアの設定を行い、さらにbuilder
インスタンスを得ます。- 得られたインスタンス
builder
に製品を登録し、検証器を生成して、初期化を開始します。- ここでは、クラスのコンストラクタで、準備から初期化の開始までを行っています。
- コンストラクタが
private
なのは、シングルトンで使うためです。Purchaser.cs/// <summary>コンストラクタ</summary> private Purchaser (IEnumerable<ProductDefinition> products) { Debug.Log ("Purchaser.Construct"); var module = StandardPurchasingModule.Instance (); module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser; isGooglePlayStoreSelected = Application.platform == RuntimePlatform.Android && module.appStore == AppStore.GooglePlay; isAppleAppStoreSelected = Application.platform == RuntimePlatform.IPhonePlayer && module.appStore == AppStore.AppleAppStore; validator = new CrossPlatformValidator (GooglePlayTangle.Data (), AppleTangle.Data (), Application.identifier); var builder = ConfigurationBuilder.Instance (module); builder.AddProducts (products); UnityPurchasing.Initialize (this, builder); }製品定義
- 先のコンストラクタが受け取って
builder
に登録した製品定義は、製品のIDとタイプのセットです。Sample.csvar products = new [] { new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item1", ProductType.Consumable), new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item2", ProductType.NonConsumable), new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item3", ProductType.NonConsumable), };
- これらはストア・ダッシュボードでの設定と正しく呼応している必要があります。
- Apple App Storeでは、IDと製品タイプの双方が設定されます。
- Google Play Storeでは、IDが設定されますが、消費の有無についての設定はありません。
- 消費タイプでは、アプリを消費したことを申告するだけです。
- この記事では、消費タイプ
Consumable
と非消費タイプNonConsumable
だけを扱い、購読タイプは扱いません。初期化の完了
- 初期化に成功したら、得られた
IStoreController
とIExtensionProvider
を保存します。Purchaser.cs/// <summary>初期化完了</summary> public void OnInitialized (IStoreController controller, IExtensionProvider extensions) { Debug.Log ($"Purchaser.Initialized {controller}, {extensions}"); appleExtensions = extensions.GetExtension<IAppleExtensions> (); appleExtensions.RegisterPurchaseDeferredListener (OnDeferred); googlePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions> (); this.controller = controller; this.extensions = extensions; Inventory = new Inventory { }; foreach (var product in controller.products.all) { Inventory [product] = possession (product); } } /// <summary>初期化失敗</summary> public void OnInitializeFailed (InitializationFailureReason error) { Debug.LogError ($"Purchaser.InitializeFailed {error}"); Unavailable = true; }
- iOSの'Ask to buy'関連、
OnDeferred
はテストできていません。Inventory
については、後述します。製品目録
- 初期化に成功すると、
controller.products.all
で、製品目録を得ることができます。Sample.csforeach (var product in Purchaser.Products.all) { Debug.Log (Purchaser.GetProductProperties (product)); }Purchaser.cs/// <summary>製品目録 初期化時の製品IDに対してストアから得た情報</summary> public static ProductCollection Products => Valid ? instance.controller.products : null;Purchaser.cs/// <summary>製品諸元</summary> public static string GetProperties (this Product product) { return string.Join ("\n", new [] { $"id={product.definition.id} ({product.definition.storeSpecificId})", $"type={product.definition.type}", $"enabled={product.definition.enabled}", $"available={product.availableToPurchase}", $"localizedTitle={product.metadata.localizedTitle}({product.metadata.shortTitle ()})", $"localizedDescription={product.metadata.localizedDescription}", $"isoCurrencyCode={product.metadata.isoCurrencyCode}", $"localizedPrice={product.metadata.localizedPrice}", $"localizedPriceString={product.metadata.localizedPriceString}", $"transactionID={product.transactionID}", $"Receipt has={product.hasReceipt}", $"Purchaser.Valid={Purchaser.Valid}", $"Receipt validation={Purchaser.ValidateReceipt (product)}", $"Possession={Purchaser.Inventory [product]}", }); }目録の謎
※以下は、Google Play StoreとApple App Store (Sandbox)で確認した内容です。製品でのテストではありません。
- もし、初期化の際に製品定義を渡さなかったらどうなるのでしょうか?
- その場合、製品目録は基本的に空になります。ただし、購入済みの非消費製品は取得されます。
- ストアから製品IDのカタログが得られるわけではありません。つまり、ストアに新製品を登録しただけでは、製品に組み込めないのです。
ProductDefinition.enabled
は、スクリプトリファレンスでは"This flag indicates whether a product should be offered for sale. It is controlled through the cloud catalog dashboard."と説明されています。
- これを見る限り、ストアのダッシュボードで設定されている有効/無効状態を取得できるように読めますが、実際には常に
true
になります。- 例え、ストアに登録されていないIDを指定した場合でも
true
です。全く役に立ちません。
- ストアにない場合は、
Product.availableToPurchase
はFalse
になります。- Play Storeで無効にされている製品を購入しようとすると「原因不明の購入エラー」になります。
- App Storeで無効にされている製品でも、Sandboxでは購入できてしまいます。
- つまり、以下の制約が生じます。
- ストアに登録されている未知の製品を取得することはできません。
- ストアでの状態(有効/無効)を取得することはできません。
- 購入の失敗が、ストアでの無効設定によるものと判別できません。
- その結果、以下のような使い方になります。
- ストアとは別の手段(あらかじめ組み込む、自前のサーバから取得するなど)で製品定義を保持する必要があります。
- ストアでの製品の有効/無効は、アプリの使用する製品定義に連動させます。
- 緊急時以外は、ストア独自に製品を無効化しないようにします。
購入
購入の開始
IStoreController.InitiatePurchase ()
にProduct
を渡すことで、購入が開始されます。Purchaser.cs/// <summary>課金開始</summary> private bool purchase (Product product) { if (product != null && product.Valid ()) { Debug.Log ($"Purchaser.InitiatePurchase {product.definition.id} {product.metadata.localizedTitle} {product.metadata.localizedPriceString}"); controller.InitiatePurchase (product); return true; } return false; }
- 購入の要求はブロックされず、後に、結果に応じたコールバックがあります。
購入の完了
- 課金結果のコールバックでは、購入に関わる処理が全て完了したら、
PurchaseProcessingResult.Complete
を返します。
- 消費タイプの場合は、消費が実行されます。
Purchaser.cs/// <summary>課金失敗</summary> public void OnPurchaseFailed (Product product, PurchaseFailureReason reason) { Debug.LogError ($"Purchaser.PurchaseFailed Reason={reason}\n{product.GetProperties ()}"); } /// <summary>課金結果 有効な消耗品なら保留、それ以外は完了とする</summary> public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs eventArgs) { var validated = ValidateReceipt (eventArgs.purchasedProduct); Debug.Log ($"Purchaser.ProcessPurchase {(validated ? "Validated" : "ValidationError")} {eventArgs.purchasedProduct.GetProperties ()}"); Inventory [eventArgs.purchasedProduct] = validated; return (validated && eventArgs.purchasedProduct.definition.type == ProductType.Consumable) ? PurchaseProcessingResult.Pending : PurchaseProcessingResult.Complete; }
- このコードでは、消費タイプでは
PurchaseProcessingResult.Pending
を返し、それ以外ではComplete
を返します。
Pending
を返すと、消費は保留されます。この保留状態は、(謎の)クラウドで保持されるためアプリが中断しても失われず、起動毎に
ProcessPurchase ()
へのコールバックが繰り返されます。
- 保留状態を終わらせるには、
ProcessPurchase ()
でComplete
を返すか、別途IStoreController.ConfirmPendingPurchase (product)
を呼びます。
Product.hasReceipt
は、起動直後に未購入または消費済みであればfalse
となり、購入完了時にはtrue
に変化します。
- しかし、
Complete
を返した場合も、消費を促すConfirmPendingPurchase (product)
を行おうとも、その場ではfalse
には戻りません。- つまり、
hasReceipt
を見て消費完了を知ることはできません。- また、
ConfirmPendingPurchase (product)
には、結果を知らせるコールバックがありません。従って、保留と消費の状態を判別するためには、Unity-IAPの外側で所持状態を管理する必要があります。
なお、非消費タイプでは、購入済みの
hasReceipt
は常にtrue
になります。所有状態の管理
- このコードでは、
Inventory
というDictionary
派生クラスを用意して、製品所有状態を管理しています。
- 初期化完了のコールバック中で初期化しています。
Inventory [string 製品ID]
またはInventory [Product 製品]
で真偽値を得ることができます。Purchaser.cs/// <summary>productID基準でProductの在庫を表現する辞書</summary> public class Inventory : Dictionary<string, bool> { /// <summary>Productによるアクセス</summary> public bool this [Product product] { get { return base [product.definition.id]; } set { base [product.definition.id] = value; } } }復元
- Appleの既定では、ユーザーがこの処理を明示的に行える必要があるのですが、これを呼ばなくてもUnity-IAPが自動的に復元をしているようなので、それ以上の意味はないように思われます。正直よく分かりません。
Purchaser.cs/// <summary>復元</summary> private void restore (Action<bool> onRestored = null) { Debug.Log ("Purchaser.Restore"); Action<bool> onTransactionsRestored = success => { OnTransactionsRestored (success); onRestored?.Invoke (success); }; if (isGooglePlayStoreSelected) { googlePlayStoreExtensions.RestoreTransactions (onTransactionsRestored); } else if (isAppleAppStoreSelected) { appleExtensions.RestoreTransactions (onTransactionsRestored); } }Purchaser.cs/// <summary>復元完了</summary> private void OnTransactionsRestored (bool success) { Debug.Log ($"Purchaser.Restored {success}"); }コード全容
Purchaser.cs// Copyright© tetr4lab. using System; using System.Text.RegularExpressions; using System.Collections.Generic; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.Purchasing.Security; /// <summary>UnityIAPを使う</summary> namespace UnityInAppPuchaser { /// <summary>課金処理</summary> public class Purchaser : IStoreListener { #region Static /// <summary>シングルトン</summary> private static Purchaser instance; /// <summary>在庫目録 製品の課金状況一覧、消費タイプは未消費を表す</summary> public static Inventory Inventory { get; private set; } /// <summary>有効 初期化が完了している</summary> public static bool Valid => (instance != null && instance.valid); /// <summary>使用不能 初期化に失敗した</summary> public static bool Unavailable { get; private set; } /// <summary>製品目録 初期化時の製品IDに対してストアから得た情報</summary> public static ProductCollection Products => Valid ? instance.controller.products : null; /// <summary>クラス初期化 製品のIDとタイプの一覧を渡す</summary> public static void Init (IEnumerable<ProductDefinition> products) { if (instance == null || Unavailable) { instance = new Purchaser (products); } } /// <summary>所有検証 有効なレシートが存在する</summary> private static bool possession (Product product) { return product.hasReceipt && Purchaser.ValidateReceipt (product); } /// <summary>レシート検証</summary> public static bool ValidateReceipt (string productID) { return (!string.IsNullOrEmpty (productID) && instance.validateReceipt (instance.controller.products.WithID (productID))); } /// <summary>レシート検証</summary> public static bool ValidateReceipt (Product product) { return (instance != null && instance.validateReceipt (product)); } /// <summary>課金 指定製品の課金処理を開始する</summary> public static bool Purchase (string productID) { if (!string.IsNullOrEmpty (productID) && Valid) { return instance.purchase (instance.controller.products.WithID (productID)); } return false; } /// <summary>課金 指定製品の課金処理を開始する</summary> public static bool Purchase (Product product) { if (product != null && Valid) { return instance.purchase (product); } return false; } /// <summary>保留した課金の完了 消費タイプの指定製品の保留していた消費を完了する</summary> public static bool ConfirmPendingPurchase (string productID) { if (!string.IsNullOrEmpty (productID) && Valid) { return instance.confirmPendingPurchase (instance.controller.products.WithID (productID)); } return false; } /// <summary>保留した課金の完了 消費タイプの指定製品の保留していた消費を完了する</summary> public static bool ConfirmPendingPurchase (Product product) { if (product != null && Valid) { return instance.confirmPendingPurchase (product); } return false; } /// <summary>復元 課金情報の復元を行い、結果のコールバックを得ることができる</summary> public static void Restore (Action<bool> onRestored = null) { if (Valid) { instance.restore (onRestored); } } #endregion /// <summary>コントローラー</summary> private IStoreController controller; /// <summary>拡張プロバイダ</summary> private IExtensionProvider extensions; /// <summary>Apple拡張</summary> private IAppleExtensions appleExtensions; /// <summary>Google拡張</summary> private IGooglePlayStoreExtensions googlePlayStoreExtensions; /// <summary>AppleAppStore</summary> private bool isAppleAppStoreSelected; /// <summary>GooglePlayStore</summary> private bool isGooglePlayStoreSelected; /// <summary>検証機構</summary> private CrossPlatformValidator validator; /// <summary>有効</summary> private bool valid => (controller != null && controller.products != null); /// <summary>コンストラクタ</summary> private Purchaser (IEnumerable<ProductDefinition> products) { Debug.Log ("Purchaser.Construct"); var module = StandardPurchasingModule.Instance (); module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser; isGooglePlayStoreSelected = Application.platform == RuntimePlatform.Android && module.appStore == AppStore.GooglePlay; isAppleAppStoreSelected = Application.platform == RuntimePlatform.IPhonePlayer && module.appStore == AppStore.AppleAppStore; validator = new CrossPlatformValidator (GooglePlayTangle.Data (), AppleTangle.Data (), Application.identifier); var builder = ConfigurationBuilder.Instance (module); builder.AddProducts (products); UnityPurchasing.Initialize (this, builder); } /// <summary>レシート検証</summary> private bool validateReceipt (Product product) { if (!valid || !product.hasReceipt) { return false; } #if UNITY_EDITOR return true; #else try { var result = validator.Validate (product.receipt); Debug.Log ("Purchaser.validateReceipt Receipt is valid. Contents:"); return true; } catch (IAPSecurityException ex) { Debug.LogError ($"Purchaser.validateReceipt Invalid receipt {product.definition.id}, not unlocking content. {ex}"); return false; } #endif } /// <summary>課金開始</summary> private bool purchase (Product product) { if (product != null && product.Valid ()) { Debug.Log ($"Purchaser.InitiatePurchase {product.definition.id} {product.metadata.localizedTitle} {product.metadata.localizedPriceString}"); controller.InitiatePurchase (product); return true; } return false; } /// <summary>保留した課金の完了</summary> private bool confirmPendingPurchase (Product product) { if (product != null && Inventory [product] && possession (product)) { controller.ConfirmPendingPurchase (product); Inventory [product] = false; Debug.Log ($"Purchaser.ConfirmPendingPurchase {product.GetProperties ()}"); return true; } return false; } /// <summary>復元</summary> private void restore (Action<bool> onRestored = null) { Debug.Log ("Purchaser.Restore"); Action<bool> onTransactionsRestored = success => { OnTransactionsRestored (success); onRestored?.Invoke (success); }; if (isGooglePlayStoreSelected) { googlePlayStoreExtensions.RestoreTransactions (onTransactionsRestored); } else if (isAppleAppStoreSelected) { appleExtensions.RestoreTransactions (onTransactionsRestored); } } #region Event Handler /// <summary>復元完了</summary> private void OnTransactionsRestored (bool success) { Debug.Log ($"Purchaser.Restored {success}"); } /// <summary>iOS 'Ask to buy' 未成年者の「承認と購入のリクエスト」 承認または却下されると通常の購入イベントが発生する</summary> private void OnDeferred (Product product) { Debug.Log ($"Purchaser.Deferred {product.GetProperties ()}"); } /// <summary>初期化完了</summary> public void OnInitialized (IStoreController controller, IExtensionProvider extensions) { Debug.Log ($"Purchaser.Initialized {controller}, {extensions}"); appleExtensions = extensions.GetExtension<IAppleExtensions> (); appleExtensions.RegisterPurchaseDeferredListener (OnDeferred); googlePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions> (); this.controller = controller; this.extensions = extensions; Inventory = new Inventory { }; foreach (var product in controller.products.all) { Inventory [product] = possession (product); } } /// <summary>初期化失敗</summary> public void OnInitializeFailed (InitializationFailureReason error) { Debug.LogError ($"Purchaser.InitializeFailed {error}"); Unavailable = true; } /// <summary>課金失敗</summary> public void OnPurchaseFailed (Product product, PurchaseFailureReason reason) { Debug.LogError ($"Purchaser.PurchaseFailed Reason={reason}\n{product.GetProperties ()}"); } /// <summary>課金結果 有効な消耗品なら保留、それ以外は完了とする</summary> public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs eventArgs) { var validated = ValidateReceipt (eventArgs.purchasedProduct); Debug.Log ($"Purchaser.ProcessPurchase {(validated ? "Validated" : "ValidationError")} {eventArgs.purchasedProduct.GetProperties ()}"); Inventory [eventArgs.purchasedProduct] = validated; return (validated && eventArgs.purchasedProduct.definition.type == ProductType.Consumable) ? PurchaseProcessingResult.Pending : PurchaseProcessingResult.Complete; } /// <summary>破棄</summary> ~Purchaser () { Debug.Log ("Purchaser.Destruct"); if (instance == this) { instance = null; Inventory = null; Unavailable = false; } } #endregion } /// <summary>製品拡張</summary> public static class ProductExtentions { /// <summary>製品諸元</summary> public static string GetProperties (this Product product) { return string.Join ("\n", new [] { $"id={product.definition.id} ({product.definition.storeSpecificId})", $"type={product.definition.type}", $"enabled={product.definition.enabled}", $"available={product.availableToPurchase}", $"localizedTitle={product.metadata.localizedTitle}({product.metadata.shortTitle ()})", $"localizedDescription={product.metadata.localizedDescription}", $"isoCurrencyCode={product.metadata.isoCurrencyCode}", $"localizedPrice={product.metadata.localizedPrice}", $"localizedPriceString={product.metadata.localizedPriceString}", $"transactionID={product.transactionID}", $"Receipt has={product.hasReceipt}", $"Purchaser.Valid={Purchaser.Valid}", $"Receipt validation={Purchaser.ValidateReceipt (product)}", $"Possession={Purchaser.Inventory [product]}", }); } /// <summary>有効性 製品がストアに登録されていることを示すが、ストアで有効かどうかには拠らない</summary> public static bool Valid (this Product product) { return (product.definition.enabled && product.availableToPurchase); } /// <summary>アプリ名を含まないタイトル</summary> public static string shortTitle (this ProductMetadata metadata) { return (metadata != null && !string.IsNullOrEmpty (metadata.localizedTitle)) ? (new Regex (@"\s*\(.+\)$")).Replace (metadata.localizedTitle, "") : string.Empty; } } /// <summary>productID基準でProductの在庫を表現する辞書</summary> public class Inventory : Dictionary<string, bool> { /// <summary>Productによるアクセス</summary> public bool this [Product product] { get { return base [product.definition.id]; } set { base [product.definition.id] = value; } } } }Sample.csusing System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UnityEngine.Purchasing; using UnityInAppPuchaser; public class Sample : MonoBehaviour { [SerializeField] private Transform CatalogHolder = default; [SerializeField] private Button RestoreButton = default; /// <summary>製品目録</summary> private readonly ProductDefinition [] products = new [] { new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item1", ProductType.Consumable), new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item2", ProductType.NonConsumable), new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item3", ProductType.NonConsumable), }; /// <summary>起動</summary> private void Start () { StartCoroutine (initPurchaser ()); } /// <summary>開始処理</summary> private IEnumerator initPurchaser () { RestoreButton.interactable = false; Purchaser.Init (products); yield return new WaitUntil (() => Purchaser.Valid || Purchaser.Unavailable); // 初期化完了を待つ if (Purchaser.Valid) { Catalog.Create (CatalogHolder); foreach (var product in Purchaser.Products.all) { CatalogItem.Create (Catalog.ScrollRect.content, product); } } RestoreButton.interactable = true; } /// <summary>復元ボタン</summary> public void OnPushRestoreButton () { if (Purchaser.Unavailable) { StartCoroutine (initPurchaser ()); } else if (Purchaser.Valid) { Purchaser.Restore (success => { if (!success) { ModalDialog.Create (transform.parent, "リストアに失敗しました。\nネットワーク接続を確認してください。"); } }); } } }
- 投稿日:2019-08-21T08:22:39+09:00