Navigation Component-дзюцу, vol. 4 – Переоценка

4601d1a407cb74d5b74a13117c7c655e.jpg

Спустя два месяца после написания цикла статей «Navigation Component-дзюцу» я задумался: неужели всё действительно так плохо? Может быть я поддался волне критики гугловых разработок и просто пропустил тревожный звоночек, принявшись исправлять баг за багом, проблему за проблемой с помощью костылей и палок?

Оказалось, во многом так оно и есть: в этой статье-дополнении я хочу рассказать, в чём была проблема, как её исправить и как это поменяло моё мнение о Navigation Component.

Кейс с BottomNavigationView

Первая статья начиналась с примера использования BottomNavigationView в приложении с Navigation Component: я описывал тернистый путь от использования стандартного шаблона Android Studio с нижней навигацией до применения специальной extension-функции из репозитория Navigation Advanced Sample. 

Напомни схему тестового приложения

06e2ca7a920af1e93a9c2e2e87e7d153

Стандартный шаблон Android Studio с нижней навигацией, который использует Navigation Component, реализует нижнюю навигацию в полном соответствии с гайдлайнами Material Design — то есть при переключении между вкладками стек экранов сбрасывается. Чтобы реализовать сохранение состояния вкладок можно воспользоваться специальной extension-функцией, которая под капотом создаёт для каждой вкладки нижней навигации отдельный NavHostFragment. К нему и будет привязан отдельный граф навигации со своим back stack-ом.

Оказалось, что при адаптации этой extension-функции для фрагментов я допустил серьёзную ошибку: использовал не тот FragmentManager. Так как мы строим навигацию внутри фрагмента, а не Activity, мне следовало использовать childFragmentManager, привязанный к фрагменту-контейнеру нижней навигации, а не supportFragmentManager, который был привязан к Activity. 

Правильный вариант выглядит так:

Код настройки BottomNavigationView внутри фрагмента

/**
 * Main fragment -- container for bottom navigation
 */
class MainFragment : Fragment(R.layout.fragment_main) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        if (savedInstanceState == null) {
            setupBottomNavigationBar()
        }
    }

    override fun onViewStateRestored(savedInstanceState: Bundle?) {
        super.onViewStateRestored(savedInstanceState)
        // Now that BottomNavigationBar has restored its instance state
        // and its selectedItemId, we can proceed with setting up the
        // BottomNavigationBar with Navigation
        setupBottomNavigationBar()
    }

    /**
     * Called on first creation and when restoring state.
     */
    private fun setupBottomNavigationBar() {
        val navGraphIds = listOf(
            R.navigation.search__nav_graph,
            R.navigation.favorites__nav_graph,
            R.navigation.responses__nav_graph,
            R.navigation.profile__nav_graph
        )

        // Setup the bottom navigation view with a list of navigation graphs
        fragment_main__bottom_navigation.setupWithNavController(
            navGraphIds = navGraphIds,
            fragmentManager = childFragmentManager, // Самая важная строка
            containerId = R.id.fragment_main__nav_host_container,
            intent = requireActivity().intent
        )
    }

}

Эта ошибка повлекла за собой описанные мной проблемы: краши в неожиданных местах, костыли для обратной навигации, странную привязку NavController-а.

Как так не заметили, что используете parentFragmentManager?

У меня есть несколько версий:

  • часто меняя код для описания большого примера, я мог не заметить разницы между поведением приложения при использовании supportFragmentManager-а и childFragmentManager-а;

  • мог подвести эмулятор;

  • а может быть, имела место банальная невнимательность при переносе кода с Advanced navigation sample с Activity на фрагменты; в исходном коде примера с Activity по понятным причинам использовался supportFragmentManager.

Как видите, нам больше не нужны никакие Handler.post для фиксов крашей IllegalStateException: FragmentManager already execute transaction. Кроме того, исчезает необходимость привязывать NavController, полученный из extension-а setupWithNavController, ко View нашего фрагмента. Плюс ко всему, у нас нет никаких крашей при сворачивании и разворачивании приложения — ура.

С учётом этого фикса кейс с BottomNavigationView делается по щелчку. Из коробки вам может не хватить только одного: иногда приложению требуется запоминать порядок выбора табов нижней навигации и при нажатии на кнопку Back возвращаться не на первый таб, а на предыдущий выбранный.

Навигация из вложенного графа во внешний граф

Благодаря использованию childFragmentManager-а мы не только исправили много проблем, но и упростили несколько других кейсов. В частности, всё стало гораздо проще с кейсом открытия вложенного флоу без нижней навигации.

Напомни схему

Речь идёт об этой части схемы:

95c35e570fabcb968789d6b1a4eea19b

Нам требовалось перейти из контейнера с нижней навигацией на уровень выше — во флоу авторизации, где нижней навигации нет.

Правильно инициализировав BottomNavigationView, мы избавились от необходимости руками привязывать NavController ко View контейнера с нижней навигацией (в коде это MainFragment). Это было неочевидно, но привело к проблемам, связанным с обратной навигацией во флоу авторизации, когда мы были вынуждены искать там «правильный» NavController:

В коде StartAuthFragment было вот так

callback = object : OnBackPressedCallback(true) {
    override fun handleOnBackPressed() {
            Navigation.findNavController(
                requireActivity(),
                R.id.activity_root__fragment__nav_host
            ).popBackStack()
    }
}.also {
            requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, it)
}

Теперь мы можем спокойно избавиться от этих переопределений OnBackPressedCallback-ов. Всё стало гораздо проще.

Навигация по условию

В целом этот кейс ошибка не затронула. Но в своей второй статье я говорил, что его можно реализовать двумя способами, однако рассмотрел только один из них. Сейчас хочу обратить внимание на второй, и теперь он кажется даже более простым.

