Navigation Component-дзюцу, vol. 4 – Переоценка
Спустя два месяца после написания цикла статей «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 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благодаря которому я нашел эту ошибку.