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


Это третья и заключительная статья в цикле про различные кейсы навигации с Navigation Component-ом. Вы также можете ознакомиться с первой и второй частями



Если вы работаете с большим приложением, вероятно, вы уже разбили его на модули. Неважно, как именно. Может быть, вы создаёте отдельные модули для логики и UI, а может храните всю логику фичи (от взаимодействия с API до логики presentation-слоя) в одном модуле. Главное – у вас могут быть кейсы, когда требуется осуществить навигацию между двумя независимыми модулями.


Где на схеме приложения кейсы с навигацией?


На картинке мы видим, что у нас есть как минимум два модуля: модуль :vacancy с одним экраном и модуль :company с двумя экранами вложенного flow. В рамках моего примера я построил навигацию из модуля :vacancy в модуль :company, которые не связаны друг с другом.


Существует три способа как это сделать, разберём их один за другим.


App-модуль + интерфейсы


Первый способ – использовать ваш application-модуль в качестве хранилища всего графа навигации и определить в feature-модулях специальные интерфейсы для роутинга.


Структура вашего приложения в этом способе


Структура приложения будет стандартной: есть app-модуль, который знает обо всех feature-модулях, есть feature-модули, которые не знают друг о друге. В этом способе ваши feature-модули пребывают в священном неведении о Navigation Component, и для навигации они будут определять интерфейсы примерно вот такого вида:


// ::vacancy module
interface VacancyRouterSource {

    fun openNextVacancy(vacancyId: String)

    // For navigation to another module
    fun openCompanyFlow()

}

А ваш app-модуль будет реализовывать эти интерфейсы, потому что он знает обо всех action-ах и навигации:


fun initVacancyDI(navController: NavController) {
  VacancyDI.vacancyRouterSource = object : VacancyRouterSource {
      override fun openNextVacancy(vacancyId: String) {
          navController.navigate(
              VacancyFragmentDirections
                .actionVacancyFragmentToVacancyFragment(vacancyId = vacancyId)
          )
      }

      override fun openCompanyFlow() {
          initCompanyDI(navController)
          navController.navigate(R.id.action__VacancyFragment__to__CompanyFlow)
      }
  }
}

Плюс этого способа – отсутствие дополнительных модулей, которые могут повлиять на скорость сборки приложения. Однако в минусах вы получаете:


  • дополнительную работу в виде определения интерфейсов, реализаций, организации DI для проброса этих интерфейсов в ваши feature-модули;
  • отсутствие возможности использовать использовать Safe Args плагин, делегат navArgs, сгенерированные Directions, и другие фишки Navigation Component-а в feature-модулях, потому что эти модули ничего не знают про библиотеку.

Сомнительный, в общем, способ.


Графы навигации в feature-модулях + диплинки


Второй способ – вынести отдельные графы навигации в feature-модули и использовать поддержку навигации по диплинкам (она же – навигация по URI, которую добавили в Navigation Component 2.1).


Структура вашего приложения в этом способе


Структура приложения будет ровно такой же, как и в первом способе: есть app-модуль, который зависит от всех feature-модулей, и есть feature-модули, которые друг о друге ничего не знают.


Но теперь ваш app-модуль не обязан содержать весь граф навигации приложения, он может содержать только его часть. А остальные кусочки будут содержать именно feature-модули.


<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/company_flow__nav_graph"
    app:startDestination="@id/CompanyFragment">
    <fragment
        android:id="@+id/CompanyFragment"
        android:name="company.CompanyFragment">

        <deepLink app:uri="companyflow://company" />

        <!-- Or with arguments -->
        <argument android:name="company_id" app:argType="long" />
        <deepLink app:uri="companyflow://company" />

        <action
            android:id="@+id/action__CompanyFragment__to__CompanyDetailsFragment"
            app:destination="@id/CompanyDetailsFragment" />
    </fragment>

    <fragment
        android:id="@+id/CompanyDetailsFragment"
        android:name="company.CompanyDetailsFragment" />
</navigation>

