phicdy devlog

Androidアプリ開発やその他技術系の記事をたまに書きます

AndroidとかiOSとかモバイル多め。その他技術的なことも書いていきます。

FragmentでViewPagerを使うときにFragmentPagerAdapterに渡すFragmentManagerの注意点

嵌ったのでメモ。

BottomNavigationBar のタブの Fragment 内で ViewPager を使っていて、そのタブから別タブに切り替えて再びそのタブに戻ってきたとき、FragmentPagerAdapter#getItem() が呼ばれず、 ViewPager 内が再読込みされない現象が起きた。

調査の結果、 FragmentPagerAdapterインスタンスを作るときに渡す FragmentManagerFragmentActivity#getSupportFragmentManager() だと起きる。

そもそも FragmentPagerAdapter はメモリ上に全てViewPagerの子Fragment達を保持する。そのためタブを切り替えて戻ってきたときには以前の Fragment が使い回される。

FragmentPagerAdapter  |  Android Developers

実際に FragmentPagerAdapter#getItem() が呼ばれる付近のコードを見てみると確かに Fragment が保存されている場合は FragmentPagerAdapter#getItem() が呼ばれない。

public abstract class FragmentPagerAdapter extends PagerAdapter {
    ...
    
    @NonNull
    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        if (this.mCurTransaction == null) {
            this.mCurTransaction = this.mFragmentManager.beginTransaction();
        }

        long itemId = this.getItemId(position);
        String name = makeFragmentName(container.getId(), itemId);
        Fragment fragment = this.mFragmentManager.findFragmentByTag(name);
        if (fragment != null) {
            this.mCurTransaction.attach(fragment);
        } else {
            fragment = this.getItem(position);
            this.mCurTransaction.add(container.getId(), fragment, makeFragmentName(container.getId(), itemId));
        }

        if (fragment != this.mCurrentPrimaryItem) {
            fragment.setMenuVisibility(false);
            fragment.setUserVisibleHint(false);
        }

        return fragment;
    }
    ...
}

BottomNavigationView のタブ切り替えでは Activity は生きたままなのでその FragmentManagerViewPager内の子Fragment達を保持したままになり、 FragmentPagerAdapter#getItem() が呼ばれない。


↓の回答を見ると Fragment#getChildFragmentManager()FragmentPagerAdapterに渡すといいとある。 stackoverflow.com

この場合、保持される ViewPager 内の子Fragment達はその親FragmentFragmentManagerで管理される。タブを切り替えたときには通常、OnNavigationItemSelectedListener#onNavigationItemSelected()で親Fragmentの新しいインスタンスを作るので、FragmentManagerもリセットされ、 FragmentPagerAdapter#getItem() が呼ばれて再読込みが始まるようになる。

ViewPager のスワイプが起きたときは 親 Fragment が生きている限り、 FragmentManager 内で子 Fragment 達が管理されるので、通常通り効率的にスワイプできるようになる。

ちなみにこの問題は子Fragmentをメモリ上に保持しないFragmentStatePagerAdapterでは発生しない。これはページ数が多いなどメモリ保持するには重い場合に使う。

FragmentStatePagerAdapter  |  Android Developers

↑StackOverflowの回答の中で一番+が多い案は FragmentStatePagerAdapterを使う案である。この場合はメモリ上に子Fragmentを保持しないのでスワイプ時の挙動が変わる。どちらが良いかはページ数などの状況で決めるといいと思う。

【Android】続・PreferenceFragmentCompatを使った設定画面

以下の記事を昔に書いたが、内容が古くなってきたようなので書き直す。

phicdy.hatenablog.com

Androidの設定画面について

  • AndroidではAPI1から設定画面を生成してくれる PreferenceActivity が用意されている
  • Android 3.0で各設定画面をFragmentに分けるための PreferenceFragment が追加された
  • Android 4.0では PreferenceActivity が拡張され、 PreferenceFragment と組み合わせることでハンドセットでは1画面、タブレットでは2画面の設定画面を自動的に作成できる
  • API 28で PreferenceFragment はdeprecatedになり PreferenceFragmentCompat が推奨となった。PreferenceActivity も使う必要はなく、 AppCompatActivity を使用する。

