WEARのAndroidアプリをBottomNavigationにリプレイスした際の状態保存について

はじめに

こんにちは。WEAR部の鈴木(@zukkey59)です。

普段は、「ファッションコーディネートアプリ WEAR」のAndroidアプリを担当しています。

実は最近、コツコツとやっていたリプレイスがおわり、AndroidアプリのBottomNavigation化がリリースされました!

今回は、ドロワーメニューからBottomNavigationへリプレイスした際に悩んだFragmentの状態保存について、紹介します。

背景

今までのWEARのAndroidアプリは、iOSアプリと異なりドロワーメニューという古いUIのままだったため、BottomNavigationでの実装を行うことにしました。

実装を進めていると、BottomNavigationの項目の切り替えを行うことでリストのデータやスクロールの位置が保存されない現象に遭遇しました。

BottomNavigationの項目切り替えでもデータやスクロールの位置の状態が保存される要件を満たすために、BottomNavigationで実現可能か調査することにしました。

調査した結果、2020年11月時点では、WEARで使用しているNavigationライブラリにはマルチバックスタックの仕組みが存在しないとissue trackerに記載されており、BottomNavigationの項目を切り替えた際、Fragment自体が作り直されることが原因で状態保存されていないと判明しました。

参考:Support multiple back stacks for Bottom tab navigation

公式サンプルのNavigation Extensionsが要件を満たすことができるため、その導入を解決策としました。

まずは公式サンプルの中でやっていることをざっくりとまとめて、状態保存について紹介します。

公式サンプルの実装を読む

サンプルの中でやっていることを大きくまとめると、5つのステップに分けられます。

  1. BottomNavigationの項目ごとにNavHostFragmentの存在チェックを行い、初めての場合は作成する
  2. BottomNavigationで選択状態に応じて、attachとdetachを行う
  3. バック時の挙動を修正する
  4. 再選択時の挙動をBottomNavigationのリスナーに合わせて実装する
  5. DeepLinkの挙動を追加する

状態保存に関してこれらの中でも特に重要なのが、次の2点です。

  • ActivityがFragmentManagerにBottomNavigationの項目ごとにFragmentを追加し、Fragmentの状態を保持する点
  • 選択状態に応じてattachとdetachを繰り返すようにするという点

まず、Navigation ExtensionsのobtainNavHostFragmentをみていきましょう。

private fun obtainNavHostFragment(
    fragmentManager: FragmentManager,
    fragmentTag: String,
    navGraphId: Int,
    containerId: Int
): NavHostFragment {
    // 指定のfragmentTagを持つFragmentがあるかをチェック
    val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?
    existingFragment?.let { return it }

    // ない場合は新しくNavHostFragmentを作成
    val navHostFragment = NavHostFragment.create(navGraphId)
    // FragmentManagerに追加
    fragmentManager.beginTransaction()
        .add(containerId, navHostFragment, fragmentTag)
        .commitNow()
    return navHostFragment
}

ここではまずActivityが持つFragmentManager内に、BottomNavigationの項目ごとに追加されているFragmentTagがあるかチェックを行います。ない場合は新しくNavHostFragmentを追加します。

次に、setupWithNavControllerのコードをみていきましょう。以降、ソースコード中の「...」は省略を意味します。

fun BottomNavigationView.setupWithNavController(
    navGraphIds: List<Int>,
    fragmentManager: FragmentManager,
    containerId: Int,
    intent: Intent
): LiveData<NavController> {
    ...
    navGraphIds.forEachIndexed { index, navGraphId ->
        ...
        if (this.selectedItemId == graphId) {
            selectedNavController.value = navHostFragment.navController
            attachNavHostFragment(fragmentManager, navHostFragment, index == 0)
        } else {
            detachNavHostFragment(fragmentManager, navHostFragment)
        }
    }
    ...
}

BottomNavigationで選択された項目のIdとnavigationGraphのIdの比較によってattachとdetachを行っています。

attachNavHostFragmentとdetachNavHostFragmentの実装についてもみていきましょう。

private fun attachNavHostFragment(
    fragmentManager: FragmentManager,
    navHostFragment: NavHostFragment,
    isPrimaryNavFragment: Boolean
) {
    fragmentManager.beginTransaction()
        .attach(navHostFragment)
        .apply {
            ...
        }
        .commitNow()
}