Feature-модули будут определять свои собственные графы навигации для роутинга между экранами, о которых они знают. А ещё они будут объявлять диплинки для тех экранов, на которые можно попасть из других модулей. В примере выше мы добавили тэг deepLink, чтобы на экран CompanyFragment можно было попасть из другого модуля.


После этого мы можем использовать этот диплинк для открытия экрана CompanyFragment из модуля :vacancy :


// ::vacancy module

fragment_vacancy__button__open_company_flow.setOnClickListener {
  // Navigation through deep link
  val companyFlowUri = "companyflow://company".toUri()
  findNavController().navigate(companyFlowUri)
}

Плюс этого метода в том, что это самый простой способ навигации между двумя независимыми модулями. А минус – что вы не сможете использовать Safe Args, или «сложные»? типы аргументов (Enum, Serializable, Parcelable) при навигации между фичами.


P.S. Есть, конечно, вариант сериализовать ваши сложные структуры в JSON и передавать их в качестве String-аргументов в диплинк, но это как-то… Странно.


Общий модуль со всем графом навигации


Третий способ – ввести для хранения всего графа навигации специальный модуль, который будет подключаться к каждому feature-модулю.


Структура вашего приложения в этом способе


У нас по-прежнему есть app-модуль, но теперь его задача – просто подсоединить к себе все feature-модули; он больше не хранит в себе граф навигации. Весь граф навигации теперь располагается в специальном модуле, который ничего не знает о feature-модулях. Зато каждый feature-модуль знает про common navigation.


В чём соль? Несмотря на то, что common-модуль не знает о реализациях ваших destination-ов (фрагментах, диалогах, activity), он всё равно способен объявить граф навигации в XML-файлах! Да, Android Studio начинает сходить с ума: все имена классов в XML-е горят красным, но, несмотря на это, все нужные классы генерируются, Safe Args плагин работает как нужно. И так как ваши feature-модули подключают к себе common-модуль, они могут свободно использовать все сгенерированные классы и пользоваться любыми action-ами вашего графа навигации.


Плюс этого способа – наконец-то можно пользоваться всеми возможностями Navigation Component-а в любом feature-модуле. Из минусов:


  • добавился ещё один модуль в critical path каждого feature-модуля, которому потребовалась навигация;
  • отсутствует автоматический рефакторинг имён: если вы поменяете имя класса какого-нибудь destination-а, вам нужно будет не забыть, что надо поправить его в common-модуле.

Выводы по навигации в многомодульных приложениях


  • У вас на выбор целых три способа организации межмодульной навигации.
  • Выбирайте тот, который вам нравится, понятен и, главное, который вам проще реализовать.

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


Практически каждое большое приложение должно уметь поддерживать диплинки. И практически каждый Android-разработчик мечтал о простом способе работы с этими «?глубокими ссылками»?. Окей, я мечтал. И казалось, что Navigation Component – ровно то, что нужно.


По моей задумке кейсы с диплинками должны были занимать довольно большую часть переходов тестового приложения.


Какую именно часть?


У меня было три кейса с диплинками, которые я хотел реализовать с помощью Navigation Component.


  • Открытие определённой вкладки нижней навигации – допустим, я хочу через диплинк открыть вторую вкладку на главном экране после Splash-экрана

Посмотреть на картинке

Допустим, я хочу через диплинк открыть вкладку Favorites нижней навигации на главном экране после Splash-экрана:



  • Открытие определённого экрана ViewPager-а внутри конкретной вкладки нижней навигации

Посмотреть на картинке

Пусть я хочу открыть определённую вкладку ViewPager-а внутри вкладки Responses:



  • Открытие экрана, который требует авторизации пользователя – пусть теперь для корректной работы вкладки нижней навигации требуется авторизация. И с помощью диплинка я хочу сначала показать Splash-экран, затем открыть флоу авторизации, а когда пользователь его пройдёт, открыть нужную вкладку

Посмотреть на картинке

Пусть вкладка Profile теперь требует авторизации. И с помощью диплинка я хочу сначала показать Splash-экран, затем открыть флоу авторизации, а когда пользователь его пройдёт – открыть вкладку Profile.



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