準備

PreferenceFragmentCompat を使用するためにはサポートライブラリが必要。AndroidX版もあるがひとまず28.0.0で書く。

    def supportLibVer = '28.0.0'
    implementation "com.android.support:appcompat-v7:$supportLibVer"
    implementation "com.android.support:preference-v7:$supportLibVer"

テレビ用には preference-leanback-v17 があるがここでは説明しない。

設定項目

クラス 説明
v7.preference.CheckBoxPreference オン/オフの設定項目
v14.preference.SwitchPreference オン/オフの設定項目
v7.preference.ListPreference リストの中から1つ選ぶ設定項目
v14.preference.MultiSelectListPreference リストの中から複数を選ぶ設定項目
v7.preference.EditTextPreference 入力からの設定

設定画面

Activity

class SettingsActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        supportFragmentManager
                .beginTransaction()
                .replace(android.R.id.content, SettingsFragment())
                .commit()
    }
}

PreferenceFragmentCompat

  • onCreatePreferences()addPreferencesFromResource() から設定画面を定義したXMLを読み込む
  • 必要であれば各設定項目の初期値等を設定する
  • XMLで設定した Preference を取得するには android:key を使い、PreferenceFragmentCompat#findPreference() で行う
class SettingFragment : PreferenceFragmentCompat() {

    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        addPreferencesFromResource(R.xml.setting_fragment)
    }
}

ヘッダー

<preference-header> によるヘッダー作成から<Preference android:fragment=""> で指定するようになった。設定するだけでは遷移してくれず、 Activity 側で PreferenceFragmentCompat.OnPreferenceStartFragmentCallback を実装して onPreferenceStartFragment() でハンドリングしてあげる必要がある。

<preference-headers xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- These settings headers are only used on tablets. -->

    <header
            android:fragment="com.phicdy.preferencesample.GeneralPreferenceFragment"
            android:title="@string/pref_header_general"
            android:icon="@drawable/ic_info_black_24dp"/>

    <header
            android:fragment="com.phicdy.preferencesample.NotificationPreferenceFragment"
            android:title="@string/pref_header_notifications"
            android:icon="@drawable/ic_notifications_black_24dp"/>

    <header
            android:fragment="com.phicdy.preferencesample.DataSyncPreferenceFragment"
            android:title="@string/pref_header_data_sync"
            android:icon="@drawable/ic_sync_black_24dp"/>

</preference-headers>

<PreferenceScreen
        xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- These settings Preferences are only used on tablets. -->

    <Preference
            android:key="aaa"
            android:fragment="com.phicdy.preferencesample.GeneralPreferenceFragment"
            android:title="@string/pref_header_general"
            android:icon="@drawable/ic_info_black_24dp"/>

    <Preference
            android:key="bbb"
            android:fragment="com.phicdy.preferencesample.NotificationPreferenceFragment"
            android:title="@string/pref_header_notifications"
            android:icon="@drawable/ic_notifications_black_24dp"/>

    <Preference
            android:key="ccc"
            android:fragment="com.phicdy.preferencesample.DataSyncPreferenceFragment"
            android:title="@string/pref_header_data_sync"
            android:icon="@drawable/ic_sync_black_24dp"/>

</PreferenceScreen>

設定画面でそんなに需要があるのかわからないが、切替時に処理を入れたり、アニメーションを入れることができる。

class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {

