Привет, Хабр! Я Дмитрий Воронов из Doubletapp, в этой статье расскажу, как мы делали навигацию в Яндекс Путешествиях. С навигацией в Android, кажется, давно все ясно: бери Jetpack Navigation, читай официальную документацию и следуй ей — и все получится. Если рекомендованная библиотека не подходит — берешь Fragment Manager, прописываешь собственную реализацию и идешь хвастаться коллегам. Если писать свою реализацию нет желания, а официальная библиотека не соответствует модным веяниям — дополняешь свое резюме умением работать с Cicerone. Если твои вкусы специфичны — почему бы не удивить людей неожиданным добавлением в проект Alligator?

В одном коротком абзаце удалось обозначить сразу 4 варианта реализации навигации и, казалось бы, в чем вопрос? Каждый выбирает для своего проекта подходящий ему вариант. Все так, но ровно до того момента, пока не возникает необходимость «поделиться» частью приложения — интегрироваться в другое приложение, и там, как оказывается, другая реализация навигации. И здесь начинается: «Что будем делать? Попробуем написать мост? А может, лучше перепишем навигацию?»

На этапе проектирования лучше сразу уточнить возможность дальнейшей интеграции с другими приложениями и заранее к этому подготовиться. Помимо всего прочего, нужно подготовить и навигацию, и сделать ее «внешней» — одно из возможных решений этой задачи.

Что за «внешняя» навигация?

Если мы пишем многомодульное приложение, перед нами встает вопрос, как организовать связь между этими модулями, и здесь вроде бы все понятно: как бы ни были разбиты на модули фичи, у них будет какое-то API (одно или несколько) и его реализация (опять-таки, одна или несколько). Однако есть вопрос: а должна ли навигация быть частью этого API? Ответ прост: нет, не должна. Ведь тогда фича должна будет сама уметь выполнять навигацию, а это нарушает принципы единственной ответственности и инкапсуляции. За реализацию навигации должна отвечать отдельная или выделенная часть приложения, а фиче нужно лишь дать возможность «заявить» о том, что она намерена выполнить навигацию в другую фичу. Отсюда несложно сделать вывод: навигация должна быть внешней зависимостью фичи, а реализовывать эту зависимость должна фича навигации — отдельный модуль приложения, который мы и будем называть «внешней» навигацией. 

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

Конечно, «с легкостью» интегрировать куда-то свои модули получится только при соблюдении множества других правил, помимо абстрагирования навигации, но полноценное описание всех нюансов тянет на отдельную серию статей, поэтому в рамках этой мы будем рассматривать только навигацию.

Итак, с вводными данными разобрались, а значит, самое время рассмотреть детали реализации.

Реализуем «внешнюю» навигацию

Итак, вы выяснили, что фичи приложения могут быть интегрированы в другой сервис в будущем. Перед вами стоит задача максимально снизить связность и зависимость модулей между собой. Помимо всего прочего, будем решать проблему навигации, закрывая ее за «чистыми» интерфейсами, чтобы при переносе модулей куда бы то ни было осталось только реализовать интерфейсы на свой лад. Прежде чем приступать к реализации и написанию кода, давайте обрисуем и сформулируем предстоящее решение. Будем рассматривать реализацию на примере классического Android-приложения. Ниже представлена схема решения, давайте разберем ее.

Ремарка: стрелки на схеме указывают на зависимость модулей (идут от зависимого модуля к модулю, от которого он зависит). Вверху мы видим привычный app-модуль. Пусть у нас есть какие-то условные Feature 1, Feature 2 и Feature 3, которые подключены к app-модулю. Это будут 3 некоторых экрана, которые представляют определенный флоу (например, это может быть флоу авторизации или любой другой). На схеме фичи представлены цельными блоками, но здесь сделаем оговорку: фичи могут быть организованы как действительно цельные модули, так и совокупность модулей: например, как модули api и impl, или как совокупность нескольких модулей domain, data и presentation в соответствии с чистой архитектурой. Сейчас для нас это не важно, мы будем рассматривать эти фичи в качестве абстрактных понятий. 