Прежде чем делать диплинк для второй вкладки, я решил настроить ссылку хотя бы для первой. Я решил, что для этого будет достаточно просто добавить диплинк на экран контейнера нижней навигации (не самой вкладки, а контейнера):


<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/app_nav_graph"
    app:startDestination="@id/SplashFragment">

    <fragment
        android:id="@+id/SplashFragment"
        android:name="ui.splash.SplashFragment" />

    <fragment
        android:id="@+id/MainFragment"
        android:name="ui.main.MainFragment">

        <deepLink app:uri="www.example.com/main" />

    </fragment>

</navigation>

Затем я, следуя документации, добавил граф навигации с диплинком в Android Manifest:


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.aaglobal.jnc_playground">

    <application android:name=".App">
        <activity android:name=".ui.root.RootActivity">

            <nav-graph android:value="@navigation/app_nav_graph"/>

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

        </activity>
    </application>

</manifest>

А потом решил проверить, работает ли то, что я настроил при помощи простой adb-команды:


adb shell am start   -a android.intent.action.VIEW   -d "https://www.example.com/main" com.aaglobal.jnc_playground

И-и-и… нет. Ничего не завелось. Я получил краш приложения с уже знакомым исключением – IllegalStateException: FragmentManager is already executing transactions. Дебаггер указывал на код, связанный с настройкой нижней навигации, поэтому я решил просто обернуть эту настройку в очередной Handler.post:


// MainFragment.kt — fragment with BottomNavigationView

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

private fun safeSetupBottomNavigationBar() {
    Handler().post {
        setupBottomNavigationBar()
    }
}

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


Это произошло, потому что в нашем случае путь диплинка был таким: мы запустили приложение, запустилась его единственная Activity. В вёрстке этой activity мы инициализировали первый граф навигации. В этом графе оказался элемент, который удовлетворял URI, мы отправили его через adb-команду – вуаля, он сразу и открылся, проигнорировав указанный в графе startDestination.


Тогда я решил перенести диплинк в другой граф – внутрь вкладки нижней навигации.


<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/menu__search"
    app:startDestination="@id/SearchContainerFragment">

    <fragment
        android:id="@+id/SearchContainerFragment"
        android:name="tabs.search.SearchContainerFragment">

        <deepLink app:uri="www.example.com/main" />

        <action
            android:id="@+id/action__SearchContainerFragment__to__CompanyFlow"
            app:destination="@id/company_flow__nav_graph" />
        <action
            android:id="@+id/action__SearchContainerFragment__to__VacancyFragment"
            app:destination="@id/vacancy_nav_graph" />
    </fragment>
</navigation>

И, запустив приложение, я получил ЭТО:


Посмотреть на ЭТО


На гифке видно, как приложение запустилось, и мы увидели Splash-экран. После этого на мгновение показался экран с нижней навигацией, а затем приложение словно запустилось заново! Мы снова увидели Splash-экран, и только после его повторного прохождения появилась нужная вкладка нижней навигации.


И что самое неприятное во всей этой истории – это не баг, а фича.


Если почитать внимательно документацию про работу с диплинками в Navigation Component, можно найти следующий кусочек:


When a user opens your app via an explicit deep link, the task back stack is cleared and replaced with the deep link destination.

То есть наш back stack специально очищается, чтобы Navigation Component-у было удобнее работать с диплинками. Говорят, что когда-то давно, в бета-версии библиотеки всё работало адекватнее.


Мы можем это исправить. Корень проблемы – в методе handleDeepLink NavController-а:


Кусочек handleDeepLink
public void handleDeepLink(@Nullable Intent intent) {
    // ...
    if ((flags & Intent.FLAG_ACTIVITY_NEW_TASK) != 0) {
        // Start with a cleared task starting at our root when we're on our own task
        if (!mBackStack.isEmpty()) {
            popBackStackInternal(mGraph.getId(), true);
        }
        int index = 0;
        while (index < deepLink.length) {
            int destinationId = deepLink[index++];
            NavDestination node = findDestination(destinationId);
            if (node == null) {
                final String dest = NavDestination.getDisplayName(mContext, destinationId);
                throw new IllegalStateException("Deep Linking failed:"
                        + " destination " + dest
                        + " cannot be found from the current destination "
                        + getCurrentDestination());
            }
            navigate(node, bundle,
                    new NavOptions.Builder().setEnterAnim(0).setExitAnim(0).build(), null);
        }
        return true;
    }
}