    // AndroidX版だとNon-Null
    override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat?, pref: Preference?): Boolean {
        // AndroidX版だとval fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment, args) で書ける
        val fragment = when (pref?.fragment) {
            GeneralPreferenceFragment::class.java.name -> GeneralPreferenceFragment()
            NotificationPreferenceFragment::class.java.name -> NotificationPreferenceFragment()
            DataSyncPreferenceFragment::class.java.name -> DataSyncPreferenceFragment()
            else -> throw InvalidParameterException("Invalid fragment, fragment name is " + pref?.fragment)
        }
        fragment.setTargetFragment(caller, 0)

        supportFragmentManager.beginTransaction()
            .replace(android.R.id.content, fragment)
            .addToBackStack(null)
            .commit()
        return true
    }
}

PreferenceFragmentCompatから読み込む設定XML

  • PreferenceFragmentCompat から読み込むXMLのトップは PreferenceScreen でなければならない
  • Preference には android:key を設定する。これが各 Preference を特定するIDとなる。
  • カテゴリを設定する場合は PreferenceCategory を使う。 android:key は必須ではない。
  • ListPreference の場合、選択候補に表示されるリスト( android:entries )と実際の値のリスト( android:entryValues )を設定する必要がある
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" >
    <PreferenceCategory android:title="Category Title" >
        <ListPreference
            android:title="Title"
            android:key="@string/key_list_preference"
            android:entries="@array/entry_sample"
            android:entryValues="@array/entry_sample_values"/>
    </PreferenceCategory>

</PreferenceScreen>

その他の属性は以下を参照

http://developer.android.com/reference/android/preference/Preference.html

array.xml

ListPreference で使う値を定義する

<resources>
    <array name="entry_sample">
        <item>@string/entry1</item>
        <item>@string/entry2</item>
    </array>

    <string-array name="entry_sample_values">
        <item>1</item>
        <item>0</item>
    </string-array>

</resources>

初期値設定

XMLで初期値を設定する場合はandroid:defaultValueに設定する。SharedPreference から保存した値を読み込んでそれに応じて初期値を設定する場合など、コードの中で初期値を設定を行う場合は Preference によって設定方法が異なる

SwitchPreference

setChecked() で行う

val switchPref = findPreference(getString(R.string.key_internal_browser)) as SwitchPreference
switchPref.setChecked(true)

ListPreference

  • ListPreferenceentryValuesstring-array の各itemが数字だと setDefaultValue() が動かなかったので setValueIndex() を使うことで解決
  • entryValuesinterger-array にすると NullPointerException で落ちてしまう

イベントハンドリング

Preference.OnPreferenceChangeListenerSharedPreferences.OnSharedPreferenceChangeListener の2種類がある。 Preferennce はデフォルトで値を SharedPreferences に保存する。 PreferenceDataStore を使うことで好きな場所に保存することもできる。

Preference.OnPreferenceChangeListener は値が変わる前に呼ばれるのでバリデーションなどに使う。 PreferenceDataStore を使う場合はここでハンドリングして値を保存する。 SharedPreferences.OnSharedPreferenceChangeListenerSharedPreferences のみ使用でき、値が保存された後に呼ばれる。

参考

Clean ArchitecutureにおけるKotlin coroutinesの処理と責務分け

自分の中で混乱してきたので整理する。

環境

  • Kotlin 1.2.71
  • Kotlin Coroutines 0.30.1

本題

以下のようなClean Architecuteベースの設計のアプリがあるとする。

f:id:phicdy:20181011225951p:plain

このときKotlin Coroutineを使って非同期処理を行ってUIを更新するとする。 各クラスの責務を分けると以下のようになる。

クラス 役割 やらない・知らない スレッド
Activity/Fragment UIを更新する。インターフェースをPresenterに提供し、イベントの処理をPresenterに移譲する。 UIロジックを持たない。Presenterが何をするかは知らない UIスレッド
Presenter UseCaseに処理を依頼し処理結果を待つ。その結果に応じてUIを更新する UIの実態を知らない。UseCaseがどう処理を実行しているか知らない UIスレッド
UseCase Repository/Apiからデータを取得して処理を行う Repository/Apiがどこからデータを取得しているか知らない。 UIスレッド/ワーカースレッド
Repository/Api 外部・内部からデータを取得する 返したデータがどう使われるかは知らない ワーカースレッド

