Спустя два месяца после написания цикла статей «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 подталкивает реализовывать навигацию на уровне UI, а не presentation-слоя;

  • Невозможность работать в фоне (из коробки) — у Navigation Component нет внутреннего стека команд, которые могли бы пережить уход приложения в фон, но вы можете дополнительно использовать LiveData. 

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

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

Let’s block ads! (Why?)

Read More

Recent Posts

Apple возобновила переговоры с OpenAI и Google для интеграции ИИ в iPhone

Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…

20 часов ago

Российская «дочка» Google подготовила 23 иска к крупнейшим игрокам рекламного рынка

Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…

1 день ago

Google завершил обновление основного алгоритма March 2024 Core Update

Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…

1 день ago

Нейросети будут писать тексты объявления за продавцов на Авито

У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…

1 день ago

Объявлены победители международной премии Workspace Digital Awards-2024

24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…

2 дня ago

Яндекс проведет гик-фестиваль Young Con

27 июня Яндекс проведет гик-фестиваль Young Con для студентов и молодых специалистов, которые интересуются технологиями и хотят работать в IT.…

2 дня ago