Что для нас действительно важно относительно этих фичей — это то, что у них есть внешние зависимости, и одна из таких зависимостей — интерфейс навигации. Он лежит в модуле navigation-api, на который ссылаются все фичи. Это чистый Kotlin-модуль, внутри которого объявлена следующая абстракция:

interface NavigationApi <DIRECTION> {
   fun navigate(direction: DIRECTION)
}

Этот интерфейс мы поставляем в фичи. В качестве дженерика DIRECTION каждая фича объявляет публичные абстракции, которые соответствуют возможным «направлениям», в которых эта фича может выполнять навигацию. Предположим например, что фича 1 может выполнять навигацию к фиче 2. Тогда ее «направления» можно представить следующей абстракцией:

sealed interface Feature1Directions {
   object ToFeature2 : Feature1Directions
}

Тогда внешние зависимости для фичи 1 будут выглядеть так:

interface Feature1Dependencies {
   val navigationApi: NavigationApi<Feature1Directions>
   // Другие зависимости ...
}

В свою очередь фича 2 может выполнить навигацию к фиче 3 и действие «назад». Тогда ее направления навигации будут выглядеть так:

sealed interface Feature2Directions {
   object Up : Feature2Directions
   data class ToFeature3(val args: Feature2To3Args) : Feature2Directions
}
data class Feature2To3Args(
   val someArg1: Int,
   val someArg2: String,
)

И наконец пусть третья фича будет выполнять действие «назад» и возвращаться к первой фиче, то есть, делать «сброс» всего флоу из трех экранов до начального состояния — первого экрана. Тогда ее направления навигации будут выглядеть так:

sealed interface Feature3Directions {
   object Up : Feature3Directions
   object UpToFeature1 : Feature3Directions
}

Ниже представлена схема только что описанного взаимодействия экранов:

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

fun someFunction() {
   // Предшествующий код ...
   navigationApi.navigate(Feature1Directions.ToFeature2)
}

Реализуем модуль навигации

Начнем с простого и понятного: с организации зависимостей. Понятно, что модуль навигации, чтобы реализовать взаимодействие фичей, должен ссылаться на модули с фичами и на API навигации. А чтобы модуль навигации был включен собственно в приложение, на него должен ссылаться app-модуль. Здесь разобрались.

Как уже говорилось выше, для навигации может быть выбран любой механизм. Для простоты используем стандартный подход: официальная библиотека Jetpack Navigation. Здесь все просто: создаем граф навигации, прописываем переходы между экранами, указываем аргументы, для удобства сразу подключаем Safe Args. Так как модуль с внешней навигацией подключает модули реализации конкретных фичей (см. схему зависимостей выше), нам в этом модуле доступны фрагменты, из которых мы создадим привычные нам Destination’ы.

Далее следует реализовать те самые интерфейсы навигации для фичей, которые объявили ранее. Для примера возьмем реализацию навигации для второй фичи:

internal class Feature2NavigationImpl @Inject constructor(
   private val navController: Provider<NavController>,
): NavigationApi<Feature2Directions> {

   override fun navigate(direction: Feature2Directions) {
       when (direction) {
           is Feature2Directions.ToFeature3 -> {
               navController.get().navigate(
                   Feature2FragmentDirections.fromFeature2ToFeature3(
                       args = direction.args.toFeature3Args(),
                   )
               )
           }
           is Feature2Directions.Up -> {
               navController.get().navigateUp()
           }
       }
   }

   companion object {
       private fun Feature2To3Args.toFeature3Args(): Feature3Args = Feature3Args(
           value = "$someArg2 : $someArg1"
       )
   }
}

В переопределенном методе navigate() проходимся по каждому из направлений и выполняем соответствующее действие. Для направления Up просто вызываем привычный navigateUp() у NavController, а для направления ToFeature3 — navigate(). 

Интерес представляет функция расширения для аргументов toFeature3Args(). Получается, что вторая фича передает в направление ToFeature3 свою модель с аргументами — Feature2To3Args, а функция маппит аргументы в модель из фичи 3 — Feature3Args. Таким образом, фичи остаются максимально независимыми друг от друга.

Как видите, ничего сложного: прошлись по всем направлениям, смаппили аргументы, сделали соответствующие вызовы NavController, и полученный класс заинжектили в компонент, если используете инъекцию зависимостей, как в данном примере. В этом случае интереснее всего здесь то, каким образом передать в конструктор абстракцию (или реализацию) механизма навигации. Давайте разберемся.