PresenterはUseCaseがどう処理を実行するか知らないし、UseCaseもRepository/Apiがどう処理をするかを知らない。めちゃくちゃ早いCPUで処理をメインスレッド上で5秒以内に実行しているかもしれないし、ワーカースレッドで待ち合わせをして結果を返しているかもしれない。現実的にはUseCaseがRepository/Apiの結果を待ち合わせをし、処理を行って結果を返す。

Activity/FragmentはAndroid SDKに基づくUIスレッドで実行するコルーチンビルダーを呼ぶ。

class MyActivity: AppCompatActivity(), MyView, CoroutineScope {

    private lateinit var presenter: MyPresenter
    private val job = Job()
    override val coroutineContext: CoroutineContext
        get() = job + Dispatchers.Main

    override fun onCreate() {
        presenter = MyPresenter(this)
        launch(context = coroutineContext) {
            presenter.onCreate()
        }
    }
    
    override fun showSuccess() {
        ...
    }
    
    override fun showFail() {
        ...
    }
}

Presenterでは呼び出し元のCoroutineScopeで処理を実行し、結果に応じてUIを更新する。UseCaseがどのスレッドで処理をしているかは意識しない。

class MyPresenter(private view: MyView) {
    suspend fun onCreate() = corountineScope {
        val result = useCase.fetchAndExecute()
        if (result.isSuccess()) {
            view.showSuccess()
        } else {
            view.showFail()
        }
     }
}

UseCaseは中でRepositoryからデータを取得する。Repositoryがどのスレッドで処理をしているかは意識しない。呼び出し側のUseCaseは非同期を意識せずに同期的に書くことができる。返ってきた結果を別メソッドで処理するが、ここでもスレッドを意識しない。実際には withContext() でワーカースレッド上で処理される。

class MyUseCase {
    suspend fun fetchAndExecute(): Result = corountineScope {
        val data = repository.fetchData()
        return onFetch(data)
    }
    
    private suspend fun onFetch(data: List<Model>): Result = coroutineScope {
        return@coroutineScope withContext(Dispatchers.Default) {
                // なんか重たい処理
                return@withContext ...
            }
        }
    }
}

Repository/Apiでは withContext() でIOスレッドに切り替えて処理を返す。

class MyRepository {
    suspend fun fetchData(): List<Model> = coroutineScope {
        return@coroutineScope withContext(Dispatchers.IO) {
            // データベース等からなにかを取得する処理
            return@withContext ...
        }
    }
}

Presenterのテスト

テストではUseCaseとViewをモックし、 runBlocking 内で実行することでAndroid SDKからの依存をなくし、同期的に実行する。

@Test
fun `when fetch and execute succeeds then show success`() = runblocking { 
    val view = mock(MyView::class.java)
    val useCase = mock(MyUseCase::class.java)
    val presneter = MyPresneter(view)
    `when`(useCase.fetchAndExecute()).thenReturn(...)
    presenter.useCase = useCase
    presenter.onCreate() }
    verify(view, times(1)).showSuccess()
}

おわりに

0.26.1の破壊的変更でいろいろ調べていたら時間がかかってしまった。もうそろそろKotlin 1.3が出てCoroutinesが1.0になる。また破壊的変更が来ないことを願う。

Kotlin Fest 2018に参加した

2018/8/25に品川で開催されたKotlin Fest 2018に参加してきました。

kotlin.connpass.com

f:id:phicdy:20180825132036j:plain

f:id:phicdy:20180825132200j:plain

飲み物とか食事。パンプディング美味しかった。

f:id:phicdy:20180825132327j:plain

f:id:phicdy:20180825132720j:plain

mixiさんブースでやってKotlinクイズの景品。7問中3問正解でプランニングポーカーもらいました。

f:id:phicdy:20180825132358j:plain

以下参加したセッションの感想

Kotlin で改善する Android アプリの品質