Чтобы переопределить это поведение, нам потребуется:


  • почти полностью скопировать к себе исходный код Navigation Component;
  • добавить свой собственный NavController с исправленной логикой (добавление исходного кода библиотеки необходимо, так как от NavController-а зависят практически все элементы библиотеки) – назовём его FixedNavController;
  • заменить все использования исходного NavController-а на FixedNavController.

Несложно, правда? Но кажется, что это уже слишком. Подобные исправления заставляют нас в будущем тратить всё больше и больше времени на поддержку нашего кастомного решения, ведь нужно будет обновлять свой фикс при каждом обновлении библиотеки. Около года назад коллеги из Яндекса столкнулись с похожей проблемой, и им пришлось делать примерно то же самое. Сочувствую.


В этот невесёлый момент я заметил ещё один баг, который был добавлен при попытке исправить краш с диплинками: сломалась обратная навигация из auth-флоу.


Покажи гифку


На гифке видно, как мы переключаемся на вкладку Profile, затем переходим во флоу авторизации. Потом мы нажимаем на кнопку Back и получаем просто белый экран. Если переключиться на какую-нибудь другую вкладку и обратно, то мы снова увидим контент вкладки Profile.


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



Если у вас будет свой собственный NavController, корректно обрабатывающий диплинки, реализовать этот кейс будет просто.


В NavController-е есть специальный булевский флажок – isDeepLinkHandled, – который говорит нам, что текущий NavController успешно обработал диплинк. Вы могли бы добавить диплинк, ведущий на фрагмент, который содержит в себе ViewPager, затем написать примерно вот такой код, чтобы перейти на нужную вкладку:


if (findMyNavController().isDeepLinkHandled && requireActivity().intent.data != null) {
    val uriString = requireActivity().intent.data?.toString()
    val selectedPosition = when {
        uriString == null -> 0
        uriString.endsWith("favorites") -> 0
        uriString.endsWith("subscribes") -> 1
        else -> 2
    }
    fragment_favorites_container__view_pager.setCurrentItem(selectedPosition, true)
}

Но, опять же, это будет доступно только в случае, если вы уже добавили к себе в кодовую базу свою реализацию NavController-а, ведь флаг isDeepLinkHandled является private-полем. Ок, можно достучаться до него через механизм reflection-а, но это уже другая история.



Navigation Component не поддерживает диплинки с условием из коробки. Если вы хотите поддержать такое поведение, Google предлагает действовать следующим образом:


  • через диплинк открыть экран, который требует авторизацию;
  • на этом экране проверить, авторизован ли пользователь, если нет – открыть флоу авторизации поверх нужного экрана;
  • пройти auth flow, вернуть результат из вложенного графа и т.д., и т.п.

Возможности глобально решить мою задачу средствами Navigation Component-а я не нашёл.


Выводы по работе с диплинками в Navigation Component


  • Работать с ними больно, если требуется добавлять дополнительные действия или условия.
  • Объявлять диплинки ближе к месту их назначения – классная идея, в разы удобнее AndroidManifest-а со списком поддерживаемых ссылок.

Бонус-секция – кейсы БЕЗ проблем


Чтобы разбавить негатив от предыдущей секции, я покажу пару кейсов, с которыми вообще не было проблем. А я их ждал. Но их не было.



Допустим, у вас есть экран вакансий, с которого вы можете перейти на другую вакансию.


Где на схеме приложения этот кейс?


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


<fragment
  android:id="@+id/VacancyFragment"
  android:name="com.aaglobal.jnc_playground.ui.vacancy.VacancyFragment"
  android:label="Fragment vacancy"
  tools:layout="@layout/fragment_vacancy">

  <argument
      android:name="vacancyId"
      app:argType="string"
      app:nullable="false" />

  <action
      android:id="@+id/action__VacancyFragment__to__VacancyFragment"
      app:destination="@id/VacancyFragment" />