private fun detachNavHostFragment(
    fragmentManager: FragmentManager,
    navHostFragment: NavHostFragment
) {
    fragmentManager.beginTransaction()
        .detach(navHostFragment)
        .commitNow()
}

attachNavHostFragmentでは、attachという関数を呼ぶことで、前にUIからdetachされたFragmentを再度attachし、ビュー階層が再作成されて表示されます。

detachNavHostFragmentでは、detachという関数を呼ぶことで、指定されたFragmentをUIから切り離し、バックスタックに配置された時と同じ状態にし、ビュー階層は破棄されます。

attachとdetachをする時のFragmentのライフサイクルの流れは、バックスタックに置いた状態と同じになるため、onCreateViewからonDestroyViewまで呼ばれることになります。

つまり、状態保存を実現するため内部的にやっていることは次の通りです。

  • インスタンスを最初に作成したあとに、同じインスタンスのUIの状態を保存してバックスタックに置いた状態にし、Viewの再生成から破棄までを実行する

これが状態保存を行うための基本的な考え方になります。

プロダクトに当てはめた時にいくつか出てきた課題

単純な遷移で、特に通信を元にしない表示だけをするのであれば、このままでも問題ありません。

しかし、実際のプロダクトに当てはめた際には、いくつか満たしたい仕様があります。

具体的には、WEARでは下記の仕様が満たさなければなりません。

  • PagerのTabLayoutタブを再選択した時に一番上までスクロールを行う
  • Pagerが持つFragment全て、タイミングに応じて全更新を行う

BottomNavigation化で、仕様を満たすために次の課題が出てきました。

  1. 初期化処理、イベントのobserveなどのタイミングについての考慮すること
  2. 密結合になっているクラスを疎にして、役割を明確にすること

1.に関しては、BottomNavigation化前は状態保存の仕様がなかったため、タイミングを考慮する必要はありませんでした。しかし、BottomNavigation化後は、Viewの生成時、最後に発行されたイベントの値がobserveのタイミングで即時に流れてくるような場合、切り替えのたびにobserve処理が実行されてしまい、イベントが流れるという意図しない挙動が発生しました。

2.に関しては、密結合になったクラスが多数存在し、役割が曖昧になっていました。例えばViewを作り直す場合、それが原因でAPIを呼び出してデータを取得する処理が密になっているため、1.のタイミングの考慮だけでは解決できませんでした。

これらの課題を解決するために、次のような対応を行いました。

公式サンプルの考え方を元に、状態保存を実現するための対応方針

まずはじめに、密結合になっているクラスを分離するために、アーキテクチャの導入を行い役割を明確にすることを行いました。

今回リプレイスしたことで、BottomNavigationの各FragmentとメインのActivityは次のように変わりました。

リプレイス後の現状のWEAR Androidのそれぞれの役割については次の通りです。

  • View(etc: Activity / Fragment)
    • Viewに関わる操作を担います。AdapterやViewHolderもこちらに入ります。
    • UIの操作を受けて、ViewModelにイベントを流す役割を持ちます。
  • ViewModel
    • Viewのデータを保持し、UIから受け取ったイベントに応じて、UIに必要な情報をLiveDataで渡します。
    • データ処理のビジネスロジックを含んでいます。
  • UseCase
    • アプリケーション固有のビジネスロジックを書く場所としています。
  • Repository
    • DBアクセスとAPI通信を担い、データの変換を行う役割を持ちます。

現状のWEARに最適なアーキテクチャは何かをチーム内で相談して決め、それを導入することでViewとビジネスロジックの分離を行うことができ、最終的に密結合の問題を解決することができました。

次に、初期化処理やイベントのobserveのタイミングについて説明します。

「公式サンプルの実装を読む」で説明した状態保存の根本的な考え方は、attachとdetachによってUIの状態が保存され、onCreateViewからonDestroyViewまでが呼ばれるということでした。

つまり、初回のみ実行したい初期化処理やイベントのobserveは、このライフサイクルの流れの中に入れてはいけません。

ライフサイクルがonCreateViewの前で、かつバックグラウンドでのプロセスキルなどの対応を考えた時に、savedInstanceStateを利用できる条件を満たすのはonCreateとなります。

そのため、WEARではonCreateにて初回に実行したい処理を記載するようにしました。

