- 投稿日:2019-03-02T22:53:29+09:00
App Links実装手順
App Links実装手順
App Links Assistantに従って設定
- 「Tools」の「App Links Assistant」を選択する。
- 「App Links Assistant」の画面が出ます。
- ①~④のステップをやっていきます。
①Add URL intent filters
- 「App Links Assistant」の「Open URL Mapping Editor」をクリックする。
URL Mapping Editor
- 「Host」にドメインを記述する。
- 「pathPattern」にドメイン以降のパスを記述する。
- 「Activity」に上記のパスでアプリが起動した際に、どのActivityを開くかを指定
例) 1. Host ->「https://nwills.github.io」 2. pathPattern -> 「/AppLinksTest/」 3. Activity -> 「.MainActivity」
- 「Check URL Mapping」でURLをマッピングできるか確認
「This URL maps to []指定したActivity]」になっていればOK例) 4. Check URL Mapping ->「https://nwills.github.io/AppLinksTest/」 This URL maps to .MainActivity(app)②Add logic to handle intent
- 「App Links Assistant」の「Select Activity」をクリックする。
Select an Activity
- URLで起動するActivityを選択
- 「Insert Code」をクリック
- 自動的にコードが生成される
- 利用用途に応じて、その後の処理を記載
③Associate website
- 「App Links Assistant」の「Open Digital Asset Links File Generation」をクリックする。
Associate website
- 「Site domain」に先ほど指定したドメインが入っているかを確認
入っていなければ修正する。- 「Application ID」に自分のアプリのパッケージ名が入ってることを確認する。
入っていなければ修正する。- 「Signing config」または「Select keystore file」を選択する。
- 「Signing config」はデバッグ用
「debug」を指定する。- 「Select keystore file」はリリース用
APK作成時に使用するjksファイルを使用する。例) 3. 「Signing config」 ->「debug」
- 「Generate Digital Asset LInks file」をクリックする。
- 「Preview」に「者256」などの情報が表示される。
- 「Save file」をクリックし、「assetlinks.json」ファイルをわかりやすい場所に保存する。
- サーバーに「assetlinks.json」ファイルを配置する。
ファイルの置く場所は「To complete associating your app with your website, save the above file to [サーバーでファイルを置く場所]」に表示されている。例) 7. To complete associating your app with your website, save the above file to To complete associating your app with your website, save the above file to https://nwills.github.io/.well-known/assetlinks.json
- 「Link and Verify」をクリックして、下記の2点がチェック入っていたらOK
- 「Added auto Verify to intent filter elements.」
- 「Success! Tour app is associated with selected domain(s).」
④Test on device or emulator
- 「Test App Links」をクリックする。
App Link Testing
- 「URL」にテストしたいURLを入力する。
例) 1. URL ->「https://nwills.github.io/AppLinksTest/」
- 入力後「Enter」キーを押下すると「Select Device Target」画面がでる。
出ない場合は「URL」を見直す。- 実行したい実機またはエミュレータを選択する。
- 投稿日:2019-03-02T18:39:11+09:00
「AndroidXでのバックキー制御」について、OnBackPressedCallbackの使い方・注意点を補足
はじめに
@androhiさんの記事「AndroidXでのバックキー制御」を読んで、これは使わない手はない!と思いプロダクトに導入し、その時にハマった点や補足を皆様に共有したいと思い記事にしました。
(@androhiさんに許可を頂いてます。ありがとうございます!)ブログの内容を掻い摘んで説明すると、
Activity#onBackPressed()
でやっていたバックキーの制御をOnBackPressedCallback
で出来るようにしてFragmentでのバックキー制御を簡単にしよう!というやつです。インストール
- 利用にはandroidxのappcompat:1.1.0とactivity:1.0.0が必要です。まずはこれらをインストールします。
app/build.gradledependencies { implementation 'androidx.appcompat:appcompat:1.1.0-alpha01' implementation 'androidx.activity:activity:1.0.0-alpha04' }
- 2019/3/2時点の最新はappcompat:1.1.0-alpha02、activity:1.0.0-alpha04ですが、appcompat:1.1.0-alpha02についてはバグがありエラーが出るためalpha01にダウングレードしています。
`NoSuchMethodException: addFontWeightStyle [class java.lang.String, int, boolean]`っていうエラー出ててなんだろーなーと調べたら 'androidx.appcompat:appcompat:1.1.0-alpha02' 使うとだめっぽい。https://t.co/omgGlZfF5R
— たる (@tarumzu) 2019年3月1日使い方
- まずActivityはandroidxのAppCompatActivityを継承させます。
MainActivity.ktimport androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { // 略SampleFragment.ktclass EndFragment : QuizFragment(), CoroutineScope { val mainActivity: MainActivity get() = (activity as MainActivity) val isOverrideBack = false override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) mainActivity.addOnBackPressedCallback(this, object : OnBackPressedCallback { override fun handleOnBackPressed(): Boolean { if (isOverrideBack) { // NOTE: tureを返すと何もしない。なので例えばpauseメソッドでバックの制御を上書いたり出来る。 pause() return true }else { // NOTE: falseの場合はfragmentがスタックに積まれていれば1つ戻る。 return false } } }) }以上です!とてもシンプルで最高ですね。
ただ、注意点としてhandleOnBackPressedでfalseを返す場合、スタックに積まれたflagmentしか戻らないので、Activityを終了すると行った処理を別途書く必要があります。例えば下記のようにする必要があります。mainActivity.addOnBackPressedCallback(this, object : OnBackPressedCallback { override fun handleOnBackPressed(): Boolean { val fm = baseActivity.supportFragmentManager val backStackCnt = fm.backStackEntryCount // フラグメントの戻り先ある場合は戻る。 if (backStackCnt == 1) { baseActivity.finish() return true } return false } })最後に
現時点(2019/3/2)ではまだalphaではありますが、前述のバージョンであれば動作も安定しており、コードも非常にシンプルになるのでプロダクトで採用しても問題ないかなと思ってます。気になった方はぜひ使ってみてください!
- 投稿日:2019-03-02T18:39:11+09:00
「AndroidXでのバックキー制御」について、OnBackPressedCallbackの使い方・注意点補足
はじめに
@androhiさんの記事「AndroidXでのバックキー制御」を読んで、これは使わない手はない!と思いプロダクトに導入し、その時にハマった点や補足を皆様に共有したいと思い記事にしました。
(@androhiさんに許可を頂いてます。ありがとうございます!)ブログの内容を掻い摘んで説明すると、
Activity#onBackPressed()
でやっていたバックキーの制御をAndroidXからはOnBackPressedCallback
で出来るようにしてFragmentでの利用を簡単にしよう!というやつです。インストール
- 利用にはandroidxのappcompat:1.1.0とactivity:1.0.0が必要です。まずはこれらをインストールします。
app/build.gradledependencies { implementation 'androidx.appcompat:appcompat:1.1.0-alpha01' implementation 'androidx.activity:activity:1.0.0-alpha04' }
- 2019/3/2時点の最新はappcompat:1.1.0-alpha02、activity:1.0.0-alpha04ですが、appcompat:1.1.0-alpha02についてはバグがありエラーが出るためalpha01にダウングレードしています。
`NoSuchMethodException: addFontWeightStyle [class java.lang.String, int, boolean]`っていうエラー出ててなんだろーなーと調べたら 'androidx.appcompat:appcompat:1.1.0-alpha02' 使うとだめっぽい。https://t.co/omgGlZfF5R
— たる (@tarumzu) 2019年3月1日使い方
- まずActivityはandroidxのAppCompatActivityを継承させます。
MainActivity.ktimport androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { // 略SampleFragment.ktclass EndFragment : QuizFragment(), CoroutineScope { val mainActivity: MainActivity get() = (activity as MainActivity) val isOverrideBack = false override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) mainActivity.addOnBackPressedCallback(this, object : OnBackPressedCallback { override fun handleOnBackPressed(): Boolean { if (isOverrideBack) { // NOTE: tureを返すと何もしない。なので例えばpauseメソッドでバックの制御を上書いたり出来る。 pause() return true }else { // NOTE: falseの場合はfragmentがスタックに積まれていれば1つ戻る。 return false } } }) }以上です!とてもシンプルで最高ですね。
ただ、注意点としてhandleOnBackPressedでfalseを返す場合、スタックに積まれたflagmentしか戻らないので、Activityを終了させるといった処理を別途書く必要があります。
例えばスタックのFlagmentがなくなったらActivityを終了させたいという時は下記のようにする必要があります。mainActivity.addOnBackPressedCallback(this, object : OnBackPressedCallback { override fun handleOnBackPressed(): Boolean { val fm = baseActivity.supportFragmentManager val backStackCnt = fm.backStackEntryCount // フラグメントの戻り先ある場合は戻る。 if (backStackCnt == 1) { baseActivity.finish() return true } return false } })最後に
現時点(2019/3/2)ではまだalphaではありますが、前述のバージョンであれば動作も安定しており、コードも非常にシンプルになるのでプロダクトで採用しても問題ないかなと思ってます。気になった方はぜひ使ってみてください!
- 投稿日:2019-03-02T18:39:11+09:00
「AndroidXでのバックキー制御」について、OnBackPressedCallbackの使い方や注意点補足
はじめに
@androhiさんの記事「AndroidXでのバックキー制御」を読んで、これは使わない手はない!と思いプロダクトに導入し、その時にハマった点や補足を皆様に共有したいと思い記事にしました。
(@androhiさんに許可を頂いてます。ありがとうございます!)ブログの内容を掻い摘んで説明すると、
Activity#onBackPressed()
でやっていたバックキーの制御をAndroidXからはOnBackPressedCallback
が使えるようになったので、これを使ってFragmentでの実装を簡単にしよう!というやつです。インストール
- 利用にはandroidxのappcompat:1.1.0とactivity:1.0.0が必要です。まずはこれらをインストールします。
app/build.gradledependencies { implementation 'androidx.appcompat:appcompat:1.1.0-alpha01' implementation 'androidx.activity:activity:1.0.0-alpha04' }
- 2019/3/2時点の最新はappcompat:1.1.0-alpha02、activity:1.0.0-alpha04ですが、appcompat:1.1.0-alpha02についてはバグがありエラーが出るためalpha01にダウングレードしています。
`NoSuchMethodException: addFontWeightStyle [class java.lang.String, int, boolean]`っていうエラー出ててなんだろーなーと調べたら 'androidx.appcompat:appcompat:1.1.0-alpha02' 使うとだめっぽい。https://t.co/omgGlZfF5R
— たる (@tarumzu) 2019年3月1日使い方
- まずActivityはandroidxのAppCompatActivityを継承させます。
MainActivity.ktimport androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { // 略SampleFragment.ktclass EndFragment : QuizFragment(), CoroutineScope { val mainActivity: MainActivity get() = (activity as MainActivity) val isOverrideBack = false override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) mainActivity.addOnBackPressedCallback(this, object : OnBackPressedCallback { override fun handleOnBackPressed(): Boolean { if (isOverrideBack) { // NOTE: tureを返すと何もしない。なので例えばpauseメソッドでバックの制御を上書いたり出来る。 pause() return true }else { // NOTE: falseの場合はfragmentがスタックに積まれていれば1つ戻る。 return false } } }) }以上です!とてもシンプルで最高ですね。
ただ、注意点としてhandleOnBackPressedでfalseを返す場合、スタックに積まれたflagmentしか戻らないので、Activityを終了させるといった処理を別途書く必要があります。
例えばスタックのFlagmentがなくなったらActivityを終了させたいという時は下記のようにする必要があります。mainActivity.addOnBackPressedCallback(this, object : OnBackPressedCallback { override fun handleOnBackPressed(): Boolean { val fm = baseActivity.supportFragmentManager val backStackCnt = fm.backStackEntryCount // フラグメントの戻り先ある場合は戻る。 if (backStackCnt == 1) { baseActivity.finish() return true } return false } })最後に
現時点(2019/3/2)ではまだalphaではありますが、前述のバージョンであれば動作も安定しており、コードも非常にシンプルになるのでプロダクトで採用しても問題ないかなと思ってます。気になった方はぜひ使ってみてください!
- 投稿日:2019-03-02T16:46:30+09:00
DroidKaigiアプリのSingle Activity(Fragment)でハマったところメモ
他にもSingle Activity関連はありましたが、とりあえず大きなところでいうとこれが勉強になったので書いておきます。
セッションのリストに戻った時にスクロールの位置が戻る問題
結論から言うとFragmentのライフサイクルへの理解が足りませんでした。
起こった問題
セッションの詳細の画面から、セッションのリストに戻った時にスクロールの位置が戻る問題がありました。
https://github.com/DroidKaigi/conference-app-2019/issues/33問題があった当時のコードはこれです。
savedInstanceState
がないときにcommitしています。これの何が問題なのでしょうか?override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setupBottomSheet(savedInstanceState)private fun setupBottomSheet(savedInstanceState: Bundle?) { if (savedInstanceState == null) { // savedInstanceStateがないときだけcommit val fragment: Fragment = when (val tab = SessionPage.pages[args.tabIndex]) { ... SessionPage.Favorite -> { BottomSheetFavoriteSessionsFragment.newInstance() } } childFragmentManager .beginTransaction() .replace(R.id.sessions_sheet, fragment) .disallowAddToBackStack() .commit() }原因ととりあえずの解決策
なぜかというとFragmentがバックスタックから戻ってきたときは
savedInstanceState
がnullになるため、replaceしてしまうためです。childFragmentManager.findFragmentByTag(tab.title)によりFragmentManager判定します
override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setupBottomSheet(savedInstanceState)private fun setupBottomSheet(savedInstanceState: Bundle?) { // suppress fragment replacement val tab = SessionPage.pages[args.tabIndex] if (savedInstanceState == null && childFragmentManager.findFragmentByTag(tab.title) == null ) {最終的な解決策
ここで自分がうまく動いた!とツイートします
そこで以下のツッコミが入りますこのPRはsetupBottomSheetをonCreateで呼ぶだけで修正できるのではないかと思いました。
— くぼぼ (@swiz_ard) 2019年1月9日ここで以下のことを学びました。
バックスタックから戻ってきたときはonCreateは呼ばれずonCreateViewやonActivityCreatedは呼ばれる。
孫フラグメントを作るときはFragmentのonCreateでcommit()をすることで、フラグメントが無駄にまた作られることを防げる。
https://github.com/xxv/android-lifecycle より最終的に以下のようにonCreateで実装すれば完成です!
https://github.com/DroidKaigi/conference-app-2019/blob/e714989139f182fe499b1b3d4d4a4f5e8b9cd9cc/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt#L91override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { setupSessionsFragment() } } ... private fun setupSessionsFragment() { val tab = SessionPage.pages[args.tabIndex] val fragment: Fragment = when (tab) { ... SessionPage.Favorite -> { BottomSheetFavoriteSessionsFragment.newInstance() } } childFragmentManager .beginTransaction() .replace(R.id.sessions_sheet, fragment, tab.title) .disallowAddToBackStack() .commit() }
- 投稿日:2019-03-02T16:46:30+09:00
DroidKaigiのアプリのSingle Activity(Fragment)でハマったところメモ
他にもSingle Activity関連はありましたが、とりあえず大きなところでいうとこれが勉強になったので書いておきます。
セッションのリストに戻った時にスクロールの位置が戻る問題
結論から言うとFragmentのライフサイクルへの理解が足りませんでした。
起こった問題
セッションの詳細の画面から、セッションのリストに戻った時にスクロールの位置が戻る問題がありました。
https://github.com/DroidKaigi/conference-app-2019/issues/33問題があった当時のコードはこれです。
savedInstanceState
がないときにcommitしています。これの何が問題なのでしょうか?override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setupBottomSheet(savedInstanceState)private fun setupBottomSheet(savedInstanceState: Bundle?) { if (savedInstanceState == null) { // savedInstanceStateがないときだけcommit val fragment: Fragment = when (val tab = SessionPage.pages[args.tabIndex]) { ... SessionPage.Favorite -> { BottomSheetFavoriteSessionsFragment.newInstance() } } childFragmentManager .beginTransaction() .replace(R.id.sessions_sheet, fragment) .disallowAddToBackStack() .commit() }原因ととりあえずの解決策
なぜかというとFragmentがバックスタックから戻ってきたときは
savedInstanceState
がnullになるため、replaceしてしまうためです。childFragmentManager.findFragmentByTag(tab.title)によりFragmentManager判定します
override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setupBottomSheet(savedInstanceState)private fun setupBottomSheet(savedInstanceState: Bundle?) { // suppress fragment replacement val tab = SessionPage.pages[args.tabIndex] if (savedInstanceState == null && childFragmentManager.findFragmentByTag(tab.title) == null ) {最終的な解決策
ここで自分がうまく動いた!とツイートします
そこで以下のツッコミが入りますこのPRはsetupBottomSheetをonCreateで呼ぶだけで修正できるのではないかと思いました。
— くぼぼ (@swiz_ard) 2019年1月9日ここで以下のことを学びました。
バックスタックから戻ってきたときはonCreateは呼ばれずonCreateViewやonActivityCreatedは呼ばれる。
孫フラグメントを作るときはFragmentのonCreateでcommit()をすることで、フラグメントが無駄にまた作られることを防げる。
https://github.com/xxv/android-lifecycle より最終的に以下のようにonCreateで実装すれば完成です!
https://github.com/DroidKaigi/conference-app-2019/blob/e714989139f182fe499b1b3d4d4a4f5e8b9cd9cc/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt#L91override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { setupSessionsFragment() } } ... private fun setupSessionsFragment() { val tab = SessionPage.pages[args.tabIndex] val fragment: Fragment = when (tab) { ... SessionPage.Favorite -> { BottomSheetFavoriteSessionsFragment.newInstance() } } childFragmentManager .beginTransaction() .replace(R.id.sessions_sheet, fragment, tab.title) .disallowAddToBackStack() .commit() }
- 投稿日:2019-03-02T16:46:30+09:00
DroidKaigiのSingle Activity(Fragment)でハマったところメモ
他にもSingle Activity関連はありましたが、とりあえず大きなところでいうとこれが勉強になったので書いておきます。
セッションのリストに戻った時にスクロールの位置が戻る問題
結論から言うとFragmentのライフサイクルへの理解が足りませんでした。
起こった問題
セッションの詳細の画面から、セッションのリストに戻った時にスクロールの位置が戻る問題がありました。
https://github.com/DroidKaigi/conference-app-2019/issues/33問題があった当時のコードはこれです。
savedInstanceState
がないときにcommitしています。これの何が問題なのでしょうか?override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setupBottomSheet(savedInstanceState)private fun setupBottomSheet(savedInstanceState: Bundle?) { if (savedInstanceState == null) { // savedInstanceStateがないときだけcommit val fragment: Fragment = when (val tab = SessionPage.pages[args.tabIndex]) { ... SessionPage.Favorite -> { BottomSheetFavoriteSessionsFragment.newInstance() } } childFragmentManager .beginTransaction() .replace(R.id.sessions_sheet, fragment) .disallowAddToBackStack() .commit() }原因ととりあえずの解決策
なぜかというとFragmentがバックスタックから戻ってきたときは
savedInstanceState
がnullになるため、replaceしてしまうためです。childFragmentManager.findFragmentByTag(tab.title)によりFragmentManager判定します
override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setupBottomSheet(savedInstanceState)private fun setupBottomSheet(savedInstanceState: Bundle?) { // suppress fragment replacement val tab = SessionPage.pages[args.tabIndex] if (savedInstanceState == null && childFragmentManager.findFragmentByTag(tab.title) == null ) {最終的な解決策
ここで自分がうまく動いた!とツイートします
そこで以下のツッコミが入りますこのPRはsetupBottomSheetをonCreateで呼ぶだけで修正できるのではないかと思いました。
— くぼぼ (@swiz_ard) 2019年1月9日ここで以下のことを学びました。
バックスタックから戻ってきたときはonCreateは呼ばれずonCreateViewやonActivityCreatedは呼ばれる。
孫フラグメントを作るときはFragmentのonCreateでcommit()をすることで、フラグメントが無駄にまた作られることを防げる。
https://github.com/xxv/android-lifecycle より最終的に以下のようにonCreateで実装すれば完成です!
https://github.com/DroidKaigi/conference-app-2019/blob/e714989139f182fe499b1b3d4d4a4f5e8b9cd9cc/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt#L91override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { setupSessionsFragment() } } ... private fun setupSessionsFragment() { val tab = SessionPage.pages[args.tabIndex] val fragment: Fragment = when (tab) { ... SessionPage.Favorite -> { BottomSheetFavoriteSessionsFragment.newInstance() } } childFragmentManager .beginTransaction() .replace(R.id.sessions_sheet, fragment, tab.title) .disallowAddToBackStack() .commit() }
- 投稿日:2019-03-02T15:45:56+09:00
さまざまなCoroutineDispatcherの指定方法メモ
概要
Kotlin CoroutineはDispatcherによって実行スレッドを制御する。そのDispatcherを切り替えることで、コルーチンのコードをテスト可能にできます。
DroidKaigiではCoroutinePluginによるやり方を作成して利用していました。そこにAppCoroutineDispatcherを使うPRが来ました。
3つの指定方法があるのでそれぞれ見ていきましょう。
CoroutinePlugin
AppCoroutineDispatcher
kotlinx-coroutines-testCoroutinePluginによるやり方
CoroutinePluginはKotlin Corotuinesに存在するクラスではなく、アプリ内で作って使う形になります。
DroidKaigiアプリで採用されている方法
RxJavaのスケジューラーを真似たやり方でstaticでもって置いて切り替えができるようにする方式です。object CoroutinePlugin { private val defaultIoDispatcher: CoroutineContext = Dispatchers.IO val ioDispatcher: CoroutineContext get() = ioDispatcherHandler?.invoke( defaultIoDispatcher ) ?: defaultIoDispatcher @set:VisibleForTesting var ioDispatcherHandler: ((CoroutineContext) -> CoroutineContext)? = null ...テストでは
CoroutinePlugin.mainDispatcherHandler = { Dispatchers.Default }
などで置き換えが出来ます。AppCoroutineDispatchersによるやり方
AppCoroutineDispatchersはこういうクラスを作って、これをInjectすることで置き換えるパターン。Chris Banesがやっていたものになります
data class AppCoroutineDispatchers( val database: CoroutineDispatcher, val disk: CoroutineDispatcher, val network: CoroutineDispatcher, val main: CoroutineDispatcher ) @Singleton @Provides fun provideDispatchers(schedulers: AppRxSchedulers) = AppCoroutineDispatchers( database = schedulers.database.asCoroutineDispatcher(), disk = schedulers.disk.asCoroutineDispatcher(), network = schedulers.network.asCoroutineDispatcher(), main = UI )kotlinx-coroutines-testによるやり方
kotlinx-coroutines-testを依存関係に追加して、これだけです。
class SomeTest { private val mainThreadSurrogate = newSingleThreadContext("UI thread") @Before fun setUp() { Dispatchers.setMain(mainThreadSurrogate) } @After fun tearDown() { Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher mainThreadSurrogate.close() } @Test fun testSomeUI() = runBlocking { launch(Dispatchers.Main) { // Will be launched in the mainThreadSurrogate dispatcher ... } } }これには一つだけ問題点があって、Dispatchers.Mainしか置き換えが出来ません。
どうしたか
CoroutinePluginによるやり方を使いました。
メリデメに関しては以下にまとまっています
https://github.com/DroidKaigi/conference-app-2019/issues/236まず
kotlinx-coroutines-test
はMainしか置き換えできないので、用途に合わない場合も出てきそうでした。ただ今考えてみるとこれとrunBlocking()
でテストを対応できた説もあるので、チャレンジしてみるのはいいと思います。
AppCoroutineDispatchersによるやり方はDroidKaigiではReceiveChannel.toLiveData()
にわたす必要があるなど、大量に使われる部分でもDispatcherを渡していく必要が出てきたりなどするため、結構煩雑になってしまいそうでした。そのためCoroutinePluginを使う方法をそのまま使いました。
好みの問題もあるので、基本的にはkotlinx-coroutines-test
でやってみて、何か問題があれば変えていくのがいいかなと思います。
- 投稿日:2019-03-02T14:22:52+09:00
リリース前に知っておきたかった iOS / Android 自動更新購読(月額)の有効期限の罠
アプリの月額課金についてたくさん調べて実装し、ようやくリリースまでこぎつけて、リリース後大きな不具合もなく安心していたのも束の間、月初の昨日(3/1)に有効期限の罠にハマったので記録しておきます。
解決方法はまだ決定できていません。1ヶ月以外の購読期間についてはわからないです。
前提と事の発端
- アプリ内に開始日と有効期限日を表示している(例・開始は2/15 有効期限は3/15)
- 有効期限はストアから発行されたレシートの情報を元にしている
- アプリ内の注意書きに「有効期限は開始日の翌月同日まで」と表記している
- ここは日本
ユーザからの「3/1に開始したのに有効期限が3/29と表示されている。どういうことか」という問い合わせで何かが起きていることが発覚。調査を開始しました。
結論からいうと
プラットフォーム 開始日時 有効期限日 iOS 3/1 0:00~16:59 3/29 iOS 3/1 17:00~ 4/1 Android 3/1 0:00~8:59 3/29 Android 3/1 9:00~ 4/1 ※ プラットフォームごとに数時間のズレがあるため厳密には上の通りではありません。後述します。
どういうことかというと、ストア側とは国による時差があり上記時間帯ではストア側はまだ2/28。
iOSでは17時間(Apple本社がカリフォルニア州だから?)、Androidでは9時間(標準時)の時差がありました。
ストアとしては2/28に開始したのだから期限は当然3/28になり、時間によっては日本時間で3/29になります。たとえばiOSの場合、日本時間で16:59に開始したユーザと17:00に開始したユーザで有効期限に3日間も差があることに。
23:59と0:00で差ができるのはユーザとして納得しますが、今回のようなのは痛いです。
実装当時は考えが及びませんでした。解決案
チームでいくつかあがった解決案。どれも根本解決でないし、前述のとおりまだ方針は決定していません。
アプリ側で有効期限を設定する
ストアのレシート情報を無視して、開始日から1ヶ月分起算して期限を設定する案。
今回の場合は永遠にストアと3日の差が出続けます。
でもそうするべきだったのかもしれません。アプリ内の有効期限表示をなくす
完全に逃げ
余談
このようなイレギュラー(?)な場合にかかわらず、iOSとAndroidでは有効期限の時間に差があります。
- iOS:
開始日時 + 1ヶ月 - 1時間
(例・2/15 14:00に開始 → 3/15 13:00が期限)- Android:
開始日時 + 1ヶ月 + 2時間
(例・2/15 14:00に開始 → 3/15 16:00が期限)Androidの方がiOSに比べて3時間優しい仕様になっています。
22:00 ~ 1:00あたりの登録で日付に齟齬が生じることがあるので注意が必要です。この時間差については検証時のテストアカウントでは発見できません。
公式リファレンスに明記しておいてほしかったです。見逃したのかな?余談2
上で公式リファレンスを軽くdisっていますが、課金の実装で一番大切なのは公式リファレンスを読みまくることです。
大変わかりづらく書かれていたりしますが、何度も何度も読むことをおすすめします。
ちゃんと読むと必要なことは書かれています。
- 投稿日:2019-03-02T11:28:26+09:00
Android の CSV ライブラリ
Java のライブラリなら、
なんでも Android で使用できると思っていたが。
そうではなかったので、まとめておく。Open CSV
カンマで分解して、文字列の配列にして、並び順で取得する。
http://opencsv.sourceforge.net/
サンプルプログラムは、ここに
https://github.com/ohwada/Android_Samples/tree/master/Csv1下記のようなヘッダー行があるファイル
は、Java オブジェクトにマッピングする機能もある。"Name","Quantity" "Apple", "10" "Banana", "20"しかし、Android では、下記のエラーになる。
NoClassDefFoundError: Failed resolution of: Ljava/beans/Introspector
Android に は必要なライブラリがないようです。
参考 : Didn't find class “java.beans.Introspector” when Using OpenCSV to parse csv files
univocity-parsers
CSV ファイルを Java オブジェクトにマッピングする機能がある。
https://www.univocity.com/pages/univocity_parsers_tutorial
使い方
CSV ファイルのカラムに合わせたJavaクラスを用意する。
アノテーションでカラムと変数の対応を指定する。@Data public class Hoge { @Parsed(field = "Name") public String name = ""; @Parsed(field = "Quantity") public int quantity = 0;CSV ファイルの読み込み
// Assetフォルダーから読み込む場合 Reader reader = new InputStreamReader( getAssets().open(ファイル名) ); CsvParserSettings settings = new CsvParserSettings(); BeanListProcessor<Shopping> rowProcessor = new BeanListProcessor<>(Hoge.class); settings.setProcessor(rowProcessor); CsvRoutines routines = new CsvRoutines(settings); List<Hoge> list = routines.parseAll( Hoge.class, reader );サンプルプログラムは、ここに
https://github.com/ohwada/Android_Samples/tree/master/Csv2Apache Commons CSV
カンマで分解して、文字列の配列にして、並び順で取得する。
https://commons.apache.org/proper/commons-csv/
サンプルプログラムは、ここに
https://github.com/ohwada/Android_Samples/tree/master/Csv3
- 投稿日:2019-03-02T10:43:12+09:00
React Nativeでピアノを作る
はじめに
React Nativeでピアノを作ったのでソースコードの説明をします。
Githubのリポジトリはこちらです。I made the piano with React Native !
— nabehide (@____nabehide) 2019年2月27日
React Nativeでピアノを実装しました!
ソースコードはGithubに置きました。https://t.co/3WHPpVzS4L pic.twitter.com/Mdb7iuqfc0プロジェクトの立ち上げ
react-nativeのコマンドでプロジェクトを作ります。
react-native init piano cd piano
ピアノ音源の準備
mp3形式で音源を準備します。
Githubのリポジトリにアップロードしているので、こちらをお使いください。audio/ ├── A.mp3 ├── As.mp3 ├── B.mp3 ├── C.mp3 ├── Cs.mp3 ├── D.mp3 ├── Ds.mp3 ├── E.mp3 ├── F.mp3 ├── Fs.mp3 ├── G.mp3 └── Gs.mp3
react-native-soundのインストール
React Nativeで音を鳴らすためのライブラリreact-native-soundをインストールします。
yarn add react-native-sound react-native link react-native-sound
iOSではxcode上でプロジェクトファイルの中に音源をドラッグ&ドロップします。
Androidでは音源を android/app/src/main/res/raw のフォルダに入れます。
mkdir android/app/src/main/res/raw cp audio/* android/app/src/main/res/raw/
ソースコード
ソースコードは以下になります。
順番に説明していきます。App.jsximport React from 'react'; import { StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import Sound from 'react-native-sound'; export default class App extends React.Component { constructor( props ){ super( props ); this.state = { colorC : "white", colorCs: "black", colorD : "white", colorDs: "black", colorE : "white", colorF : "white", colorFs: "black", colorG : "white", colorGs: "black", colorA : "white", colorAs: "black", colorB : "white", } this.sound = {}; const soundList = [ "C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B" ] soundList.forEach(note => { this.sound[note] = new Sound( note + ".mp3", Sound.MAIN_BUNDLE, error => { if ( error ) { console.log("failed to load the sound.", error); } }) }); } stroke ( note ) { switch ( note ) { case "C": this.setState({ colorC: "rgba(1, 1, 1, 0.1)" }) break; case "Cs": this.setState({ colorCs: "rgba(0, 0, 0, 0.5)" }) break; case "D": this.setState({ colorD: "rgba(1, 1, 1, 0.1)" }) break; case "Ds": this.setState({ colorDs: "rgba(0, 0, 0, 0.5)" }) break; case "E": this.setState({ colorE: "rgba(1, 1, 1, 0.1)" }) break; case "F": this.setState({ colorF: "rgba(1, 1, 1, 0.1)" }) break; case "Fs": this.setState({ colorFs: "rgba(0, 0, 0, 0.5)" }) break; case "G": this.setState({ colorG: "rgba(1, 1, 1, 0.1)" }) break; case "Gs": this.setState({ colorGs: "rgba(0, 0, 0, 0.5)" }) break; case "A": this.setState({ colorA: "rgba(1, 1, 1, 0.1)" }) break; case "As": this.setState({ colorAs: "rgba(0, 0, 0, 0.5)" }) break; case "B": this.setState({ colorB: "rgba(1, 1, 1, 0.1)" }) break; } setTimeout( () => { this.sound[note].play(success => { if ( success ) { console.log("successfully finished playing."); } else { console.log("failed to play the sound."); } }); }, 1); } stop( note ) { switch ( note ) { case "C": this.setState( { colorC: "white" } ) break; case "Cs": this.setState( { colorCs: "black" } ) break; case "D": this.setState( { colorD: "white" } ) break; case "Ds": this.setState( { colorDs: "black" } ) break; case "E": this.setState( { colorE: "white" } ) break; case "F": this.setState( { colorF: "white" } ) break; case "Fs": this.setState( { colorFs: "black" } ) break; case "G": this.setState( { colorG: "white" } ) break; case "Gs": this.setState( { colorGs: "black" } ) break; case "A": this.setState( { colorA: "white" } ) break; case "As": this.setState( { colorAs: "black" } ) break; case "B": this.setState( { colorB: "white" } ) break; } setTimeout( () => { for (let i=0; i<2000; i++) { this.sound[note].setVolume( 1.0-i/2000. ); } this.sound[note].stop(); this.sound[note].setVolume( 1.0 ); }, 1 ) } render () { return ( <View style={styles.container}> <View style={{ flex: 1, flexDirection: "column", alignItems: "center" }}> <View style={{ flexDirection : "row", alignItems: "center", justifyContent: "center" }}> <View style={{ backgroundColor: "white", height: 100, width: 32, borderLeftWidth: 1, borderTopWidth: 1,}} > </View > <View onTouchStart={() => this.stroke("Cs")} onTouchEnd={() => this.stop("Cs")} style={{ backgroundColor: this.state.colorCs, height: 100, width: 32, borderTopWidth: 1, borderLeftWidth: 1,}} > </View > <View style={{ backgroundColor: "white", height: 100, width: 16, borderTopWidth: 1, }} > </View > <View onTouchStart={() => this.stroke("Ds")} onTouchEnd={() => this.stop("Ds")} style={{ backgroundColor: this.state.colorDs, height: 100, width: 32, borderTopWidth: 1, borderLeftWidth: 1,}} > </View > <View style={{ backgroundColor: "white", height: 100, width: 32, borderTopWidth: 1, }} > </View > <View style={{ backgroundColor: "white", height: 100, width: 32, borderTopWidth: 1, borderLeftWidth: 1, }} > </View > <View onTouchStart={() => this.stroke("Fs")} onTouchEnd={() => this.stop("Fs")} style={{ backgroundColor: this.state.colorFs, height: 100, width: 32, borderTopWidth: 1, }} > </View > <View style={{ backgroundColor: "white", height: 100, width: 16, borderTopWidth: 1, }} > </View > <View onTouchStart={() => this.stroke("Gs")} onTouchEnd={() => this.stop("Gs")} style={{ backgroundColor: this.state.colorGs, height: 100, width: 32, borderTopWidth: 1, }} > </View > <View style={{ backgroundColor: "white", height: 100, width: 16, borderTopWidth: 1, }} > </View > <View onTouchStart={() => this.stroke("As")} onTouchEnd={() => this.stop("As")} style={{ backgroundColor: this.state.colorAs, height: 100, width: 32, borderTopWidth: 1, }} > </View > <View style={{ backgroundColor: "white", height: 100, width: 32, borderRightWidth: 1, borderTopWidth: 1, }} > </View > </View> <View style={{ flexDirection : "row", alignItems: "center", justifyContent: "center" }}> <View onTouchStart={() => this.stroke("C")} onTouchEnd={() => this.stop("C")} style={{ backgroundColor: this.state.colorC, height: 100, width: 48, borderBottomWidth: 1, borderLeftWidth: 1 }} > </View > <View onTouchStart={() => this.stroke("D")} onTouchEnd={() => this.stop("D")} style={{ backgroundColor: this.state.colorD, height: 100, width: 48, borderBottomWidth: 1, borderLeftWidth: 1 }} > </View > <View onTouchStart={() => this.stroke("E")} onTouchEnd={() => this.stop("E")} style={{ backgroundColor: this.state.colorE, height: 100, width: 48, borderBottomWidth: 1, borderLeftWidth: 1 }} > </View > <View onTouchStart={() => this.stroke("F")} onTouchEnd={() => this.stop("F")} style={{ backgroundColor: this.state.colorF, height: 100, width: 48, borderBottomWidth: 1, borderLeftWidth: 1 }} > </View > <View onTouchStart={() => this.stroke("G")} onTouchEnd={() => this.stop("G")} style={{ backgroundColor: this.state.colorG, height: 100, width: 48, borderBottomWidth: 1, borderLeftWidth: 1 }} > </View > <View onTouchStart={() => this.stroke("A")} onTouchEnd={() => this.stop("A")} style={{ backgroundColor: this.state.colorA, height: 100, width: 48, borderBottomWidth: 1, borderLeftWidth: 1 }} > </View > <View onTouchStart={() => this.stroke("B")} onTouchEnd={() => this.stop("B")} style={{ backgroundColor: this.state.colorB, height: 100, width: 48, borderBottomWidth: 1, borderLeftWidth: 1, borderRightWidth: 1 }} > </View > </View> </View> </View> ); } } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5FCFF', flexDirection: "row", }, });鍵盤の表示
Viewで鍵盤を作ってきます。
鍵盤に触れた時に音が鳴り、離した時に音が鳴り終わるように、OnTouchStartとOnTouchEndに処理を書いていきます。
また、鍵盤に触れている時にbackgroundColorを変えたいので、stateにしておきます。constructor( props ){ super( props ); this.state = { colorC : "white", colorCs : "black", ... } ... } render() { ... <View onTouchStart={() => this.stroke("Cs")} onTouchEnd={() => this.stop("Cs")} style={{ backgroundColor: this.state.colorCs, height: 100, width: 32, borderTopWidth: 1, borderLeftWidth: 1,}} > </View > .... }音を鳴らす(鍵盤に触れる)
コンストラクタで音源を読み込んでおきます。
鍵盤に触れた時、鍵盤のbackgroundColorをstateで変更して、音を鳴らします。
音が鳴らないことがあるため、setTimeoutが必要です。constructor( props ){ super( props ); ... this.sound = {}; const soundList = [ "C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B" ] soundList.forEach(note => { this.sound[note] = new Sound( note + ".mp3", Sound.MAIN_BUNDLE, error => { if ( error ) { console.log("failed to load the sound.", error); } }) }); } stroke ( note ) { switch ( note ) { case "C": this.setState({ colorC: "rgba(1, 1, 1, 0.1)" }) break; ... } setTimeout( () => { this.sound[note].play(success => { if ( success ) { console.log("successfully finished playing."); } else { console.log("failed to play the sound."); } }); }, 1); }音を止める(鍵盤から指を離す)
同様にstateで鍵盤の色を元に戻します。
音をいきなり止めてしまうとブツッと鳴ってしまうので、徐々にボリュームを下げてから止めるようにします。
止めた後は、次に音を鳴らす時のためにボリュームを元に戻しておきます。stop( note ) { switch ( note ) { case "C": this.setState( { colorC: "white" } ) break; ... } setTimeout( () => { for (let i=0; i<2000; i++) { this.sound[note].setVolume( 1.0-i/2000. ); } this.sound[note].stop(); this.sound[note].setVolume( 1.0 ); }, 1 ) }まとめ
React Nativeでピアノを実装しました。
パフォーマンスは良いとは言えませんが、アプリにちょっとした鍵盤を実装したい場合(?)に試してみてください!