</fragment>

И этого оказалось достаточно – новый экран открывался поверх старого, при нажатии на кнопку Back навигация была корректной. Если бы я захотел, чтобы каждый новый экран открывался вместо текущего, было бы достаточно добавить атрибут popUpTo к моему action-у.



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


Где на схеме приложения этот кейс?


Я добавил контейнер для будущего фрагмента со списком в вёрстку вкладки нижней навигации:


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/fragment_favorites_container__text__title"
        style="@style/LargeTitle"
        android:text="Favorites container" />

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/fragment_favorites_container__container__recommend_vacancies"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

А затем в runtime-е добавил нужный мне фрагмент в этот контейнер:


class FavoritesContainerFragment : Fragment(R.layout.fragment_favorites_container) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        childFragmentManager.attachFragmentInto(
          containerId = R.id.fragment_container_view,
          fragment = createVacancyListFragment()
        )

    }
}

Метод attachFragmentInfo на childFragmentManager – это extension-метод, который просто оборачивает всю работу с транзакциями, не более того.


А вот как я создал фрагмент:


class FavoritesContainerFragment : Fragment(R.layout.fragment_favorites_container) {

    // ...
    private fun createVacancyListFragment(): Fragment {
        return VacancyListFragment.newInstance(
          vacancyType = "favorites_container",
          vacancyListRouterSource = object : VacancyListRouterSource {
              override fun navigateToVacancyScreen(item: VacancyItem) {
                  findNavController().navigate(
                      R.id.action__FavoritesContainerFragment__to__VacancyFragment,
                      VacancyFragmentArgs(vacancyId = "${item.name}|${item.id}").toBundle()
                  )
              }
        }
     }

}

Вся соль в пробросе реализации интерфейса для навигации – я инициализирую нужный мне фрагмент правильным навигатором, который будет срабатывать при нажатии на элемент списка.



Пусть у меня есть несколько BottomSheetDialog-ов, между которыми я хочу перемещаться с помощью Navigation Component.


Где на схеме приложения этот кейс?


Год назад с таким кейсом были какие-то проблемы, но сейчас всё работает как надо. Можно легко объявить какой-то dialog в качестве destination-а в вашем графе навигации, можно добавить action для открытия диалога из другого диалога.


<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/menu__favorites"
    app:startDestination="@id/FavoritesContainerFragment">
   <dialog
        android:id="@+id/ABottomSheet"
        android:name="ui.dialogs.dialog_a.ABottomSheetDialog">
        <action
            android:id="@+id/action__ABottomSheet__to__BBottomSheet"
            app:destination="@id/BBottomSheet"
            app:popUpTo="@id/ABottomSheet"
            app:popUpToInclusive="true" />
    </dialog>

    <dialog
        android:id="@+id/BBottomSheet"
        android:name="ui.dialogs.dialog_b.BBottomSheetDialog">
        <action
            android:id="@+id/action__BBottomSheet__to__ABottomSheet"
            app:destination="@id/ABottomSheet"
            app:popUpTo="@id/BBottomSheet"
            app:popUpToInclusive="true" />
    </dialog>
</navigation>

Диалоги создавались как нужно, закрывались вовремя, навигация по кнопке Back отрабатывала как ожидалось.


Выводы по бонус-секции


Кейсы без проблем – существуют.


Подведём итоги


На данный момент нет никакой причины переводить большое приложение на Navigation Component. Слишком много проблем, слишком много костылей, постоянно нужно выдумывать что-то для осуществления не самых сложных кейсов навигации. Сам факт, что я ухитрился написать так много текста про проблемы с Navigation Component-ом, что-то да говорит.


Если у вас маленькое приложение, надо внимательно посмотреть на то, какие кейсы навигации вы хотите поддержать. Если задумываетесь про нижнюю навигацию, про диплинки – пожалуй, лучше реализовать всё по-старинке: либо руками, либо на Cicerone. В остальных случаях, если вас не пугает необходимость постоянно искать фиксы, можно воспользоваться Navigation Component-ом.


Пример приложения на Github-е лежит здесь.


Полезные ссылки по теме