KotlinFest.pdf - Speaker Deck

非技術系の上の人にJava -> Kotlin を説得しにくいよねという話から始まり、Effective JavaにならってKotlinでの言語仕様での対応が聞けた。 kotlinにするだけで自然にEffective Javaのエッセンスを導入できる!というのが確かに・・・となってそれだけでもコストをかけて移行する価値があると感じた。Javaのつらみを書かなくていいし・・・。Effective Java読み直そう。

Kotlinアプリのリファクタリングポイント

Refactoring point of Kotlin application

nullが何を表しているかを考えることは重要だなと思った。状態数の話がまさに今直面している問題で、名前をつけたりすることを考えるきっかけになりそう

Kotlin linter

kotlin linter - Speaker Deck

カスタムルールは自然言語処理的なつらさがありそう。PsiViewerでだいぶ楽になる。カスタムフォーマットは更につらそう。android-lintが型が見れて強い。

Kotlin コルーチンを理解しよう

Kotlin コルーチンを 理解しよう - Speaker Deck

コルーチンを全然理解しないまま使っていたので、この発表でかなりすっきりした。実際にはステートマシンに変換されていることや直列・並列実行、待ち合わせなどがどう実現されているかの理解がかなり深まったと思う。

LT大会

3分短いなーと。個人的には5分くらいあってもよかったんじゃないかと。

おわりに

まずKotlinやっている人こんなにいるんだな〜というのが感想だった。Androidだけじゃなくサーバサイドの人も増えてきている印象だった。Java -> Kotlinに変換する話題が多かったのはなんだろう・・・まだ移行期で完全に移行できていない、もしくは上の人を説得できなくてJavaのままのプロダクトがまだまだあるのかなと感じました。 2019はまだ未定とのことですが、どのセッションも面白い話が多くて今日からでも使えそうな考え方や知見が得られたのでぜひ来年もやってほしい!

会場設備もよく、飲み物や軽食が充実していた。これだけの人数だと仕方ないけど立ち見はつらそうだった。 わいわいした雰囲気で楽しかったので、2019があればまた参加しようと思う。

【Android】他のアプリの上に重ねて表示まとめ

Androidの世界では基本的に1アプリのみが前面に出る。 しかし android.permission.SYSTEM_ALERT_WINDOW を宣言することで他のアプリが前面の場合でも Viewを表示できる

Android 6.0未満

制限はなく、 android.permission.SYSTEM_ALERT_WINDOW を宣言するだけで利用可能

Android 6.0以上

攻撃者でもこの権限を悪用し、ランサムウェアなどで常に画面表示することができてしまうため、ユーザの明示的な許可が必要になった。 ただしAndroid 6.0.1以上でGoogle Playアプリ6.0.5以上が入っている場合、Google Playからインストールしたアプリは「他のアプリの上に重ねて表示」の権限がデフォルトオンになる。恐らくGoogle的にはGoogle Playにあるアプリは安全という前提がある。 https://stackoverflow.com/questions/36016369/system-alert-window-how-to-get-this-permission-automatically-on-android-6-0-an/36019034

この権限はRuntime Perissionではなく設定の奥にあるのでIntentを飛ばし、 onActivityResult() でハンドリングする。

val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMIISSION)

設定が有効かどうかは Settings.canDrawOverlays() を使う。

Settings  |  Android Developers

Android 8.0以上

targetSdkVersion 26以上にてViewを表示するときの指定でTYPE_APPLICATION_OVERLAYを使わなければいけなくなった。これによりシステムUIなどの重要なViewの上に描画できなくなり、アプリインストール時に権限の上にViewを表示して隠すなどの攻撃が不可能になった。 https://developer.android.com/about/versions/oreo/android-8.0-changes

Android 8.0では権限取得にバグがあり、変更がすぐに反映されないので監視する。 https://stackoverflow.com/questions/46173460/why-in-android-o-method-settings-candrawoverlays-returns-false-when-user-has