class HogeFragment : BaseDaggerFragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 初期化処理を記載する。バックキル時の対応などはこちらに記載

        // 最初の一回だけ行いたい、親Fragmentからのイベントの通知をobserveする
        (requireParentFragment() as FugaFragment).viewModel.behaviorLive.observe(requireParentFragment(), Observer { behavior ->
          ...
        })
    }
     
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // Viewの構築、下タブを切り替えた時に行ってほしい処理をこちらに記載
    }
}

これらの対応を行ったことで、下タブ切り替え時の状態保存を実現することができました。

また、WEARのタイムライン画面には独自ヘッダーの切り替えでも状態保存を行うという仕様が存在し、こちらも今まで説明した考え方を応用して実装しました。

公式サンプルの考え方を元に、独自ヘッダーで応用する

BottomNavigation化に伴い、新しく独自のヘッダーを作成することになりました。今まで説明した考え方を応用して実装した際のポイントをまとめて紹介します。

リプレイスしたタイムラインの画面が次に示すものです。

独自ヘッダーで実装する際のポイントは大きく5つのステップに分けられます。

  1. 親FragmentのインスタンスがonAttachされた際に、子Fragmentのインスタンスを作成する
  2. ヘッダーの選択情報をActivity側のViewModelで保持する
  3. View生成時、最初にattachされるFragmentをセットする
  4. ヘッダー切り替え時、選択状態に応じてattachとdetachを行う
  5. 子Fragment側で、最初に処理したいことをonCreateに記載する

まずはじめに、親FragmentであるTimelineAdminFragmentに子Fragmentのインスタンスを保持する必要があるため、onCreateにてリストで持たせます。

// タイムラインの親Fragment
class TimelineAdminFragment : BaseDaggerFragment() {
    private lateinit var childFragments: MutableList<Fragment>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // タイムラインにて保持したいFragmentをリストで持つ
        childFragments = mutableListOf(
            TimelineColumnFragment.newInstance(ONE),
            TimelineColumnFragment.newInstance(TWO),
            NewsFragment.newInstance(),
            NewSnapFragment.newInstance(ALL)
        )
    }
}

次に、子Fragmentにて独自ヘッダーを持つ場合はその選択状態を保持する必要があるため、Activity側のViewModelにてヘッダー情報を保持します。

// Activity側のViewModel
class MainViewModel(
    private val application: WEARApplication,
    private val mainUseCase: MainUseCase,
    private val accountUseCase: AccountUseCase
) : AndroidViewModel(application) {
    ...
    // LiveDataで保持する
    val timelineHeaderDataLive: MutableLiveData<Event<TimelineHeaderData>> = MutableLiveData()
    ...
}

// ヘッダーの情報を保持するクラス
data class TimelineHeaderData(
    val timelineTypes: List<TimelineType>,
    val followTypes: List<FollowType>,
    val categoryTypes: List<CategoryType>,
    ...
)

子Fragment側で、ヘッダーを持っているので、切り替えた際にActivityのViewModelのLiveDataにpostValueします。

View生成時、最初にattachされるFragmentをセットします。その際、Activity側でヘッダー情報を保持したので、そちらの情報を元にセットします。

Navigation Extensionsの実装を参考にして、タイムラインの親Fragmentに反映させました。

// タイムラインの親Fragment
class TimelineAdminFragment : BaseDaggerFragment() {
    // ヘッダーで切り替えた時にfragmentTagを保持しておくために用意
    private val graphIdToTagMap = SparseArray<String>()
    ...
    // View生成時(onViewCreated)に最初にattachするFragmentをセットする
    private fun setUpPrimaryFragment(type: TimelineType, followType: FollowType) {
        childFragments.forEachIndexed { index, fragment ->
            val fragmentTag = getFragmentTag(index)

            val obtainFragment = obtainFragment(fragmentTag, index)

            val selectedIndex = getSelectedFragmentIndex(type, followType)
            val mapKey = getLayoutResourceId(fragment) + index
            graphIdToTagMap[mapKey] = fragmentTag
            if (index == selectedIndex) {
                attachFragment(obtainFragment)
            } else {
                detachFragment(obtainFragment)
            }
        }
    }
    ...
    // BottomNavigationの時と同様にFragmentがすでに存在しているかチェックを行い、されていなければ追加する
    private fun obtainFragment(
        fragmentTag: String, 
        childFragmentsIndex: Int
    ): Fragment {
        val existingFragment = childFragmentManager.findFragmentByTag(fragmentTag)
        existingFragment?.let { return it }

        val fragment = childFragments[childFragmentsIndex]
        childFragmentManager.beginTransaction()
            .add(R.id.timeLineAdminFragmentContainer, fragment, fragmentTag)
            .commitNow()
        return fragment
    }

