Два года назад на Google I/O Android-разработчикам представили
новое решение для навигации в приложениях библиотеку Jetpack
Navigation Component
. Про маленькие приложения уже было
сказано достаточно, а вот о том, с какими проблемами можно
столкнуться при переводе большого приложения на Navigation
Component
, информации практически нет.
В этой и следующих двух статьях я расскажу о кейсах, с которыми
может встретиться разработчик, желающий опробовать Navigation
Component
в большом Android-приложении.
Это текстовая версия моего выступления в рамках серии митапов по Android 11 в Android Academy. Само выступление было на английском, статью пишу на русском. Кому удобнее смотреть велкам.
В первой статье я расскажу о кейсах, связанных с
BottomNavigationView
, во второй о кейсах с вложенными
графами навигации, в третьей про навигацию в многомодульных
приложениях, дип линки, встраиваемые фрагменты и диалоги. Все три
статьи лонгриды, которые, однако, способны сэкономить много времени
и вам, и вашей команде.
Disclaimer
Я сделал пример, по структуре навигации повторяющий основные моменты навигации соискательского приложения hh.ru, и выхватил ряд проблем, о которых и собираюсь рассказать. Я основательно поресёрчил практическую сторону вопроса, но, разумеется, рассмотрел далеко не все возможные кейсы.
Схема моего тестового приложения выглядит так:
В цикле статей мы разберём каждый переход, который описан на этой схеме, а также несколько кейсов, которые не поместились на картинку.
Кейсы с BottomNavigationView
Когда я только-только услышал про Navigation
Component
, мне стало интересно: как будет работать
BottomNavigationView
и как Google подружит несколько
отдельных back stack-ов в разных вкладках. Два года назад с этим
кейсом были некоторые проблемы, и я решил проверить, как там
обстоят дела сегодня.
Если кратко проблемы не пропали, но появился способ их обойти. И, поскольку нижняя навигация сейчас есть практически в каждом большом приложении, нужно разбираться.
Первый опыт
Я установил Android Studio 4.1 Beta
(последнюю
более-менее стабильную версию на тот момент) и попробовал шаблон
приложения с нижней навигацией. Начало было многообещающим.
- Мне сгенерировали
Activity
в качестве контейнера для хоста навигации и нижней навигации
<androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/container"> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/nav_view" app:menu="@menu/bottom_nav_menu" /> <fragment android:id="@+id/nav_host_fragment" android:name="androidx.navigation.fragment.NavHostFragment" app:defaultNavHost="true" app:navGraph="@navigation/mobile_navigation" /></androidx.constraintlayout.widget.ConstraintLayout>
Я убрал шумовые атрибуты, чтобы было проще читать.
Стандартный ConstraintLayout
, в который добавили
BottomNavigationView
и тэг
<fragment>
для инициализации
NavHostFragment
-а (Android Studio
,
кстати, подсвечивает, что вместо фрагмента лучше использовать
FragmentContainerView
).
- Для каждой вкладки
BottomNavigationView
был создан отдельный фрагмент
<navigation android:id="@+id/mobile_navigation" app:startDestination="@+id/navigation_home"> <fragment android:id="@+id/navigation_home" android:name="com.aaglobal.graph_example.ui.home.HomeFragment"/> <fragment android:id="@+id/navigation_dashboard" android:name="com.aaglobal.graph_example.ui.dashboard.DashboardFragment"/> <fragment android:id="@+id/navigation_notifications" android:name="com.aaglobal.graph_example.ui.notifications.NotificationsFragment"/></navigation>
Все фрагменты были добавлены в качестве отдельных destination-ов в общий граф навигации.
- А ещё в проект был добавлен файл-ресурс для описания меню
BottomNavigationView
<menu xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"> <item android:id="@+id/navigation_home" android:icon="@drawable/ic_home_black_24dp" android:title="@string/title_home" /> <item android:id="@+id/navigation_dashboard" android:icon="@drawable/ic_dashboard_black_24dp" android:title="@string/title_dashboard" /> <item android:id="@+id/navigation_notifications" android:icon="@drawable/ic_notifications_black_24dp" android:title="@string/title_notifications" /></menu>
При этом я заметил, что идентификаторы элементов меню должны
совпадать с идентификаторами destination-ов в графе навигации. Не
самая очевидная связь между табами
BottomNavigationView
и фрагментами, но работаем с тем,
что есть.
Пора запускать приложение
После создания приложения из шаблона я запустил его и сразу столкнулся с двумя проблемами.
Первая проблема: при переключении между вкладками их состояние не сохранялось.
Для проверки я добавил во вкладку Dashboard
простенькую ViewModel
со счётчиком. На гифке видно,
как я переключаюсь со вкладки Home
на вкладку
Dashboard
, увеличиваю счётчик до четырёх. После этого
я переключился обратно на вкладку Home
и вновь
вернулся на Dashboard
. Счётчик сбросился.
Баг с описанием этой проблемы уже два года висит в
Issue Tracker-е. Чтобы решить её, Google-у потребовалось
серьёзно переработать внутренности фреймворка
Fragment
-ов, чтобы поддержать возможность работать с
несколькими back stack-ами одному FragmentManager
-у.
Недавно
на Medium вышла статья Ian Lake, в которой он рассказывает, что
Google серьёзно продвинулись в этом вопросе, так что, возможно,
фикс проблемы с BottomNavigationView
не за горами.
Вторая проблема следствие первой. По принятым принципам навигации, когда вы нажимаете на уже выбранную вкладку BottomNavigationView, вы должны вернуться на первый фрагмент в стеке текущей вкладки. Но когда это происходит, состояние этого первого фрагмента сбрасывается, так как сам фрагмент пересоздаётся.
Для демонстрации этой проблемы я добавил на вкладку
Dashboard
кнопку, которая ведёт на следующий экран. На
гифке видно, как я переключаюсь на вкладку Dashboard
,
увеличиваю счётчик до трёх, а затем перехожу на экран
Graphic
. Если я нажимаю на кнопку Back
то
всё работает как надо, состояние вкладки не сбрасывается. Но если,
находясь на экране Graphic
, я ещё раз нажму на вкладку
Dashboard
, то после возврата на первый экран в стеке
увижу, что его состояние сброшено.
Не самое лучшее первое впечатление, подумал я. И начал искать фикс.
У нас есть workaround
Решение этих проблем живёт в специальном репозитории Google-а с
примерами работы с Architecture Components
, в
проекте NavigationAdvancedSample.
Большая часть фикса расположена в файле NavigationExtensions.kt. В самом проекте довольно много кода, поэтому я не буду его разбирать подробно, а вместо этого подсвечу основные моменты, которые относятся к решению проблем.
- Во-первых, для каждой вкладки вводится отдельный, независимый граф навигации
<?xml version="1.0" encoding="utf-8"?><navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto" xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools" android:id="@+id/navigation_home" app:startDestination="@id/HomeFragment"> <fragment android:id="@+id/HomeFragment" android:name="com.aaglobal.jnc_playground.ui.home.HomeFragment" android:label="@string/title_home" tools:layout="@layout/fragment_home" /></navigation>
Соответственно, для примера BottomNavigationView
с
тремя вкладками у нас получится три отдельных файла навигации XML,
в которых в качестве startDestination
будут указаны
первые фрагменты вкладок.
- Во-вторых, для каждой вкладки под капотом создаётся отдельный
NavHostFragment
, который будет связан с графом навигации этой вкладки
private fun obtainNavHostFragment( fragmentManager: FragmentManager, fragmentTag: String, navGraphId: Int, containerId: Int): NavHostFragment { // If the Nav Host fragment exists, return itval existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment? existingFragment?.let { return it } // Otherwise, create it and return it. val navHostFragment = NavHostFragment.create(navGraphId) fragmentManager.beginTransaction() .add(containerId, navHostFragment, fragmentTag) .commitNow() return navHostFragment}
FragmentManager пока что не поддерживает работу с множеством
back stack-ов одновременно, поэтому пришлось придумать
альтернативное решение, которое позволило ассоциировать с каждым
графом свой back stack. Им стало создание отдельного
NavHostFragment
-а для каждого графа. Из этого следует,
что с каждой вкладкой BottomNavigationView
у нас будет
связан отдельный NavController
.
- В-третьих, мы устанавливаем в
BottomNavigationView
специальныйlistener
, который будет заниматься переключением между back stack-ами фрагментов
setOnNavigationItemSelectedListener { item -> val newlySelectedItemTag = graphIdToTagMap[item.itemId] if (selectedItemTag != newlySelectedItemTag) { fragmentManager.popBackStack(firstFragmentTag, FragmentManager.POP_BACK_STACK_INCLUSIVE) val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag) as NavHostFragment if (firstFragmentTag != newlySelectedItemTag) { fragmentManager.beginTransaction() .attach(selectedFragment) .setPrimaryNavigationFragment(selectedFragment).apply { graphIdToTagMap.forEach { _, fragmentTagIter -> if (fragmentTagIter != newlySelectedItemTag) { detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!) } } } .addToBackStack(firstFragmentTag) .setReorderingAllowed(true) .commit() } selectedNavController.value = selectedFragment.navController true } else { false }}
В прикреплённом кусочке кода мы видим, как при переключении
между вкладками BottomNavigationView
выполняется
специальная транзакция в FragmentManager
-е, которая
прикрепляет фрагмент выбранной вкладки и отцепляет все остальные
фрагменты. По сути, так мы и переключаемся между различными back
stack-ами.
- В итоге метод настройки
BottomNavigationView
возвращает разработчику специальнуюLiveData
, которая содержит в себеNavController
выбранной вкладки. ЭтотNavController
можно использовать, например, для обновления надписи наActionBar
-е
class RootActivity : AppCompatActivity(R.layout.activity_root) { private var currentNavController: LiveData<NavController>? = null private fun setupBottomNavigationBar() { // Setup the bottom navigation view with a list of navigation graphs val liveData = bottom_nav.setupWithNavController( navGraphIds = listOf( R.navigation.home_nav_graph, R.navigation.dashboard_nav_graph, R.navigation.notifications_nav_graph ), fragmentManager = supportFragmentManager, containerId = R.id.nav_host_container, intent = intent ) // Whenever the selected controller changes, setup the action bar. liveData.observe(this, Observer { ctrl -> setupActionBarWithNavController(ctrl) }) currentNavController = liveData }}
Метод для настройки BottomNavigationView
вызывают в
onCreate
-е, когда Activity
создаётся в
первый раз, затем в методе onRestoreInstanceState
,
когда Activity
пересоздаётся с помощью сохранённого
состояния.
Кроме того, для работы фикса нужно, чтобы идентификаторы
элементов меню, которое используется для инициализации табов
BottomNavigationView
, совпадали с идентификаторами
графов навигации.
Опять же, не самая очевидная связь между этими элементами, зато работает.
После применения этого workaround-а первые две проблемы исчезли теперь состояние вкладки сохраняется между переключениями вкладок.
Первая проблема решилась:
И вторая тоже:
Адаптация workaround-а для фрагментов
Очень здорово, что workaround помог решить основную проблему с сохранением состояний, но этих фиксов мне было недостаточно. Пример из проекта NavigationAdvancedSample использовал в качестве контейнера нижней навигации Activity, мне же нужен был фрагмент.
Посмотрите внимательно на эту схему:
На ней можно увидеть, что пользователь начинает свой путь в приложении со Splash-экрана:
Google говорит, что Splash-экраны зло, ухудшающее UX приложения.
Тем не менее, Splash-экраны суровая реальность большинства крупных
Android-приложений. И если мы хотим использовать в нашем приложении
Single Activity
-архитектуру, то в качестве контейнера
нижней навигации придётся использовать Fragment
, а не
Activity
:
Я добавил вёрстку для фрагмента с нижней навигацией и перенёс
настройку BottomNavigationView
во фрагмент:
class MainFragment : Fragment(R.layout.fragment_main) { private var currentNavController: LiveData<NavController>? = null override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) setupBottomNavigationBar() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (savedInstanceState == null) { setupBottomNavigationBar() } }}
Я добавил в свой пример Splash-экран и дополнительную вкладку
для BottomNavigationView
. А чтобы пример стал ещё
более походить на приложение для соискателей hh.ru, я также убрал
из него ActionBar
.
Для этого я поменял тему приложения с
Theme.MaterialComponents.DayNight.DarkActionBar
на
Theme.MaterialComponents.DayNight.NoActionBar
и убрал
код для связки NavController
-а с
ActionBar
-ом:
class MainFragment : Fragment(R.layout.fragment_main) { private var currentNavController: LiveData<NavController>? = null 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 ) val controller = bottom_navigation.setupWithNavController( navGraphIds = navGraphIds, fragmentManager = requireActivity().supportFragmentManager, containerId = R.id.fragment_main__nav_host_container, intent = requireActivity().intent ) currentNavController = controller }}
После всех манипуляций я включил режим Don't keep activities, запустил свой пример и получил краш при сворачивании приложения.
На гифке видно, как я запустил приложение, и после Splash-экрана показывается экран с нижней навигацией. После этого мы сворачиваем приложение и получаем краш.
В чём была причина? При вызове onDestroyView
активный NavHostFragment
пытается отвязаться от
NavController
-а. Так как мой фрагмент-контейнер с
нижней навигацией никак не привязывал к себе
NavController
, который он получил из
LiveData
, метод
Navigation.findNavController
из
onDestroyView
крашил приложение.
Добавляем привязку NavController
-а к фрагменту с
нижней навигацией (для этого в Navigation Component-е есть
утилитный метод Navigation.setViewNavController
), и
проблема исчезает.
class MainFragment : Fragment(R.layout.fragment_main) { private var currentNavController: LiveData<NavController>? = null private fun setupBottomNavigationBar() { ... currentNavController?.observe( viewLifecycleOwner, Observer { liveDataController -> Navigation.setViewNavController(requireView(), liveDataController) } ) }}
Но это ещё не всё. Не выключая режим Don't keep activities, я
попробовал свернуть, а затем развернуть приложение. Оно снова
упало, но с другим неприятным исключением
IllegalStateException
в FragmentManager
FragmentManager already executing transactions
.
На гифке видно, как мы сворачиваем приложение, находясь на экране с нижней навигацией, затем пытаемся развернуть приложение обратно и получаем краш.
Краш происходит в методах, которые прикрепляют
NavHostFragment
к FragmentManager
-у после
их создания. Это исключение
можно исправить при помощи костыля: обернуть методы
attach-detach в Handler.post {}
.
// NavigationExtensions.ktprivate fun attachNavHostFragment( fragmentManager: FragmentManager, navHostFragment: NavHostFragment, isPrimaryNavFragment: Boolean) { Handler().post { fragmentManager.beginTransaction() .attach(navHostFragment) .apply { if (isPrimaryNavFragment) { setPrimaryNavigationFragment(navHostFragment) } } .commitNow() }}
После добавления Handler.post
приложение
заработало, как надо.
Выводы по работе с BottomNavigationView
- Использовать
BottomNavigationView
в связке сNavigation Component
можно, если знать, где искать workaround-ы. - Если вы захотите иметь фрагмент в качестве контейнера нижней
навигации
BottomNavigationView
, будьте готовы искать дополнительные фиксы для ваших проблем, так как скорее всего я поймал не все возможные краши.
На этом с BottomNavigationView
всё, на следующей
неделе расскажу про кейсы с вложенными графами навигации.