2021/02/27

NavHostFragmentをFragmentの入れ子にする時はsetPrimaryNavigationFragmentを指定する

NavHostFragment で app:defaultNavHost=trueを指定すればバックキー制御をNavHostFragmentに任せることができます.

    <androidx.fragment.app.FragmentContainerView
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:defaultNavHost="true"
        ...

NavHostFragmentをアクティビティのレイアウトに指定した時の構造は次の通りです.

Activity
  |- NavHostFragment

一方で, アクティビティ直下にNavHostFragmentを配置せず, 下記のように間にフラグメントがいる場合は注意が必要です.

Activity
  |- Fragment
     |- NavHostFragment

この場合, フラグメントのレイアウトでapp:defaultNavHost=trueを指定しても, バックキー制御などナビゲーション周りで意図しない動作となります.

解決策

NavHostFragmentを持つフラグメントを PrimaryNavigationFragment に設定します.

class HostFragment : Fragment {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        parentFragmentManager.commit {
            setPrimaryNavigationFragment(this@HostFragment)
        }

FragmentTransaction.setPrimaryNavigationFragment

app:defaultNavHost

NavHostFragmentはapp:defaultNavHost=trueを指定されると, 自身のonAttachで同様に setPrimaryNavigationFragment(this) を設定します.

NavHostFragment.ktの該当行 - GitHub

setPrimaryNavigationFragment

プライマリナビゲーションフラグメントに指定されると, バックナビゲーションなどをハンドリングできるようになります.
app:defaultNavHost=trueを指定するだけで, NavHostFragmentがバックナビゲーションをうまく制御できるのはこのためです.

プライマリナビゲーションフラグメントはフラグメントマネージャのインスタンス毎に1つしか設定できません.

FragmentManager.setPrimaryNavigationFragment - GitHub

NavHostFragmentを複数管理する場合, app:defaultNavHost=true なNavHostFragmentは1つにしなければならない理由でもあります.

フラグメントがプライマリナビゲーションフラグメントと判定されるには, 親フラグメントがいる場合, 関連するフラグメントマネージャのプライマリナビゲーションフラグメントに指定されている必要があります.

つまり, 次の構造ではNavHostFragmentの親フラグメント/親フラグメントマネージャがいないので, NavHostfragmentがプライマリナビゲーションフラグメントになります.

Activity
  |- NavHostFragment

しかし, 次の構造ではNavHostFragmentに親フラグメントがおり, その親がsetPrimaryNavigationFragmentとして指定されていない場合, 子であるNavHostFragmentもプライマリナビゲーションフラグメントの条件を満たしません.

Activity
  |- Fragment
     |- NavHostFragment

そのため, 親フラグメントは次のようなコードで自身をプライマリナビゲーションフラグメントとして指定する必要があります.

class HostFragment : Fragment {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        parentFragmentManager.commit {
            setPrimaryNavigationFragment(this@HostFragment)
        }

蛇足: NavHostFragmentのバックナビゲーション周りの実装

// デフォルトでフラグメントマネージャのOnBackPressedCallbackはenable=falseになっている

FragmentManager
    private final OnBackPressedCallback mOnBackPressedCallback =
            new OnBackPressedCallback(false) {
                @Override
                public void handleOnBackPressed() {
                    FragmentManager.this.handleOnBackPressed();
                }
            };

---

// OnBackPressedCallbackをenable=trueにするにはisPrimaryNavigationでtrueを返す必要がある