Android 8.1では修正済み。https://issuetracker.google.com/issues/66072795

if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) {
    val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
    appOpsManager.startWatchingMode(AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW, packageName, object : AppOpsManager.OnOpChangedListener {
        override fun onOpChanged(op: String?, packageName: String?) {
            if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) {
                appOpsManager.stopWatchingMode(this)
                onOverlayPermissionResult(Settings.canDrawOverlays())
            }
        }
    })
}

【Android】Google AnalyticsからFirebaseへの乗り換え

Firebaseを使ってみたかったので置き換えてみた。

プロジェクトのbuild.gradle

    dependencies {
         ...
        classpath 'com.google.gms:google-services:4.0.0'
    }

アプリのbuild.gradle

dependencies {
    ...
    implementation "com.google.firebase:firebase-core:16.0.1"
}

apply plugin: 'com.google.gms.google-services'

イベントを送るクラスの置き換え

    companion object DefaultTracker {
-        fun setUp(context: Context): Tracker {
-            val analytics = GoogleAnalytics.getInstance(context)
-            return analytics.newTracker(R.xml.global_tracker)
+        fun setUp(context: Context): FirebaseAnalytics {
+            return FirebaseAnalytics.getInstance(context)

必要ないコードの削除

スクリーンは screen_view というイベントで勝手に送ってくれるので削除

-        tracker.setScreenName(screenName)
-        tracker.send(HitBuilders.ScreenViewBuilder().build())

クリックイベントの置き換え

Firebase側である程度のイベントを用意してくれている。FirebaseAnalytics.Param.CONTENT_TYPEFirebaseAnalytics.Param.VALUE で色々指定できそうなのでヘルパーメソッドを作った。

    fun sendButtonEvent(itemId: String) {
        sendEvent(itemId, "button", FirebaseAnalytics.Event.SELECT_CONTENT)
    }

    fun sendSettingEvent(setting: String, value: String) {
        sendEvent(setting, "setting", FirebaseAnalytics.Event.SELECT_CONTENT, value)
    }

    private fun sendEvent(itemId: String, contentType: String, event: String, value: String = "") {
        if (BuildConfig.DEBUG) return
        val params = Bundle().apply {
            putString(FirebaseAnalytics.Param.ITEM_ID, itemId)
            putString(FirebaseAnalytics.Param.CONTENT_TYPE, contentType)
            putString(FirebaseAnalytics.Param.VALUE, value)
        }
        tracker.logEvent(event, params)
    }

デバッグ

コンソールでDebugViewを開く。あとは端末を繋いで adb shell setprop debug.firebase.analytics.app <package_name> を実行すればデバッグができる。

support.google.com

ErgoDoxのキースイッチを交換する

最近どうもdが押しにくく、ファームウェアをアップデートしても直らなかったため、キースイッチを交換することにしました。

okapies.hateblo.jp

を参考にやりました。

必要なもの

Cherry MX Black Keyswitch - Linear - 5 pack by Cherry

  • キースイッチを引き抜く器具 ($6.99+送料$15=$21.99=2403円) *1ドル約109円として計算

MX Switch Top Removal Tool (Set of 2) by Mechanical Keyboards Inc

当初↑の記事を読んで100均のクリップを改造すればOKと書いてあったので、キースイッチを引き抜く器具は買っていなかったが、私には難しすぎたので結局注文。送料がもったいない・・・ 海外発送なので届くまでに約1ヶ月ほどかかるので注意。

手順

1.キーキャップを取る

2 キースイッチを取る

f:id:phicdy:20180608194313g:plain

3 ばねとばねについているパーツを取る

f:id:phicdy:20180608194604j:plain

4 新しいキースイッチを分解する

5 新しいキースイッチのばねとパーツをつけ、カバーをする

おわりに

100均のクリップを改造したものでは1時間かかってもキースイッチが取れなかったが、器具を買ってやったら1分でできたので素直に器具を買うことをおすすめします・・・