    // BottomNavigationの時と同様にFragmentのタグを取得する
    private fun getFragmentTag(index: Int) = "timeline#$index"
    // BottomNavigationの時とは異なり、navigationではなくlayoutのResourceIdを取得する
    private fun getLayoutResourceId(childFragment: Fragment) = when (childFragment) {
        is TimelineColumnFragment -> R.layout.fragment_timeline_column
        is NewsFragment -> R.layout.fragment_timeline_news
        is NewSnapFragment -> R.layout.fragment_new_snap
        else -> throw IllegalArgumentException("Not found such a fragment.")
    }
    
    // typeによって親Fragmentで保持している子Fragmentのどのindexかを取得する
    private fun getSelectedFragmentIndex(type: TimelineType, followType: FollowType): Int {
        return when {
            type == FOLLOW && followType == ONE -> TIMELINE_COLUMN_ONE_INDEX
            type == FOLLOW && followType == TWO -> TIMELINE_COLUMN_TWO_INDEX
            type == NEWS -> NEWS_INDEX
            type == SNAP -> NEW_SNAP_INDEX
            else -> TIMELINE_COLUMN_TWO_INDEX
        }
    }
}

ヘッダー切り替え時、選択状態に応じてattachとdetachを行います。ヘッダーの「フォロー中」、「ニュース」、「新着」の項目はRecyclerViewになっており、選択されたindexに応じてattachとdetachを行います。

class TimelineAdminFragment : BaseDaggerFragment() {
    ...
    private fun switchFragment(type: TimelineType, followType: FollowType, categoryType: CategoryType, shouldChangeCategory: Boolean = false) {
        ...
        val selectedIndex = getSelectedFragmentIndex(type, followType)
        val mapKey = getLayoutResourceId(childFragments[selectedIndex]) + selectedIndex
        val newlySelectedItemTag = graphIdToTagMap[mapKey] ?: return

        // 最初に全てのFragmentをdetachする
        childFragments.forEach { detachFragment(it) }

        // 新着のサブ項目である性別の選択の時は、状態を保存しない仕様があるため分岐がある
        if (shouldChangeCategory) {
            // 新着サブ項目の性別選択時に指定のFragmentを破棄して作り直すことをしているが、本筋と逸れるため省略
        } else {
            // 選択した項目のFragmentをattachするようにしている
            val selectedFragment = childFragmentManager.findFragmentByTag(newlySelectedItemTag) ?: throw IllegalArgumentException("There is no such Fragment. Please review the process.")
            attachFragment(selectedFragment)
        }
    }
    ...
}

ここで行っていることはBottomNavigationの実装と同様です。選択したindexからFragmentManagerに保持しているFragmentを取得しattachする流れになります。

子Fragment側で最初に処理したいことをonCreateに記載すれば完成です。

BottomNavigationの時と異なるのは、ヘッダー情報を保持する点と、fragmentTagのIdが変わっている点です。

根本的な考え方は同じなため、BottomNavigation以外で、Navigation Extensionsの考え方を用いれば容易にどのようなViewでも状態保存の実現が可能です。

まとめ

BottomNavigationに限らず状態保存を実現するために、重要なことは4つです。

  1. 密結合になっている実装の場合、まず切り離すことを考える
  2. インスタンスを最初に一度作成し、attachとdetachで切り替えるようにする
  3. 最初の1回だけやりたい処理は、attachとdetachが行われる側のFragmentのonCreateに記載する
  4. 独自のViewで実装する場合は、indexなどの情報を生存期間が長い方のActivity側のViewModelで保持するようにする

さいごに

今回紹介した事例で、BottomNavigationの方はNavigation Extensionsの実装が、Navigationでマルチバックスタックが対応されるまでの解決方法になると思いますが、内部の実装を掘り下げて考え方を学ぶと、他のViewであっても利用することができ、状態保存をしたい時には役立つと思います。

他にもやり方があったり、もっとこうしたほうがいいというアドバイスがございましたら、私のTwitterアカウント@zukkey59までご連絡をいただけますと嬉しいです!

さいごに、ZOZOテクノロジーズでは、一緒にモダンなサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください!

tech.zozo.com

カテゴリー