В любом случае абстракцию навигации нужно брать из слоя представления: фрагмента, активити. Представление в Android имеет свойство «умирать» — просто так не заинжектить, однако способы все же есть. Итак, фрагменты и активити имеют свойство пересоздаваться, значит просто передать инстанс не получится, нужно передавать «способ получения». Именно поэтому в примере выше в конструктор не инжектится NavController напрямую — используется Provider. Откуда провайдер может взять NavController? Из фрагмента, который лежит в модуле навигации и содержит в себе NavHost. Ну или из активити, которая содержит в себе NavHost, но мы тут так-то навигацию в отдельный модуль выносим, да и вообще у нас Single Activity, поэтому остановимся на фрагменте.

Итак, в app-модуле лежит MainActivity, которая отображает NavigationFragment, который лежит в модуле навигации и содержит NavHost. Вот такая схема:

Отсюда следует, что для доступа к навигации из NavHost нам нужно «подавать» источник, то есть активити. Прежде всего объявим в модуле навигации интерфейс:

interface NavigationActivity {

   fun getNavigationFragment(): NavigationFragment?
}

Этот интерфейс позволит получать доступ к фрагменту навигации, и его реализует MainActivity следующим образом:

override fun getNavigationFragment(): NavigationFragment? = supportFragmentManager.fragments
   .filterIsInstance<NavigationFragment>()
   .firstOrNull()

Теперь мы можем получить фрагмент навигации из активити. Нужно найти способ получать саму активити. Это можно сделать с помощью Application.ActivityLifecycleCallbacks. В качестве внешней зависимости для модуля навигации объявим вот такой класс:

class NavigationActivityProvider(application: Application) {

   private var activityReference: WeakReference<NavigationActivity>? = null

   fun get(): NavigationActivity? = activityReference?.get()

   init {
       registerActivityCallbacks(application)
   }

   private fun registerActivityCallbacks(application: Application) {
       application.registerActivityLifecycleCallbacks(
           object : Application.ActivityLifecycleCallbacks {
               override fun onActivityCreated(activity: Activity, p1: Bundle?) {
                   if (activity is NavigationActivity) {
                       activityReference = WeakReference(activity)
                   }
               }

               override fun onActivityDestroyed(activity: Activity) {
                   if (activity is NavigationActivity) {
                       activityReference = null
                   }
               }

               …
           }
       )
   }
}

Тогда, передав в конструктор Application, в модуле навигации мы сможем получить доступ к NavigationActivity, из нее — к NavigationFragment, а из NavigationFragment уже непосредственно к NavController, вот так:

class NavigationFragment : Fragment() { 

   val navController: NavController by lazy {
       (childFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment).navController
   } 

   …
}

Все что нам остается — запровайдить этот «способ доступа» в модуле навигации:

@Module
internal class NavigationImplModule {

   @Provides
   fun provideNavController(
       activityProvider: NavigationActivityProvider,
   ): NavController = activityProvider.get()
       ?.getNavigationFragment()
       ?.navController
       ?: error("Do not make navigation calls while activity is not available")
}

И все — можем использовать в реализациях NavigationApi для различных фич!

Небольшая ремарка: если вам все же потребуется сделать «внутреннюю» навигацию в каком-то фича-модуле, вы можете реализовать ее внутри конкретного фича-модуля — модифицируйте или расширяйте подход под ваши задачи.

В завершение

Лучше один раз увидеть, чем сто раз услышать, поэтому вот здесь можно найти пример описанной в статье «внешней» навигации. В нем нет ничего лишнего, но есть инъекция зависимостей, реализованная на Dagger 2 с Component Holder’ами, базовые классы для которого лежат в модуле di — это лишь один из вариантов того, как можно организовать зависимости в многомодульном проекте. Что-то подобное можно найти в этой статье. Подробнее можно почитать о кейсе у нас на сайте или скачать приложение для iOS или Android и протестировать самому. Если остались вопросы или хотите рассказать, какую навигацию используете на проектах, — обсудим в комментах.

Комментарии (3)