    private void updateOnBackPressedCallbackEnabled() {
        ...
        // This FragmentManager needs to have a back stack for this to be enabled
        // And the parent fragment, if it exists, needs to be the primary navigation
        // fragment.
        mOnBackPressedCallback.setEnabled(getBackStackEntryCount() > 0
                && isPrimaryNavigation(mParent));
    }

---

// PrimaryNavigationFragmentに変更があると...

Fragment
    void performPrimaryNavigationFragmentChanged() {
        boolean isPrimaryNavigationFragment = mFragmentManager.isPrimaryNavigation(this); ⭐️
        // Only send out the callback / dispatch if the state has changed
        if (mIsPrimaryNavigationFragment == null
                || mIsPrimaryNavigationFragment != isPrimaryNavigationFragment) {
            mIsPrimaryNavigationFragment = isPrimaryNavigationFragment;
            onPrimaryNavigationFragmentChanged(isPrimaryNavigationFragment); 🍣

---

// 一方, NavHostFragmentでは...

NavHostFragment
🍣
    public void onPrimaryNavigationFragmentChanged(boolean isPrimaryNavigationFragment) {
        if (mNavController != null) {
            mNavController.enableOnBackPressed(isPrimaryNavigationFragment); 🌴

---

// BackPressedCallbackを有効にするにはisPrimaryNavigationFragmentがtrueである必要がある.

NavController
🌴
    void enableOnBackPressed(boolean enabled) {
        mEnableOnBackPressedCallback = enabled;  
        updateOnBackPressedCallbackEnabled();

    private void updateOnBackPressedCallbackEnabled() {
        mOnBackPressedCallback.setEnabled(mEnableOnBackPressedCallback
                && getDestinationCountOnBackStack() > 1);

---

// NavControllerはOnBackPressedCallbackを持っている. NavHostFragmentのバックキー制御はNavControllerの責務

    private final OnBackPressedCallback mOnBackPressedCallback =
            new OnBackPressedCallback(false) {
        @Override
        public void handleOnBackPressed() {
            popBackStack();
        }
    };

以上.

2020/05/13

Android: dropbox/Store

dropbox/Store :Github

Dropbox/Storeとは

Storeはデータロードのためのライブラリです.
データの問合せに対して, ネットワーク越しにデータを取得するフェッチャーを定義し, 取得したデータをどのようにキャッシュするのかを決め, 指定したキャッシュポリシーにしたがって, その後のデータ取得を効率的に行うことができます.
Storeにアクセスするクラスは, データの所在(ネットワーク or ディスク or メモリ)を気にすることなくデータ取得することができるようになります.

Storeが主に提供する機能は次の3つです.

  1. ネットワークを経由してデータをフェッチする方法の宣言(required)
  2. 取得したデータをメモリまたはディスクにキャッシュする方法の宣言(optional)
  3. キャッシュのEvictionPolity(optional)

StoreはデータをFlowで返すためマルチスレッド処理することが容易になるよう設計されています. Flow/Coroutinesによる構造化された同時実行性の性質によって, スコープが明確に定義され, メモリリークの減少, パフォーマンスの向上, クラッシュリスクの軽減が期待されます.

Storeによってデータのフェッチ/共有/キャッシュに関するロジックがカプセル化され, ビューで最新のデータを効率的に購読することができ, データをオフラインで使用することもできるようになります.

Store簡単まとめ

  • フェッチャーには単一レスポンスと複数レスポンス(Flow)のバリエーションがある
  • ディスクキャッシュする/しないを選べる. メモリキャッシュする/しないを選べる
  • メモリキャッシュのEviction Polityには最も過去に生成/更新されたものを破棄, 最終アクセスからn時間経過で破棄, キャッシュの上限個数を指定できる
  • Storeへのデータ問合せ時にはデータを一意に識別できる汎用キーが必要. これは同一リクエストかの判定やキャッシュヒットの判定に使われ, 汎用キーはKotlin Data Classが推奨される.
  • Storeへのデータ問合せによって Loading, Data, Errorのレスポンスがエミットされる
  • Storeへのデータ問合せ中に発生したエラーはErrorとしてエミットされる
  • フェッチャーが使うFlowのスコープはGlobalScope
  • In-flight debouncerが実装されており, 初回の複数同時リクエスト時にもうまくキャッシュが効く

ビルダーによるStoreの構築

StoreはStoreBuilderによって構築されます.

StoreBuilder
    .from(
        fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) },
        sourceOfTruth = SourceOfTrue.from(
            reader = db.postDao()::loadPosts,
            writer = db.postDao()::insertPosts,
            delete = db.postDao()::clearFeed,
            deleteAll = db.postDao()::clearAllFeeds
        )
    ).cachePolicy(
        MemoryPolicy.builder()
            .setMemorySize(10)
            .setExpireAfterAccess(10.minutes) // or setExpireAfterWrite(10.minutes)
            .build()
    ).build()

これは次のことを宣言しています

  1. 複数回呼び出された場合に備えるフェッチしたデータのメモリキャッシュ
  2. ネットワークがオフラインの場合に備えたディスクキャッシュ

Storeはネットワークへの過剰な呼び出しを防ぎ, ディスクキャッシュをSource of Truthとして使用することができます. Source of Truthの実装には Room, SQLDelight などの監視可能なソースを提供できるデータベースが利用できます.

StoreBuilder.fromNonFlow

fun <Key : Any, Output : Any> fromNonFlow(
    fetcher: suspend (key: Key) -> Output
): StoreBuilder<Key, Output>

Flowを返さないフェッチャーを持つStoreBuilderを生成します.
リクエストに対してHTTPのように単一の応答を返すフェッチャーを持つStoreを生成します.

StoreBuilder.from

fun <Key : Any, Output : Any> from(
    fetcher: (key: Key) -> Flow<Output>
): StoreBuilder<Key, Output> = BuilderImpl(fetcher)

Flowを返すフェッチャーを持つStoreBuilderを生成します.
リクエストに対してWebsocketのように複数の応答を返すフェッチャーを持つStoreを生成します.

StoreBuilder.persister

fun <NewOutput : Any> persister(
    reader: (Key) -> Flow<NewOutput?>,
    writer: suspend (Key, Output) -> Unit,
    delete: (suspend (Key) -> Unit)? = null,
    deleteAll: (suspend () -> Unit)? = null
): StoreBuilder<Key, NewOutput>

Flowなディスクキャッシュへアクセスするための reader, writer, deleter を定義します.

柔軟性を確保するため, writerのレコードタイプ(Output)とreaderのレコードタイプ(NewOutput)は異なる型にすることができます. これによって, ネットワークから取得される型とローカルストレージのレコードタイプを分けることができます.

StoreBuilder.nonFlowingPersister

fun <NewOutput : Any> nonFlowingPersister(
    reader: suspend (Key) -> NewOutput?,
    writer: suspend (Key, Output) -> Unit,
    delete: (suspend (Key) -> Unit)? = null,
    deleteAll: (suspend () -> Unit)? = null
): StoreBuilder<Key, NewOutput>

Flowではないディスクキャッシュへアクセスするための reader, writer, deleter を定義します.

StoreBuilder.cachePolicy

fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder<Key, Output>

StoreのメモリキャッシュにおけるEvictionポリシーを指定できます.
MemoryPolicy.MemoryPolicyBuilderで TTLまたは容量ベースのEvictionを設定できます.

ビルダーで特にポリシーの指定がない場合, 次のキャッシュポリシーが適用されます.

  • キャッシュできるエントリーの上限個数 = 100
  • Evictionポリシー = キャッシュしたエントリーの作成/更新から24時間以上経過したものを破棄

StoreBuilder.disableCache

fun disableCache(): StoreBuilder<Key, Output>

キャッシュ機構を持たないStoreになります.

StoreBuilder.scope

fun scope(scope: CoroutineScope): StoreBuilder<Key, Output>

Storeが汎用キーに対応するデータをフェッチ(あるいはキャッシュヒット)して, 複数の購読者に結果をマルチキャストする際, そのスコープはGlobalScopeとなるのがデフォルトの挙動です.
このマルチキャストのスコープを独自にハンドリングしたい場合はこの関数でCoroutineScopeを指定します.

MemoryPolicyBuilder

Storeのメモリキャッシュポリシー(MemoryPolicy)を定義するためのビルダークラスです.
いわゆるEvictionポリシーはここで指定することになります.
このポリシーは最終的にCacheクラスを生成するパラメータとして利用されます.

MemoryPolicyBuilder.setExpireAfterWrite

fun setExpireAfterWrite(expireAfterWrite: Duration): MemoryPolicyBuilder

キャッシュエントリーが作成 or 置換/更新されてから一定時間後に自動削除するポリシーです.
Durationが0で指定されるとキャッシュされなくなります.

MemoryPolicyBuilder.setExpireAfterAccess

fun setExpireAfterAccess(expireAfterAccess: Duration): MemoryPolicyBuilder

キャッシュエントリーが作成 or 置換/更新 or 最後にアクセスされてから一定時間後に自動削除するポリシーです.
Durationが0で指定されるとキャッシュされなくなります.

MemoryPolicyBuilder.setMemorySize

fun setMemorySize(maxSize: Long): MemoryPolicyBuilder

キャッシュされるエントリー個数の上限を指定します. エントリー個数が上限を超えた場合, アクセスされた時間の最も古いエントリーが削除対象となります(LRU)
0が指定されるとすぐにキャッシュを破棄するため, キャッシュされなくなります.
特にビルダーで指定しなかった場合は個数の上限を設けません.

Storeの実装制約

Storeの唯一の実装制約はflowを返す関数, または特定の型を返すフェッチ用関数(フェッチャー)を実装する必要があることです.

val store = StoreBuilder.from {
    articleId -> api.getArticle(articleId) //Flow<Article>
}
.build() 

データの識別子

Storeはデータの識別子として汎用キーを使用します.
この汎用キーはtoString(), equals(), hashCode() を適切に実装した値オブジェクトにする必要があります.
汎用キーにはKotlinのdata classを使うことが強く推奨されます.

この汎用キーはフェッチ関数の引数として渡されます. また, キャッシュのプライマリ識別子としても利用されます.
UIはこの汎用キーさえ知っていれば, いつでもStoreからデータをネットワーク/キャッシュを気にせず再取得できるようになっています.

Stream API

Storeが提供する主要なAPIとしてstream functionがあります.

fun stream(request: StoreRequest<Key>): Flow<StoreResponse>Output>>

stream の呼び出しに渡される StoreRequest には次の情報が格納されています.

  1. データを識別するための汎用キー
  2. キャッシュの利用方針(ディスク/メモリキャッシュの利用有無)

streamの戻り値はStoreResponseがFlowで返されます.

StoreResponse

StoreResponseはseald classでサブクラスにはLoading, Data, Errorが定義されています.

  • それぞれのクラスには ResponseOrigin フィールドがあり, データの取得元がキャッシュ or ディスク or フェッチなのかを判別できるようになっています.
  • LoadingはResponseOriginのみを持ちます. このクラスはデータのロードをUIに反映するきっかけとして使うことができます.
  • DataはStoreから返される値を持ったクラスです
  • ErrorはResponseOriginによって投げられた例外をフィールドに持ちます

エラーが発生した場合でもStoreは例外をスローしません. その代わり, StoreResponse.Errorタイプによってこれが表現されます.
これによってFlowが壊れることがなく, データへの問い合わせやデータの更新が継続して行われることになります.
これによって, UIはflowの再起動/再接続を意識する必要がなくなります.

lifecycleScope.launchWhenStarted {
  store.stream(StoreRequest.cached(key = key, refresh=true)).collect { response ->
    when(response) {
        is StoreResponse.Loading -> showLoadingSpinner()
        is StoreResponse.Data -> {
            if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner()
            updateUI(response.value)
        }
        is StoreResponse.Error -> {
            if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner()
            showError(response.error)
        }
    }
  }
}

extention function

suspend fun Store.get(key: Key): Value

渡された値に対応するデータを単発取得します.
メモリ/ディスクキャッシュにヒットするデータがあればそこから取得されます.
エラー(StoreResponse.Error)が発生した場合は例外がスローされ, データがない場合はNullPointerExceptionがスローされます.

キャッシュされたデータもない初めての Store.get の呼び出しでは, ネットワークからデータを取得して, ディスク/メモリキャッシュにこれを格納します.
再度, おなじ汎用キーでStore.getを呼び出したなら, キャッシュからデータを取得して, ネットワーク通信を最低限に抑えようとします.

suspend fun Store.fresh(key: Key): Value

フェッチャーによる問合せによって, ネットワークからデータを単発取得します.
ディスク/メモリキャッシュをスキップしてデータを取得するため, 定期ジョブによるデータ(キャッシュ)更新や, Pull to Refreshによるデータの強制更新時などに利用されます.

suspend fun Store.stream(key: Key): Flow

データを監視してリアルタイムにUI更新をしたい場合などでは Store.stream が利用できます.
ディスクキャッシュの更新や, ネットワークからのロード/エラーイベントを監視するストリームを作成する方法と考えることができます.

In-flight debouncer

Storeは同じデータに対するリクエストの重複を避けるためにIn-flight debouncerの機能が組み込まれています.
リクエストに対するデータがまだキャッシュされていない場合, 複数個の同じデータに対するリクエストが同時にあると, それぞれが並列に処理されてキャッシュヒットせず, 両方のリクエストがネットワーク通信に至ってしまう可能性があります.
In-flight debouncerはこの不要なネットワーク通信を回避するために, 最初のリクエストはデータ取得のためにブロックされ, 他方の呼び出しはデータの到着を待たせます.

参考