Спустя два месяца после написания цикла статей Navigation Component-дзюцу я задумался: неужели всё действительно так плохо? Может быть я поддался волне критики гугловых разработок и просто пропустил тревожный звоночек, принявшись исправлять баг за багом, проблему за проблемой с помощью костылей и палок?
Оказалось, во многом так оно и есть: в этой статье-дополнении я хочу рассказать, в чём была проблема, как её исправить и как это поменяло моё мнение о Navigation Component.
Кейс с BottomNavigationView
Первая статья начиналась с примера использования BottomNavigationView в приложении с Navigation Component: я описывал тернистый путь от использования стандартного шаблона Android Studio с нижней навигацией до применения специальной extension-функции из репозитория Navigation Advanced Sample.
Напомни схему тестового приложенияСтандартный шаблон 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-а мы не только исправили много проблем, но и упростили несколько других кейсов. В частности, всё стало гораздо проще с кейсом открытия вложенного флоу без нижней навигации.
Напомни схемуРечь идёт об этой части схемы:
Нам требовалось перейти из контейнера с нижней навигацией на уровень выше во флоу авторизации, где нижней навигации нет.
Правильно инициализировав 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-ов. Всё стало гораздо проще.
Навигация по условию
В целом этот кейс ошибка не затронула. Но в своей второй статье я говорил, что его можно реализовать двумя способами, однако рассмотрел только один из них. Сейчас хочу обратить внимание на второй, и теперь он кажется даже более простым.
Покажи на картинкеНапомню, что делали в этом кейсе: мы показывали пользователю экран 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 flowval 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благодаря которому я нашел эту ошибку.
Полезные ссылки
-
Репозиторий с обновлённым примером
-
Полезные материалы, связанные с Navigation Component от @shipa_o