20190302のAndroidに関する記事は11件です。

App Links実装手順

App Links実装手順

App Links Assistantに従って設定

  1. 「Tools」の「App Links Assistant」を選択する。
  2. 「App Links Assistant」の画面が出ます。
  3. ①~④のステップをやっていきます。

①Add URL intent filters

  • 「App Links Assistant」の「Open URL Mapping Editor」をクリックする。

URL Mapping Editor

  1. 「Host」にドメインを記述する。
  2. 「pathPattern」にドメイン以降のパスを記述する。
  3. 「Activity」に上記のパスでアプリが起動した際に、どのActivityを開くかを指定
例)
1. Host ->「https://nwills.github.io」
2. pathPattern -> 「/AppLinksTest/」
3. Activity -> 「.MainActivity」
  1. 「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

  1. URLで起動するActivityを選択
  2. 「Insert Code」をクリック
  3. 自動的にコードが生成される
  4. 利用用途に応じて、その後の処理を記載

③Associate website

  • 「App Links Assistant」の「Open Digital Asset Links File Generation」をクリックする。

Associate website

  1. 「Site domain」に先ほど指定したドメインが入っているかを確認
    入っていなければ修正する。
  2. 「Application ID」に自分のアプリのパッケージ名が入ってることを確認する。
    入っていなければ修正する。
  3. 「Signing config」または「Select keystore file」を選択する。
    • 「Signing config」はデバッグ用
      「debug」を指定する。
    • 「Select keystore file」はリリース用
      APK作成時に使用するjksファイルを使用する。
例)
3. 「Signing config」 ->「debug」
  1. 「Generate Digital Asset LInks file」をクリックする。
  2. 「Preview」に「者256」などの情報が表示される。
  3. 「Save file」をクリックし、「assetlinks.json」ファイルをわかりやすい場所に保存する。
  4. サーバーに「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
  1. 「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

  1. 「URL」にテストしたいURLを入力する。
    例) 1. URL ->「https://nwills.github.io/AppLinksTest/」
  2. 入力後「Enter」キーを押下すると「Select Device Target」画面がでる。
    出ない場合は「URL」を見直す。
  3. 実行したい実機またはエミュレータを選択する。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「AndroidXでのバックキー制御」について、OnBackPressedCallbackの使い方・注意点を補足

はじめに

@androhiさんの記事「AndroidXでのバックキー制御」を読んで、これは使わない手はない!と思いプロダクトに導入し、その時にハマった点や補足を皆様に共有したいと思い記事にしました。
@androhiさんに許可を頂いてます。ありがとうございます!)

ブログの内容を掻い摘んで説明すると、
Activity#onBackPressed()でやっていたバックキーの制御を OnBackPressedCallbackで出来るようにしてFragmentでのバックキー制御を簡単にしよう!というやつです。

インストール

  • 利用にはandroidxのappcompat:1.1.0とactivity:1.0.0が必要です。まずはこれらをインストールします。