Покажи на картинке

ac3696d906b55ccc24d4c0ce2ab8fc74

Напомню, что делали в этом кейсе: мы показывали пользователю экран Splash-а и на нём решали, куда двигаться дальше: на «главный» экран с нижней навигацией или же на первый из экранов авторизации. После прохождения авторизации мы должны были перевести пользователя на главный экран.

Первый способ заключался в пробрасывании флажка об открытии флоу авторизации со Splash-экрана и дальнейшей обработке этого флага в OnBackPressedCallback-е. Второй способ сводится к модификации текущего графа навигации: мы можем в runtime-е поменять startDestination графа на нужный нам «первый» фрагмент.

Ещё один вариант реализации навигации по условию

splashViewModel.splashNavCommand.observe(viewLifecycleOwner, Observer { splashNavCommand ->
    val navController = Navigation.findNavController(requireActivity(), R.id.activity_root__fragment__nav_host)

    val mainGraph = navController.navInflater.inflate(R.navigation.app_nav_graph)

    // Way to change the first screen at runtime.
    mainGraph.startDestination = when (splashNavCommand) {
        SplashNavCommand.NAVIGATE_TO_MAIN -> R.id.MainFragment
        SplashNavCommand.NAVIGATE_TO_AUTH -> R.id.auth__nav_graph
        null -> throw IllegalArgumentException("Illegal splash navigation command")
    }

    navController.graph = mainGraph
})

Мы по-прежнему выбираем начальный экран в SplashViewModel, но теперь в observer-е перестраиваем граф навигации и устанавливаем его в рутовый NavController, который получаем из Activity.

При таком способе навигации экран Splash-а больше не находится в back stack-е, и нажатие на кнопку Back на первом экране авторизации сразу закроет приложение без необходимости добавлять OnBackPressedCallback, завязанный на аргумент. 

Что ещё нужно сделать: поправить способ перехода с последнего экрана флоу авторизации на главный экран. Раньше мы закрывали флоу авторизации с помощью findNavController().popBackStack и пробрасывали результат о пройденной авторизации через SavedStateHandle, чтобы заново открывшийся Splash-экран перевёл нас на главный экран. Теперь можно поступить проще:

Навигация с последнего экрана авторизации

// Navigate back from auth flow
val result = findNavController().popBackStack(R.id.auth__nav_graph, true)
if (result.not()) {
    // we can't open new destination with this action
    // --> we opened Auth flow from splash
    // --> need to open main graph
    findNavController().navigate(R.id.MainFragment)
}

Метод popBackStack возвращает true, если стек был извлечён хотя бы один раз и пользователь был перемещён в какой-то другой destination, а false — в противном случае. Если граф авторизации был первым открытым destination-ом после Splash-экрана (а так и будет, поскольку мы изменили startDestination), этот метод вернёт нам false. 

Убрав из back stack-а все экраны авторизации, мы вернулись в рутовый граф, где в качестве start destination-а выбран именно граф авторизации. При этом, если открыть граф авторизации, например, с главного экрана, вызов popBackStack уже вернёт true, и мы не выполним ещё один переход на главный экран.

Работа с диплинками

С исправленной инициализацией BottomNavigationView при запуске команды на открытие диплинка через ADB больше не происходит никаких крашей — это прекрасно. Но никуда не делась особенность со сбросом стека: приложение по-прежнему целиком перезапускается, и нужно придумывать свои собственные способы обработки диплинков.

И как же это повлияло на мнение о Navigation Component

Найденная ошибка, разумеется, резко улучшила моё первоначальное мнение о Navigation Component. Библиотека действительно позволяет решить множество кейсов навигации довольно простым способом.

  • Нижняя навигация через BottomNavigationView — Navigation Component из коробки соответствует гайдам Material design-а (не сохраняется стек при переходе между вкладками), но если вам требуется поведение а-ля iOS (когда стек вкладок должен сохраняться), можно использовать extension-функцию, которая даст нужное поведение.

  • Навигация во вложенные графы и обратно — всё работает корректно, навигацию «обратно» можно реализовать через NavController.popBackStack (R.id.nestednavgraph), никаких костылей.

  • Навигация из вложенного контейнера во внешний (например, из контейнера с нижней навигацией в контейнер без неё) — реализуется через поиск «правильного» NavController-а и не вызывает никаких проблем.

  • Навигацию на старте приложения по условию можно реализовывать разными способами — либо через аргументы, либо через модификацию стартового графа в runtime-е. Модификация графа может сильно упростить этот кейс;

  • Навигация между модулями — из трёх предложенных способов надёжно работают два: описание графа в app-модуле + навигация через интерфейсы и описание графа в отдельном модуле, который подсоединяется ко всем остальным.

  • Кейс с пробросом результата из вложенного графа, как правильно заметили в комментариях к одной из статей, проще сделать через какую-нибудь реактивную шину или Result API и не использовать никакой SavedStateHandle.

Что может оттолкнуть вас в Navigation Component:

  • Навигация через deep link-и — потому что есть особенность со сбросом back stack-а, а это поведение подойдёт не всем приложениям;

  • Зависимость от тулинга и (опционально) кодогенерация — пока редактор графа навигации не выделили в отдельный плагин Android Studio, чтобы получить какие-то обновления редактора, нужно ожидать обновления Android Studio + опционально, с помощью gradle-плагинов вы можете сгенерировать много кода, а это может замедлить сборку;  

  • Зависимость от платформы — Navigation Component крепко завязан на Fragments и их жизненный цикл. Если фрагмент временно находится в уничтоженном состоянии, то буферизацию команд навигации придется реализовывать самостоятельно.

Спасибо @shipa_oблагодаря которому я нашел эту ошибку.

Полезные ссылки

© Habrahabr.ru