- 投稿日:2020-03-22T21:15:09+09:00
【Android】Fragment間で値をやりとりする
はじめに
あるFragmentで取得した値を、他のFragmentでも使いたい場面は多々あるかと思います。
この記事では、Fragment間で値をやりとりする方法をまとめました。方法一覧
Fragment間で値をやりとりする方法は、ざっくり分けて次のとおりです。
- Bundleを使う
- 【応用】SafeArgsを使う
- ActivityのスコープでViewModelを使う
- navGraphViewModelsを使う
抜けがあったら教えてください。
次に、それぞれのやり方について解説していきます。
※番外編としてViewPagerやBottomNavigationのページ間で値をやりとりする方法についても解説します。Bundleを使う
一番オーソドックスなやり方です。遷移先のFragmentを生成する際に値をargumentsとして渡します。
やり方
以下のようにして遷移先のFragmentに値を渡します。
val title = "タイトル" // Bundleインスタンスを作成 val bundle = Bundle() // putXXXXで値をセットする bundle.putString("BUNDLE_KEY_TITLE", title) // Fragmentに値をセットする val fragment = SecondFragment() fragment.arguments = bundle // 遷移処理 parentFragmentManager.beginTransaction() .add(R.id.container, fragment) .commit()値を受け取る側はgetArguments(arguments)で値を取得します。
SecondFragment// putXXXXに対応するgetXXXXで値を取得 val args = arguments?.getString("BUNDLE_KEY_TITLE") // "タイトル"こういう時に使える
Bundleを使った方法は、単方向の値のやりとりに向いています。
Bundleで渡した値は読み取り専用のため、受け取った値を書き換えたり、加工した値をまたFragmentAで使いたい場合には向いていません。
(varで値を取得して書き換えてまたBundleに渡して…というやり方も出来なくはないですが、煩雑になるのでオススメは出来ません。おとなしくViewModelを使いましょう)【応用】SafeArgsを使う
Bundleで渡した値を型安全に使えるSafeArgsというものが登場しました。
bundle.putXXXX
やarguments.getXXXX
で値をやりとりするのは便利ではありますが、渡すキー名を間違えるとNULL
が返ってくるため、簡単にクラッシュしてしまいます。
それをプロパティを介したアクセスにする事で、型安全にアクセス可能になります。やり方
Gradleに依存関係は追加済みとします。
SafeArgsはNavigation ComponentのNavigation Graphを使用します。
Navigation Graphにargument
タグを追加します。これは遷移先のFragmentタグ内に追加します。nav_graph.xml<fragment android:id="@+id/firstFragment" ...> <action android:id="@+id/action_first_to_second" app:destination="@id/secondFragment"/> </fragment> <fragment android:id="@+id/secondFragment" ...> <argument android:name="title" app:argType="string"/> </fragment>
argument
タグ追加後は一度ビルドしておきましょう。SecondFragment遷移時に以下のように引数を渡します。
FirstFragmentval title = "タイトル" val action = FirstFragmentDirections.actionFirstToSecond(title) findNavController().navigate(action)遷移後のFragmentで値を取り出すには
navArgs()
を使います。SecondFragmentclass SecondFragment : Fragment() { private val args: SecondFragmentArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { textView.text = args.title // プロパティでアクセス可能 } }Navigation Componentを使った遷移を使わない場合でも、SafeArgsだけを使用することが可能です。
Navigation Graphへの定義までは同じで、そのあとは以下のように使えます。FirstFragmentval bundle = SecondFragmentArgs.Builder(title) .build() .toBunble() val fragment = SecondFragment() fragment.argument = bundle // 遷移処理はBundleと同じなので省略値の取り出し方はNavigation Componentを使った場合と同じです。
SecondFragmentprivate val args: SecondFragmentArgs by navArgs()ActivityのスコープでViewModelを使う
読んで字のとおりですが、ActivityのスコープでViewModelを定義し、それをFragment間で使いまわします。公式でも紹介されているやり方です。
やり方
例えばMainViewModelがあったとします。
MainViewModelclass MainViewModel: ViewModel()これをFragmentで取得する際、Activityを引数に渡します。
FirstFragmentclass FirstFragment: Fragment() { lateinit var viewModel: MainViewModel override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { activity?.run { viewModel = ViewModelProvider(this).get(MainViewModel::class.java) } } }これでViewModelがActivityのViewModelStore(※)に保持されるので、他のFragmentでも同じViewModelのインスタンスを利用できます。
(※)ViewModelStore:ViewModelを保持するクラス
もしくはFragment-KTXを利用すれば、
activityViewModels
でもViewModelのスコープをActivityにできます。FirstFragmentprivate val viewModel: MainViewModel by activityViewModels()(独自のViewModelStoreOwnerを定義して利用する方法もあるようですが、ここでは割愛します。こちらの記事が参考になりそうです)
こういう時に使える
ViewModelを使った方法は、双方向の値のやりとりに向いています。
FragmentAでもFragmentBでも値を参照し、かつ、どちらのFragmentでも値の変更が行われる可能性がある場合などは、BundleよりもViewModelを使った方が処理がスマートになると思います。
navGraphViewModelsを使う
Navigation ComponentとFragment-KTXを組み合わせることによって、Navigation Graph単位のスコープを持ったViewModelを使うことが出来ます。
(ネストしたNavGraphでしか使えず、メインのNavGraphでは使えない事に注意が必要です)やり方
NavigationGraphの中に、以下のようなネストしたNavGraphとFragmentを定義します。
(説明のために色々端折っています)nav_graph.xml<navigation android:id="@+id/nested_nav_graph" app:startDestination="@id/secondFragment"> <fragment android:id="@+id/secondFragment"> ... <fragment android:id="@+id/thirdFragment"> </navigation>Fragment上で以下のようにViewModelを取得します。
SecondFragmentclass SecondFragment: Fragment() { private val viewModel: MainViewModel by navGraphViewModels<R.id.nested_nav_graph>() }SecondFragmentclass ThirdFragment: Fragment() { private val viewModel: MainViewModel by navGraphViewModels<R.id.nested_nav_graph>() }これでSecondFragmentとThirdFragmentは共通のViewModelインスタンスを使えるようになります。
このViewModelはNavGraph単位のスコープを持つので、別のNavGraphに遷移した場合は値が破棄されます。もっと詳しい情報はSTAR-ZEROさんのこちらの記事が参考になります。
こういう時に使える
ActivityスコープのViewModelは、SingleActivityのアプリだと、実質どこのFragmentからでもアクセス出来てしまいます。
それではスコープが大きすぎる、という時はnavGraphViewModelsを使ってスコープ範囲を分割していくと良いかと思います。番外編: ViewPagerやBottomNavigationViewの子ページ同士で値をやりとりする
ViewPagerやBottomNavigationViewを使っている時、ページ間で値のやりとりをしたい場合は、BundleやnavGraphViewModelsは使えません(たぶん)。
方法はいくつかあるかと思いますが、個人的にはViewModelProviderに
parentFragment
を渡して、ViewModelを共有するのが一番やりやすいと思います。ChildFragmentlateinit var viewModel: MainViewModel override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // parentFragmentを渡す viewModel = ViewModelProvider(parentFragment).get(MainViewModel::class.java) }こうすることでViewModelのインスタンスが親FragmentのViewModelStoreに保持されるため、ページ間で共通のViewModelインスタンスを利用することが可能になります。
おわりに
本記事を書くにあたって、以下のリンク先を参考にさせて頂きました。
・[Qiita] 【Kotlin】Bundleを使ったFragment間の値渡し
・[Qiita] [Android] NavigationでSafeArgsを使って引数付き画面遷移をする
・ViewModel、ViewModelProviderについて調べてみた(Android)
・[Qiita] Activity, Fragmentを跨いでViewModelを共有する
・Navigation GraphスコープのViewModel
- 投稿日:2020-03-22T21:04:23+09:00
Build Valiant毎に使用するkeystoreを変更する
Flavor毎にkeystoreを変更する方法は見かけるのですが、Flavor と Build Type毎にkeystoreを分ける方法がなかなか見つかりませんでした。
以下のようにproductFlavors.{flavor名}.signingConfigsの中にdebug, releaseを書けば、flavor と build typeそれぞれで使用するkeystoreを変更することができます。productFlavors { hoge { dimension = 'market' signingConfigs { debug { ... } release { ... } } } }参考
https://stackoverflow.com/questions/30898611/gradle-signing-flavors-with-different-keys-on-android
- 投稿日:2020-03-22T20:47:41+09:00
Flutterでflutter_app_badgerを使い、アプリのホームアイコンにバッジを表示する
Flutterでアプリ開発をしている村松龍之介と申します。
(仕事ではiOSアプリのネイティブアプリ開発を行っています)今回は、FlutterアプリにiPhoneやiPadではお馴染みのアイコンバッジを付けるためにパッケージを導入しましたので備忘録です。
flutter_app_badgerパッケージを導入する
簡単にアイコンバッジを付けられるパッケージがありますので、導入します。
flutter_app_badger | Flutter Packagepubspec.yamlを編集
dependencies: # 〜〜省略〜〜 flutter_app_badger: ^1.1.2 # 導入時点で最新のバージョンを指定でOKだと思います。iOS
iOSでバッジを表示するためには権限が必要なので以下を
info.plist
に追記します。Android StudioやVS Codeで
info.plist
を開いて編集する場合<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <!-- ここから下を追加 --> <key>UIBackgroundModes</key> <array> <string>remote-notification</string> </array> <!-- ここまで --> </dict> </plist>Xcodeで
info.plist
を(Property Listとして)開いて編集する場合
Required background modes
を追加して、その一要素としてApp downloads content in response to push notifications
を追加します。Android
On Android, no official API exists to show a badge in the launcher. But some devices (Samsung, HTC...) support the feature. Thanks to the Shortcut Badger library , ~ 16 launchers are supported.
(翻訳)Androidには、ランチャーにバッジを表示する公式のAPIはありません。ただし、一部のデバイス(Samsung、HTC ...)はこの機能をサポートしています。 Shortcut Badgerライブラリの おかげで、 最大 16個のランチャーがサポートされます。Androidの場合は、対応する機種が限られますが、特にやらなきゃいけないこともないようです。
ちなみにPixel 3 Xlではバッジは表示されませんでした。コード実装
バッジの表示・数字の更新
実装は思いのほか簡単です。
intを引数にして以下の関数を呼び出すことでバッジを表示できます。FlutterAppBadger.updateBadgeCount(1);上記の例では「1」を渡しているので、ホーム画面のアイコンに❶というようにバッジが表示されます。
doubleの場合はintに変換してから引数に渡しましょう。
0以下の数値を渡すことでバッジが表示されないことを確認しました。
マイナスの数値を渡してもバッジが表示されません。バッジをサポートしている環境か調べる
Androidでは一部のデバイスしか対応していないこともあり、あらまじめバッジ機能をサポートしているか調べた方が良いですね。
FlutterAppBadger.isAppBadgeSupported();上記の関数を実行することで、非同期で真偽値(bool)が返却されます。
上記の実装をメソッドにするなら以下のような感じでしょうか?
/// 1以上の数値を渡すことでホーム画面のアイコンにバッジを表示する void setIconBadge({int number}) async { // バッジ表示機能に対応している場合のみ、バッジの数字を更新する if (await FlutterAppBadger.isAppBadgeSupported()) { FlutterAppBadger.updateBadgeCount(number ?? 0); // <-引数の`number`が`null`だった場合は`0` } }iOSの場合、許可を得る必要がありますが、バッジの初回更新時に表示してくれます。
バッジを非表示にしたいとき
0以下の数値で更新することもできますが、意図的にバッジを非表示にしたいときは以下の関数でバッジが消した方が良さそうです。
FlutterAppBadger.removeBadge();呼び出す場所
基本的に好きな場所で呼び出して良いかと思います。
僕の場合は、アプリのメイン画面で有給休暇の残日数を計算しており、バッジでその残日数を表示させたかったので、計算結果を引数に入れて使用しました。蛇足
ご覧いただきありがとうございました!
蛇足ですが、Flutterアプリをリリースできたので良かったらインストールしてみてもらえると嬉しいです?♂️
iOS: 「レストル-有給休暇管理」をApp Storeで
Android: レストル-有給休暇管理 - Google Play のアプリ
- 投稿日:2020-03-22T18:01:02+09:00
ExpoでAPKをビルド後にAndroidManifest.xmlをいじってみる
やりたいこと
ExpoのデフォルトのAndroidManifest.xmlではapplicationの
android:allowBackup
がtrue
で、つまり自動バックアップが有効になっています。
訳あってこれをfalse
にしたい。
しかしExpoのManaged WorkflowではiOSのinfoPlistのようにはAndroidManifest.xmlの詳細項目を直接設定することはできないようで、Feature requestsとしてもいくつか挙がっています。
https://expo.canny.io/feature-requests?search=allowbackupそのため、ビルド後のAPKファイルを展開してAndroidManifest.xmlを編集、再ビルドという手順で無理矢理いじってみることにしました。Javaのソースコードはともかく、マニフェストファイルくらいは簡単に書き換えられるだろうということで。
あまり聞こえのいい感じはしませんが、とりあえずできるかどうか試してみたという感じです。まずは
allowBackup="true"
の挙動を確認こんな感じで入力したテキストを保存するような画面を簡単に作ってみます。
バックアップはAsyncStorageで保存したデータなども含むはず。App.jsimport React, { useState, useEffect } from 'react'; import { StyleSheet, View, AsyncStorage, Button, TextInput } from 'react-native'; export default function App() { const [text, setText] = useState(''); useEffect(() => { AsyncStorage.getItem('text').then(setText); }, []); const saveText = () => { AsyncStorage.setItem('text', text); }; return ( <View style={styles.container}> <TextInput style={styles.input} value={text} onChangeText={setText} /> <Button title="Save" onPress={saveText} /> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, input: { backgroundColor: 'lightgray', width: 200, height: 40, marginBottom: 16 } });
app.json
にパッケージ名を設定したうえでAndroid向けにビルドし、Android端末で確認してみます。
何かしら入力したあと「SAVE」ボタンを押し、AsyncStorageに保存させます。再度起動しても同じ文字列が表示されるようになるはずです。この状態で、adbを使ってバックアップをしてみます。
(この辺りのドキュメントや記事を参考にしています。)$ adb shell setprop log.tag.BackupXmlParserLogging VERBOSE $ adb shell bmgr run $ adb shell bmgr backupnow <パッケージ名>バックアップができたら、先ほど入力した文字列を消して「SAVE」し、
再度起動しても入力した文字列が消えているのを確認します。ここで先ほどバックアップした状態を復元してみます。
バックアップを特定するためのトークンを確認するコマンドを叩きましょう。$ adb shell dumpsys backupずらっと出てきた中に、
Current: ~~
とあるのがバックアップトークンです。よほど時間が経っていない限りこれが先ほど行ったバックアップのものなので、$ adb shell bmgr restore <バックアップトークン> <パッケージ名>こんな感じで叩けば復元が完了するはずです。
再度アプリを開いてみると、文字列が復活しました。
バックアップした時のAsyncStorageのデータが復元されているのがわかります。これで
allowBackup="true"
が正常に効いているのがわかりました。ビルド後のAPKをいじってみる
ビルド後のAPKを展開し、
allowBackup="false"
に修正してみます。
APKの展開には、apktoolを使用しますが、Expoの開発フローとある程度調和させたいなとか、インストールが面倒だなということで、Node.jsでシェルスクリプトっぽいのを書いてみました。
.jarのライブラリファイルをそのまま含むnpmパッケージがあるので、これを使います。パッケージのインストール$ yarn add --dev child_process apktool-jar replaceExpoでビルドしたAPKファイルを、ディレクトリを切って
dist/app.apk
としてプロジェクトディレクトリに移動するような感じを想定しています。editAndroidManifestXML.jsconst childProcess = require('child_process'); const apktool = require('apktool-jar'); const replace = require('replace'); const directory = 'dist/'; // APKを入れるディレクトリ const srcApkName = 'app.apk'; // 元のAPKファイル名 const distDirectory = 'dist/app/'; // 展開するディレクトリ const revisedApkName = 'app-revised.apk'; // 再ビルドしたAPKファイル名 const distApkName = 'app-release.apk'; // 最適化・署名したAPKファイル名 const keystorePath = '/Users/.../.android/test.keystore'; // keystoreのパス const keyAlias = 'TEST'; // keystoreのエイリアス const keyPass = 'password'; // keystoreのパスワード /** * spawnをpromise化 * @param {string} command * @param {Array<string>} args * @returns {Promise<any>} */ const spawn = (command, args) => ( new Promise((resolve)=>{ const process = childProcess.spawn(command, args); process.stdout.on('data', (data) => console.log(`${data}`)); process.stderr.on('data', (data) => console.log(`${data}`)); process.on('exit', resolve); }) ); (async () => { // apktoolでAPKを展開 await spawn('java', ['-jar', apktool.path, 'd', `${directory}${srcApkName}`, '-o', distDirectory, '-f']); // 展開されたAndroidManifest.xmlをいじる // allowBackupをfalseに変更 replace({ regex: 'android:allowBackup="true"', replacement: 'android:allowBackup="false"', paths: [`${distDirectory}AndroidManifest.xml`], silent: true }); console.log('Replaced: allowBackup="true" ==> allowBackup="false"'); // apktoolでAPKを再ビルド await spawn('java', ['-jar', apktool.path, 'b', distDirectory, '-o', `${directory}${revisedApkName}`, '-f']); // zipalignでAPKを最適化 await spawn('zipalign', ['-f', '-v', '4', `${directory}${revisedApkName}`, `${directory}${distApkName}`]); // apksignerでAPKに署名 await spawn('apksigner', ['sign', '--ks', keystorePath, '-v', '--v2-signing-enabled', 'true', '--ks-key-alias', keyAlias, '--ks-pass', `pass:${keyPass}`, `${directory}${distApkName}`]); // 署名を確認 await spawn('apksigner', ['verify', '--print-certs', '-v', `${directory}${distApkName}`]); })();Expoでは署名も勝手にやってくれますが、再ビルドした後はkeystoreを用意し署名したりを自分でやらないといけません。
すでにあるものや、作成するなどして任意のkeystoreとエイリアスを使うようにコードは修正してください。
スクリプトの処理は
- apktoolでAPKを展開
- 展開されたファイルを編集
- apktoolでAPKを再ビルド
- zipalignでAPKを最適化
- apksignerでAPKに署名
という流れになっています。
$ node editAndroidManifestXML.jsで実行すると、
dist/app
ディレクトリにAPKが展開され、その中のAndroidManifest.xmlが書き換わります。
allowBackup="false"
の挙動を確認全て問題なく処理が終わり、
dist/app-release.apk
が生成されたら、これを確認してみます。
先ほどAndroid端末にインストールした元のアプリをアンインストールし、修正後のアプリをインストールします。その後、バックアップを実行してみると、
$ adb shell bmgr backupnow <パッケージ名> Running incremental backup for 1 requested packages. Package <パッケージ名> with result: Backup is not allowed Backup finished with result: Success
Backup is not allowed
と表示され、allowBackup="false"
の設定変更が効いているのが確認できました。
- 投稿日:2020-03-22T17:20:57+09:00
AsyncTaskが実行されない不具合の解析
Androidのアプリ開発でAsyncTaskが実行されない不具合があり、解析した際の解析方法をメモします。
AsyncTaskが実行されない不具合
原因は、AsyncTask(executeOnExecutor(THREAD_POOL_EXECUTOR))で実行した、タスクの処理が想定していたより多くの時間がかかったことで、poolのthreadが全て実行状態となり、新しいタスクが実行されていませんでした。
解析方法
再現性の低い不具合だったため、発生時のBugreportで解析を行いました。
※やはり、不具合発生時はバグレポートを取っておいた方が良いですね。下記は、不具合が発生したコードを擬似的に再現したものです。
AsyncTaskのexecuteOnExecutor(THREAD_POOL_EXECUTOR)でタスクを実行していますが、sleepを入れて処理に時間がかかるようにしています。Sample.ktLog.d(TAG, "CPU_COUNT : " + Runtime.getRuntime().availableProcessors()) for (i in 1..3) { val task = object : AsyncTask<Void, Void, Void>() { override fun doInBackground(vararg params: Void): Void? { Log.d(TAG, ("doInBackground start " + i)) Thread.sleep(100000) Log.d(TAG, "doInBackground end " + i) return null } } task.executeOnExecutor(THREAD_POOL_EXECUTOR) }下記は、上記のコードを実行した際のlogcatです。
2020-03-20 19:05:08.828 10486-10486/com.ykato.sample D/MainActivity: CPU_COUNT : 2 2020-03-20 19:05:08.839 10486-11079/com.ykato.sample D/MainActivity: doInBackground start 1 2020-03-20 19:05:08.841 10486-11080/com.ykato.sample D/MainActivity: doInBackground start 2 2020-03-20 19:06:48.848 10486-11080/com.ykato.sample D/MainActivity: doInBackground end 2 2020-03-20 19:06:48.849 10486-11080/com.ykato.sample D/MainActivity: doInBackground start 3 2020-03-20 19:06:48.874 10486-11079/com.ykato.sample D/MainActivity: doInBackground end 1 2020-03-20 19:08:28.862 10486-11080/com.ykato.sample D/MainActivity: doInBackground end 3CPU数が2の環境で実行したため、thread poolサイズが2となります。
※thread poolサイズはこちらを参照してください。
そのため、タスク3の実行が待たされてしまいます。タスク3が待たされている間に取得したBugreportのスタック トレースは下記のようになっています。
プロセスID10486のスレッドID11079、11080が実行中であることが分かります。
また、どこで止まっているかも分かります。
MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:21)------ VM TRACES JUST NOW (/data/anr/dumptrace_64OkJO: 2020-03-20 19:05:26) ------ 〜〜省略〜〜 ----- pid 10486 at 2020-03-20 19:05:25 ----- Cmd line: com.ykato.sample 〜〜省略〜〜 "AsyncTask #1" prio=5 tid=16 Sleeping | group="main" sCount=1 dsCount=0 flags=1 obj=0x12f4a558 self=0xd0186600 | sysTid=11079 nice=10 cgrp=default sched=0/0 handle=0xce41a970 | state=S schedstat=( 928855 3381136 1 ) utm=0 stm=0 core=0 HZ=100 | stack=0xce317000-0xce319000 stackSize=1042KB | held mutexes= at java.lang.Thread.sleep(Native method) - sleeping on <0x0b069882> (a java.lang.Object) at java.lang.Thread.sleep(Thread.java:373) - locked <0x0b069882> (a java.lang.Object) at java.lang.Thread.sleep(Thread.java:314) at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:21) at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:18) at android.os.AsyncTask$2.call(AsyncTask.java:333) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641) at java.lang.Thread.run(Thread.java:764) "AsyncTask #2" prio=5 tid=17 Sleeping | group="main" sCount=1 dsCount=0 flags=1 obj=0x12f4a778 self=0xd0186c00 | sysTid=11080 nice=10 cgrp=default sched=0/0 handle=0xce314970 | state=S schedstat=( 298657 4820846 1 ) utm=0 stm=0 core=1 HZ=100 | stack=0xce211000-0xce213000 stackSize=1042KB | held mutexes= at java.lang.Thread.sleep(Native method) - sleeping on <0x0ad2bb93> (a java.lang.Object) at java.lang.Thread.sleep(Thread.java:373) - locked <0x0ad2bb93> (a java.lang.Object) at java.lang.Thread.sleep(Thread.java:314) at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:21) at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:18) at android.os.AsyncTask$2.call(AsyncTask.java:333) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641) at java.lang.Thread.run(Thread.java:764)補足
今回は、Bugreportから原因が判明しましたが、Bugreportは取るタイミングによって結果が変わるので、threadの処理にはlogを入れておいた方が良いですね。
- 投稿日:2020-03-22T15:54:05+09:00
Kotlin coroutine入門 ③~ViewModelの中でcoroutineを扱ってみる~
Kotlin coroutine入門 ②の続きです。
前回の入門記事から、時間が経っていますが、今回はViewModelの中で、何らかのAPIと通信する体のcoroutineを実装しUIに反映して行きたいと思います!!
従来の構成 MVVM のRxとcoroutine比較
Rx使用時
coroutine使用した場合
observableがまるっとcoroutinesに置き換わります。実際はReactiveStreamに似た、Coroutine flowでしょう。
実装
実際に実装をしてみます。(Repositry,ApiClientはApi.ktとして実装しています)
Api.ktpackage *** import kotlinx.coroutines.* class Api(){ suspend fun isHoge (): Deferred<Boolean> = coroutineScope { async (context = Dispatchers.IO) { true } } }MainActivityViewModel.ktpackage *** import android.util.Log import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.ViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext class MainActivityViewModel() : ViewModel(), CoroutineScope, LifecycleObserver { private val job = Job() // Dispatcher.Main を指定しているためこのスコープで起動するコルーチンはメインスレッドで動作する。 // Job として上で定義した job を渡しているので、すべてのコルーチンはこの job の子になる。 override val coroutineContext: CoroutineContext get() = Dispatchers.Main + job val scope = CoroutineScope(coroutineContext) @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun onResume() { val api = Api() //main threadで実行する scope.launch { try { api.isHoge().await().let { Log.d("MainActivityViewModel", it.toString()) } }catch (e: Throwable){ Log.d("MainActivityViewModel", e.message) } } Log.d("MainActivityViewModel","onResumeEnd") } @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun onPause() { //jobに属している全てのtaskを停止する job.cancel() } }Jobキャンセルについて
親ジョブがキャンセルされると子Job は子にキャンセルを伝播します。
キャンセルを忘れると、リークする可能性があります。MainActivityViewModel.kt@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun onPause() { //jobに属している全てのtaskを停止する job.cancel() }こちらが参考になりました。
ViewModel KTX
ViewModel KTXの viewModelScope() により、Coroutine Scopeやキャンセルが不要となりました。
実際のコードは以下の通りです。(app)build.gradledependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" }MainActivityViewModel.ktpackage **** import android.util.Log import androidx.lifecycle.* import kotlinx.coroutines.launch class MainActivityViewModel() : ViewModel(), LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun onResume() { val api = Api() //main threadで実行する Log.d("MainActivityViewModel","onResumeStart") viewModelScope.launch { try { api.isHoge().await().let { Log.d("MainActivityViewModel", it.toString()) } }catch (e: Throwable){ Log.d("MainActivityViewModel", e.message) } } Log.d("MainActivityViewModel","onResumeEnd") } @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun onPause() { //job cancel は不要 } }コードもすっきりしましたし、cancelが漏れることもないでしょう。
まとめ
viewModelScopeが登場しキャンセル漏れも防げるようになりましたね。
この辺りはRxの時もDispose忘れなどあったので、すごく助かります。
次回はRxのReactiveStreamでおなじみである、coroutine channel flowについて
記載します。
- 投稿日:2020-03-22T12:34:22+09:00
checkSelfPermissionがPERMISSION_DENIEDしか返してくれない罠を踏み抜いた話
すでにリリース済みのアプリに対して某広告系ライブラリを入れた時に突如パーミッション関係が軒並みDENIEDを返すようになりパニックになったので自分のための備忘録として。
現象
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)もしくはその後、
requestPermission
をコールした後にonRequestPermissionsResult
で戻ってきた後の第三引数が全部PERMISSION_DENIEDになる。
ちなみに勿論パーミッションは許可された状態で起きる。
今回の場合はすでにリリース済みのアプリで、このあたりは元々元気に動いていたのに急にスンッ・・・とおかしくなったので事態の把握は楽でした。原因と対応
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
AndroidManifest.xml
で定義しているpermissionは上記のような定義だったのだけれど・・・
Merged Manifest
のほうで確認してみると
/(^o^)\・・・
そう。ライブラリ側でuses-permission
にmaxSdkVersion
が有り難くも設定されていたのでそもそもmaxSdkVersion
を超えるSDKバージョンの場合はuses-permission
の定義は無かったことになる。
すなわちcheckSelfPermission
がDENIEDなのは当然の結果だった・・・ということで、
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="replace" />
node="replase"
指定を追加して、有り難く設定されているライブラリ側のmaxSdkVersion
は無視させて頂くことにした。教訓
新しいライブラリをリリース済みアプリに入れる時は、
Merged Manifest
がどうなっているかちゃんとチェックしようね!ぜったいだよ!
- 投稿日:2020-03-22T01:28:37+09:00
Android で Firebase In-App Messaging が表示されない問題の workaround
はじめに
Android で Firebase In-App Messaging を表示させてようとしても、アプリの作り次第では、 表示されない or すぐに消えてしまう問題 が発生することが確認されました。
全てのケースで解決しないかも知れませんが、意図通りに表示できる workaround を見つけたので記しておきます。Firebase In-App Messaging 導入時の参考になれば幸いです。
※調査した技術内容が多めです
※キャンペーン情報の取得完了のタイミングや、メッセージを表示したい Activity の lifecycle の状態次第では上手く行かないケースが存在するかも知れません
→ 技術的背景を理解した上で、最適な workaround を使うことをオススメします。本記事での用語
用語 意味 LaunchActivity AndroidManifest.xml
でandroid.intent.category.LAUNCHER
が指定されている ActivityMainActivity LaunchActivity から起動される Activity In-App Message Firebase In-App Messaging が表示するメッセージ
(com.google.firebase.inappmessaging.model.InAppMessage というクラスがあります)再現条件
In-App Message を表示した後、何らかの Activity(最前面になくても良い)が destroy されると、表示されている In-App Message が消える。
(タイミングによっては、表示されたことに気付けない)ありがちな例
LaunchActivity
が、 onCreate で他の Activity を呼び出して、LaunchActivity
は即閉じるような場合には再現します。実装例
class LaunchActivity : AppCompatActivity() { // Firebase In-App Messaging SDK はこの Activity にメッセージを表示させようとするが、 // すぐに画面遷移してしまうため、メッセージが表示されない(or すぐ消える) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_launch) if (isLogin) { openMainActivity() } else { openLoginActivity() } // ... } private fun openMainActivity() { val intent = Intent(this, MainActivity::class.java) //finish() で閉じなくても、↓でも再現 intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } }なぜ消えるのか?
Firebase In-App Messaging の内部では、
Application.ActivityLifecycleCallbacks
を実装したクラス(com.google.firebase.inappmessaging.display.FirebaseInAppMessagingDisplay
)が、アプリケーション内の Activity の Lifecycle を監視して、In-App Message の制御を行っています。
onActivityDestroyed
の override 実装を見ると、
https://github.com/firebase/firebase-android-sdk/blob/master/firebase-inappmessaging-display/src/main/java/com/google/firebase/inappmessaging/display/FirebaseInAppMessagingDisplay.java#L222-L228@Override public void onActivityDestroyed(Activity activity) { // clear all state scoped to activity and dismiss fiam headlessInAppMessaging.clearDisplayListener(); imageLoader.cancelTag(activity.getClass()); removeDisplayedFiam(activity); super.onActivityDestroyed(activity); }のようになっており、 Activity が destroy されたら、
removeDisplayedFiam(...)
が呼び出されています。
※Fiam
は Firebase In-App Messaging のことです特筆すべきは、最前面にある Activity かどうかは考慮していないということです。
(つまり、最前面にない Activity が destroy されることを SDK 側が考慮できていません)workaround
MainActivity
にて、LaunchActivity
が onDestroy されるまで In-App Messaging の表示を遅らせる。と、端的に言っても、いくつかステップがあります。
- In-App Message の表示を抑制しておく
MainActivity
にて、LaunchActivity
が onDestroy されたことを検出する- In-App Message の表示を抑制を解除して、In-App Message の表示処理を呼び出す
です。
1. 表示の抑制方法
FirebaseInAppMessaging.getInstance().setMessagesSuppressed(true)
を呼び出せば、In-App Message の表示を抑制することができます。FirebaseInAppMessaging.java/** * Enable or disable suppression of Firebase In App Messaging messages * * <p>When enabled, no in app messages will be rendered until either you either disable * suppression, or the app restarts, as this state is not preserved over app restarts. * * <p>By default, messages are not suppressed. * * @param areMessagesSuppressed Whether messages should be suppressed */ @Keep public void setMessagesSuppressed(@NonNull Boolean areMessagesSuppressed) { this.areMessagesSuppressed = areMessagesSuppressed; }実装を見てもわかるとおり、単に suppress するか否かのフラグの書き換えだけです。
suppress を false にしたからといって、そのタイミングで In-App Message の表示処理が実行されるわけではありません。
(JavaDoc コメントにもそのあたりの言及はありませんでした…)2.
LaunchActivity#onDestroy
の検出
Application.ActivityLifecycleCallbacks
を使えば検出が可能です。
後述しますが、In-App Messaging は最前面の Activity が resumed になったら表示処理が実行されるので、
LaunchActivity#onDestroy
になったMainActivity#onResume
になったの2つの条件を満たしたときに、
MainActivity
で何らかの callback を受け取れるようにすれば OK です。もうちょっとやりようはあるかも知れませんが…
InAppMessagingDelayHelper.ktobject InAppMessagingDelayHelper : Application.ActivityLifecycleCallbacks { private val targetActivityName: String = MainActivity::class.simpleName.orEmpty() private val backgroundActivityName: String = LaunchActivity::class.simpleName.orEmpty() private val destroyed = MutableLiveData<Boolean>().apply { value = false } private val resumed = MutableLiveData<Boolean>().apply { value = false } init { FirebaseInAppMessaging.getInstance().setMessagesSuppressed(true) } val canShow: LiveData<Boolean> = Transformations.distinctUntilChanged( MediatorLiveData<Boolean>().apply { value = false listOf(destroyed, resumed).forEach { liveData -> addSource(liveData) { val isDestroyed = destroyed.value ?: false val isResumed = resumed.value ?: false value = isDestroyed && isResumed } } } ) override fun onActivityDestroyed(activity: Activity?) { destroyed.value = (backgroundActivityName == activity?.localClassName) } override fun onActivityResumed(activity: Activity?) { resumed.value = (targetActivityName == activity?.localClassName) } override fun onActivityPaused(activity: Activity?) = Unit override fun onActivityStarted(activity: Activity?) = Unit override fun onActivitySaveInstanceState(activity: Activity?, outState: Bundle?) = Unit override fun onActivityStopped(activity: Activity?) = Unit override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) = Unit }を作って、 Application で
registerActivityLifecycleCallbacks
しておけばoverride fun onCreate() { super.onCreate() registerActivityLifecycleCallbacks(InAppMessagingDelayHelper) ... }
MainActivity
で LaunchActivity is destroyed && MainActivity is resumed の状態になったコールバックを受け取ることができます。MainActivity.ktoverride fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) .... InAppMessagingDelayHelper.canShow.observe(this, Observer { canShow -> if (canShow) { // LaunchActivity is destroyed && MainActivity is resumed // TODO : In-App Message の表示処理の呼び出し } }) ... }3. In-App Message の表示処理を呼び出す
In-App Message の表示処理は、
FirebaseInAppMessagingDisplay#onActivityResumed
から呼び出されています。FirebaseInAppMessagingDisplay.java@Override public void onActivityResumed(Activity activity) { super.onActivityResumed(activity); if (inAppMessage != null) { showActiveFiam(activity); } } ... private void showActiveFiam(@NonNull final Activity activity) { if (inAppMessage == null || headlessInAppMessaging.areMessagesSuppressed()) { Logging.loge("No active message found to render"); return; } // 表示処理 }
showActiveFiam(...)
は private ですが、呼び出し元のonActivityResumed(...)
は public なので、
FirebaseInAppMessagingDisplay.getInstance()
でインスタンスを取得すれば呼び出すことができます。つまり、
InAppMessagingDelayHelper.canShow.observe(this, Observer { canShow -> if (canShow) { - // LauncherActivity is destroyed && MainActivity is resumed - // TODO : In-App Message の表示処理の呼び出し + FirebaseInAppMessaging.getInstance().setMessagesSuppressed(false) // 抑制解除 + FirebaseInAppMessagingDisplay.getInstance().onActivityResumed(this) // 表示処理の呼び出し } })のようにすれば、In-App Message がちゃんと表示されるようになります。
アプリケーションによって、Landing する Activity までに、どんな Activity が表示されては消えるのかがまちまちだと思うので、それぞれのアプリケーションに応じた回避方法を採る必要があると考えています。
あくまでも参考程度にして下さい。本件は Firebase Android SDK の GitHub Issue で報告しているので、あわよくば、将来的にはこの workaround は不要になるかも知れません。
(不要になることを祈ってます。)
https://github.com/firebase/firebase-android-sdk/issues/1324
それ以外に調べた事
調査したときにわかったことを、ついでなので記しておきます。
メッセージレイアウトのトップバナーは Push 通知ではない
これは、単に勘違いしていただけですが、一応記載。
は
こんな感じで Push 通知っぽく表示されますが、Push 通知ではありません。
つまり、アプリを開かないと表示されません。アプリの起動を促進させるためには、Cloud Messaging を使う必要があります。
※In-App Messaging と Cloud Messaging を併用すれば、Push 通知経由でアプリを開いた場合のみに、特定の In-App Message を表示させることも可能です。In-App Message を表示している状態で別 Activity を表示させたときの挙動
新たに開いた Activity の上に、In-App Message が新たに表示される。
(既に表示している In-App Message が、新たに開いた Activity に隠れる…と思ってましたが、大丈夫でした!)In-App Message 表示させる方法
1. キャンペーンを作成して、テストデバイスに送る
https://firebase.google.com/docs/in-app-messaging/get-started?authuser=0&platform=android
によると、電力を節約するため、Firebase アプリ内メッセージングはサーバーからのメッセージの取得を 1 日に 1 回だけ行います。この設定の場合、テストが困難になることがあるため、メッセージをオンデマンドで表示するテストデバイスを Firebase コンソールで指定できます。
と書かれています。
この手ももちろん使えます。
(でも、若干めんどくさい…)2. コードでダミーのメッセージを生成して、メッセージの表示処理を呼び出す
Firebase Android SDK のソースコード を見てみると、どうやら、コードで In-App Messaging の表示処理を実行することができそうです。
リンク先が変わるかも知れないので、コードを引用しておきます。
com.example.firebase.fiamui.MainActivity.javaModalMessage message = builder .setBackgroundHexColor(bodyBackgroundColorString) .setTitle(title) .setBody(body) .setImageData(imageData) .setAction(modalAction) .build(campaignMetadata, data); FirebaseInAppMessagingDisplay.getInstance() .testMessage(this, message, new NoOpDisplayCallbacks());これを
LaunchActivity#onCreate(...)
などで実行します。
この手を使うと、色んなタイミングで In-App Messaging の表示処理の呼び出しを再現することが可能です。
表示処理の呼び出しのタイミングを変えてみたり、表示時や表示中の Activity の状態を変えてみるには、この方法が手軽でオススメです。具体的に、どんな場合に In-App Messaging の表示処理が呼び出されるのかは後述します。
In-App Message の表示処理の発動
InAppMessageStreamManager.javapublic Flowable<TriggeredInAppMessage> createFirebaseInAppMessageStream() { return Flowable.merge( appForegroundEventFlowable, analyticsEventsManager.getAnalyticsEventsFlowable(), programmaticTriggerEventFlowable) .doOnNext(e -> Logging.logd("Event Triggered: " + e)) .observeOn(schedulers.io()) ...色んな処理... // キャンペーン取得の通信が絡めば時間が掛かる可能性がある .observeOn(schedulers.mainThread())これを、
FirebaseInAppMessaging
の constructor で subscribe しています。
(内部では RxJava を使ってるんですね!)FirebaseInAppMessaging.javapublic class FirebaseInAppMessaging { FirebaseInAppMessaging(...) { ... Disposable unused = inAppMessageStreamManager .createFirebaseInAppMessageStream() .subscribe(FirebaseInAppMessaging.this::triggerInAppMessage); } ... private void triggerInAppMessage(TriggeredInAppMessage inAppMessage) { if (this.fiamDisplay != null) { fiamDisplay.displayMessage( inAppMessage.getInAppMessage(), displayCallbacksFactory.generateDisplayCallback( inAppMessage.getInAppMessage(), inAppMessage.getTriggeringEvent())); } }
- アプリが最前面に来たとき
- Analytics の Event が発行されたとき
- programmaticTriggerEvent が発行されたとき
に In-App Message の表示処理が開始されます。
ただし、バックグラウンド処理が完了してから、UI への表示処理の実行になるので、その間に Activity が遷移・終了している可能性も十分あり得ます。しかしながら、UI への表示処理を行ったタイミングで最前面の Activity に表示されるようになっていました。
(逆に言えば、MainActivity#onCreate
でイベントを送信し、それをトリガーに In-App Messaging を表示するように設定しても、画面遷移をしてしまえば、別の Activity で表示されることもある)
- 投稿日:2020-03-22T00:31:20+09:00
GoogleMapsAPI備忘録(2020年3月時点)
はじめに
今までの案件で使ったGoogleMapsAPIについての備忘録です。
APIの仕様や制限が変わるので、忘れる前に備忘録を残します。GoogleMapsAPI
マップ
Maps SDK for Android
地図を Android アプリに追加します。Maps SDK for iOS
地図を iOS アプリに追加します。Maps Static API
シンプルで埋め込み可能な地図画像を最小限のコードで追加します。Maps JavaScript API
ウェブサイトにインタラクティブな地図を追加します。独自のコンテンツと画像でマップをカスタマイズできます。Street View API
360 度のパノラマ画像を使用して実世界の画像を埋め込みます。マップ URL
Google マップを起動し、クロスプラットフォームの URL スキームを使用して、
検索やルート検索などの操作を開始します。ネイティブアプリ開発の案件だったため、Android/iOSそれぞれのSDKを使用しました。
検索すれば、やりたいことを実現するための方法を教えてくれるサイト・記事が多く、
特に詰まった部分はありませんでした。ルート
Directions API
複数の場所間の公共交通機関、自転車、車、徒歩でのルートを提供します。Distance Matrix API
複数の目的地について移動時間と距離を計算します。Roads API
車両が走行する正確なルートを決定します。Directions APIを使用しました。
ネイティブアプリでもAPIを直接使用する形式になるので、公式ドキュメントでほとんどこと足りました。
一点、そもそもで詰まった部分がGoogle mapのAPI KeyをAndroid/iOSで使用しても、
APIが正常に動きませんでした。
公式ドキュメントに以下の一文があるので、WebのKeyでないとダメなのかもしれません。
(バックエンドチームにラップしたAPIを作ってもらいました..)This service is also available as part of the client-side Maps JavaScript API, or for server-side use with the Java Client, Python Client, Go Client and Node.js Client for Google Maps Services.
プレイス
Places SDK for Android
多くの場所の豊富な詳細を Android アプリに追加します。ユーザーのクエリにオートコンプリートの結果を提供します。住所と地理座標を変換します。Places SDK for iOS
多くの場所の豊富な詳細を iOS アプリに追加します。ユーザーのクエリにオートコンプリートの結果を提供します。住所と地理座標を変換します。プレイス ライブラリ、Maps JavaScript API
多くの場所の豊富な詳細をウェブサイトに追加します。ユーザーのクエリにオートコンプリートの結果を提供します。住所と地理座標を変換します。Places API for Web
HTTP リクエストを使用して、多くの場所の最新情報を取得できます。Geocoding API
住所を地理的座標に変換したり、地理的座標から住所を特定したりできます。Geolocation API
携帯電話の基地局や Wi-Fi ノードからの位置データを利用して、GPS に依存せずにデバイスの位置を返します。Time Zone API
特定の緯度と経度のタイムゾーンを取得します。PlacesのSDKを使用しました。
公式ドキュメントに書いてあることで事足りましたが、どう使うかを考える必要はありました。
AutoCompleteを実現するときにFragmentやActivityのようなWidgetごと導入するか、
API実行のように導入するか選択できます。
どちらも試してみましたが、UIデザインが決まっている場合は後者の方が自由が効きます。まとめ
はじめは、案件で使ったAPIで適当なアプリを作ろうと思ったんですが、
諸々時間が足りずひとまずは記憶のあるうちにメモだけを残しました。