app/build.gradle
dependencies {
    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にダウングレードしています。

使い方

  • まずActivityはandroidxのAppCompatActivityを継承させます。
MainActivity.kt
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {

// 略
SampleFragment.kt
class 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ではありますが、前述のバージョンであれば動作も安定しており、コードも非常にシンプルになるのでプロダクトで採用しても問題ないかなと思ってます。気になった方はぜひ使ってみてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「AndroidXでのバックキー制御」について、OnBackPressedCallbackの使い方・注意点補足

はじめに

@androhiさんの記事「AndroidXでのバックキー制御」を読んで、これは使わない手はない!と思いプロダクトに導入し、その時にハマった点や補足を皆様に共有したいと思い記事にしました。
@androhiさんに許可を頂いてます。ありがとうございます!)

ブログの内容を掻い摘んで説明すると、
Activity#onBackPressed()でやっていたバックキーの制御をAndroidXからは OnBackPressedCallbackで出来るようにしてFragmentでの利用を簡単にしよう!というやつです。

インストール

  • 利用にはandroidxのappcompat:1.1.0とactivity:1.0.0が必要です。まずはこれらをインストールします。
app/build.gradle
dependencies {
    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にダウングレードしています。

使い方

  • まずActivityはandroidxのAppCompatActivityを継承させます。
MainActivity.kt
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {

// 略
SampleFragment.kt
class 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ではありますが、前述のバージョンであれば動作も安定しており、コードも非常にシンプルになるのでプロダクトで採用しても問題ないかなと思ってます。気になった方はぜひ使ってみてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「AndroidXでのバックキー制御」について、OnBackPressedCallbackの使い方や注意点補足

はじめに

@androhiさんの記事「AndroidXでのバックキー制御」を読んで、これは使わない手はない!と思いプロダクトに導入し、その時にハマった点や補足を皆様に共有したいと思い記事にしました。
@androhiさんに許可を頂いてます。ありがとうございます!)

ブログの内容を掻い摘んで説明すると、
Activity#onBackPressed()でやっていたバックキーの制御をAndroidXからは OnBackPressedCallbackが使えるようになったので、これを使ってFragmentでの実装を簡単にしよう!というやつです。

インストール

  • 利用にはandroidxのappcompat:1.1.0とactivity:1.0.0が必要です。まずはこれらをインストールします。
app/build.gradle
dependencies {
    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にダウングレードしています。

使い方

  • まずActivityはandroidxのAppCompatActivityを継承させます。
MainActivity.kt
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {

// 略
SampleFragment.kt
class 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ではありますが、前述のバージョンであれば動作も安定しており、コードも非常にシンプルになるのでプロダクトで採用しても問題ないかなと思ってます。気になった方はぜひ使ってみてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DroidKaigiアプリのSingle Activity(Fragment)でハマったところメモ

他にもSingle Activity関連はありましたが、とりあえず大きなところでいうとこれが勉強になったので書いておきます。

セッションのリストに戻った時にスクロールの位置が戻る問題

結論から言うとFragmentのライフサイクルへの理解が足りませんでした。

起こった問題

セッションの詳細の画面から、セッションのリストに戻った時にスクロールの位置が戻る問題がありました。
https://github.com/DroidKaigi/conference-app-2019/issues/33

問題があった当時のコードはこれです。savedInstanceStateがないときにcommitしています。これの何が問題なのでしょうか?

https://github.com/DroidKaigi/conference-app-2019//blob/468db8acfd4912cafae3b2f745af0a225e288313/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt より

    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してしまうためです。

そこでこういうPRが届きました。(https://github.com/Akihiroooo/conference-app-2019/blob/07ee9c585d39c33f7872f3055dd63d89de5c7fee/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt#L127 )

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
        ) {

最終的な解決策

ここで自分がうまく動いた!とツイートします
そこで以下のツッコミが入ります

ここで以下のことを学びました。
バックスタックから戻ってきたときはonCreateは呼ばれずonCreateViewやonActivityCreatedは呼ばれる。
孫フラグメントを作るときはFragmentのonCreateでcommit()をすることで、フラグメントが無駄にまた作られることを防げる。

image.png
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#L91

    override 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()
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DroidKaigiのアプリのSingle Activity(Fragment)でハマったところメモ

他にもSingle Activity関連はありましたが、とりあえず大きなところでいうとこれが勉強になったので書いておきます。

セッションのリストに戻った時にスクロールの位置が戻る問題

結論から言うとFragmentのライフサイクルへの理解が足りませんでした。

起こった問題

セッションの詳細の画面から、セッションのリストに戻った時にスクロールの位置が戻る問題がありました。
https://github.com/DroidKaigi/conference-app-2019/issues/33

問題があった当時のコードはこれです。savedInstanceStateがないときにcommitしています。これの何が問題なのでしょうか?

https://github.com/DroidKaigi/conference-app-2019//blob/468db8acfd4912cafae3b2f745af0a225e288313/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt より

    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してしまうためです。

そこでこういうPRが届きました。(https://github.com/Akihiroooo/conference-app-2019/blob/07ee9c585d39c33f7872f3055dd63d89de5c7fee/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt#L127 )

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
        ) {

最終的な解決策

ここで自分がうまく動いた!とツイートします
そこで以下のツッコミが入ります

ここで以下のことを学びました。
バックスタックから戻ってきたときはonCreateは呼ばれずonCreateViewやonActivityCreatedは呼ばれる。
孫フラグメントを作るときはFragmentのonCreateでcommit()をすることで、フラグメントが無駄にまた作られることを防げる。

image.png
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#L91

    override 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()
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DroidKaigiのSingle Activity(Fragment)でハマったところメモ

他にもSingle Activity関連はありましたが、とりあえず大きなところでいうとこれが勉強になったので書いておきます。

セッションのリストに戻った時にスクロールの位置が戻る問題

結論から言うとFragmentのライフサイクルへの理解が足りませんでした。

起こった問題

セッションの詳細の画面から、セッションのリストに戻った時にスクロールの位置が戻る問題がありました。
https://github.com/DroidKaigi/conference-app-2019/issues/33

問題があった当時のコードはこれです。savedInstanceStateがないときにcommitしています。これの何が問題なのでしょうか?

https://github.com/DroidKaigi/conference-app-2019//blob/468db8acfd4912cafae3b2f745af0a225e288313/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt より

    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してしまうためです。

そこでこういうPRが届きました。(https://github.com/Akihiroooo/conference-app-2019/blob/07ee9c585d39c33f7872f3055dd63d89de5c7fee/feature/session/src/main/java/io/github/droidkaigi/confsched2019/session/ui/SessionPageFragment.kt#L127 )

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
        ) {

最終的な解決策

ここで自分がうまく動いた!とツイートします
そこで以下のツッコミが入ります

ここで以下のことを学びました。
バックスタックから戻ってきたときはonCreateは呼ばれずonCreateViewやonActivityCreatedは呼ばれる。
孫フラグメントを作るときはFragmentのonCreateでcommit()をすることで、フラグメントが無駄にまた作られることを防げる。

image.png
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#L91

    override 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()
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

さまざまなCoroutineDispatcherの指定方法メモ

概要

Kotlin CoroutineはDispatcherによって実行スレッドを制御する。そのDispatcherを切り替えることで、コルーチンのコードをテスト可能にできます。

DroidKaigiではCoroutinePluginによるやり方を作成して利用していました。そこにAppCoroutineDispatcherを使うPRが来ました。

3つの指定方法があるのでそれぞれ見ていきましょう。
CoroutinePlugin
AppCoroutineDispatcher
kotlinx-coroutines-test

CoroutinePluginによるやり方

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

...

https://github.com/DroidKaigi/conference-app-2019/blob/master/corecomponent/androidcomponent/src/main/java/io/github/droidkaigi/confsched2019/ext/CoroutinePlugin.kt

テストでは 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を依存関係に追加して、これだけです。

https://github.com/Kotlin/kotlinx.coroutines/tree/master/core/kotlinx-coroutines-test#using-in-your-project より

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でやってみて、何か問題があれば変えていくのがいいかなと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

リリース前に知っておきたかった 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っていますが、課金の実装で一番大切なのは公式リファレンスを読みまくることです。
大変わかりづらく書かれていたりしますが、何度も何度も読むことをおすすめします。
ちゃんと読むと必要なことは書かれています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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/Csv2

Apache Commons CSV

カンマで分解して、文字列の配列にして、並び順で取得する。

https://commons.apache.org/proper/commons-csv/

サンプルプログラムは、ここに
https://github.com/ohwada/Android_Samples/tree/master/Csv3

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React Nativeでピアノを作る

はじめに

React Nativeでピアノを作ったのでソースコードの説明をします。
Githubのリポジトリはこちらです。

プロジェクトの立ち上げ

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上でプロジェクトファイルの中に音源をドラッグ&ドロップします。
Screen Shot 2019-02-27 at 18.29.57.png

Androidでは音源を android/app/src/main/res/raw のフォルダに入れます。

mkdir android/app/src/main/res/raw
cp audio/* android/app/src/main/res/raw/

ソースコード

ソースコードは以下になります。
順番に説明していきます。

App.jsx
import 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でピアノを実装しました。
パフォーマンスは良いとは言えませんが、アプリにちょっとした鍵盤を実装したい場合(?)に試してみてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む