Введение: зачем навигации нужна модульность

По мере роста Android-приложений главный модуль app превращается в раздутый монолит. Это влияет на время сборки, ограничивает параллельную разработку и превращает основной граф навигации (nav_graph.xml) в нечитаемую путаницу.

Решение? Модульность.

Разделив приложение на независимые feature-модули (например, :feature:profile:feature:settings), вы получаете значительные преимущества:

  • Более быструю сборку: с Gradle можно параллельно собирать небольшие модули.
  • Развязка: модули не знают (и не должны знать) деталей реализации друг друга.
  • Четкое разделение ответственности: команды владеют всей полнотой функциональности, включая навигационный поток.

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

1. Основа: Вложенные графы (<include>)

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

Пример структуры модулей

МодульНазначениеФайл
:feature:profile Содержит все экраны Профиляres/navigation/profile_nav_graph.xml
:appСодержит главный NavHost и ссылается на все графы feature-модулейres/navigation/main_nav_graph.xml

Пример на Kotlin: включение графа функции

Модуль :feature:profile предоставляет самодостаточный граф, например, такой (в profile_nav_graph.xml):

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/profile_nav_graph"
    app:startDestination="@id/profileStartFragment">

    <fragment
        android:id="@+id/profileStartFragment"
        android:name="com.example.profile.ProfileStartFragment"
        android:label="ProfileStart" />
    
    <fragment
        android:id="@+id/editProfileFragment"
        android:name="com.example.profile.EditProfileFragment"
        android:label="EditProfile" />

    <action
        android:id="@+id/action_to_edit_profile"
        app:destination="@id/editProfileFragment" />
        
</navigation>

Основной модуль :app просто включает этот граф в свой файл main_nav_graph.xml:

<?xml version="1.0" encoding="utf-8"?>
<navigation ... >
    
    <include app:graph="@navigation/profile_nav_graph" />

    <fragment
        android:id="@+id/homeFragment"
        android:name="com.example.app.HomeFragment"
        android:label="Home" >
        
        <action
            android:id="@+id/action_to_profile"
            app:destination="@id/profile_nav_graph" />
            
    </fragment>
    
</navigation>

2. Реальная развязка: навигация через Deep Links (API-контракт)

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

Ответ — использовать Deep Links (глубокие ссылки). Мы рассматриваем URI Deep Link как публичный API-контракт для назначения (destination); он позволяет модулям взаимодействовать без привязки к идентификаторам ресурсов.

Важное пояснение: область видимости NavController

При реализации feature-потоков:

  • Каждый feature-модуль может содержать собственный NavController, если включает в себя несколько внутренних экранов. Это подразумевает использование вложенного NavHostFragment или NavHost (для Compose) в UI этого модуля.
  • Или же все точки назначения (destinations) могут оставаться под управлением единого, глобального NavController, если функциональность собрана как простые destinations в основном графе.

Пример на Kotlin: навигация через Deep Link

  1. Определение контракта (в XML целевого модуля)

В файле settings_nav_graph.xml модуля :feature:settings определите глубокую ссылку (deep link), которая будет служить публичной точкой входа:

<fragment
    android:id="@+id/accountSettingsFragment"
    android:name="com.example.settings.AccountSettingsFragment"
    android:label="AccountSettings">

    <deepLink
        android:id="@+id/deep_link_to_settings"
        app:uri="myapp://features/account_settings/{userId}" />

    <argument
        android:name="userId"
        app:argType="string" />

</fragment>

⚠️ Примечание по определению хоста: чтобы эта глубокая ссылка работала внутри приложения, целевая точка назначения (destination) (или содержащий ее граф) должна быть включена в собранный глобальный граф навигации, который использует главный NavHost.

2. Осуществите навигацию (из кода Kotlin исходного модуля)

Во ViewModel или Fragment развязанного модуля :feature:home осуществляем навигацию, используя URI:

// Эта функция находится внутри компонента независимого модуля :feature:home
fun navigateToSettings(userId: String, navController: NavController) {

    // 1. 1. Формируем URI с использованием согласованного публичного контракта
    val deepLinkUri = "myapp://features/account_settings/$userId".toUri()

    // 2. Создаем NavDeepLinkRequest
    val request = NavDeepLinkRequest.Builder
        .fromUri(deepLinkUri)
        .build()

    // 3. Выполняем навигацию с использованием запроса (без знания ID ресурсов!)
    navController.navigate(request)

    // Мы успешно перешли из :feature:home в :feature:settings 
    // без зависимости :feature:home от id ресурса модуля :feature:settings
}

3. Аргументы Deep Link и типобезопасная модульная навигация

Хотя Deep Links идеально развязывают модули, существует распространенное заблуждение относительно передачи данных.

Аргументы Deep Link поддерживают не только строки. Компонент Navigation позволяет передавать множество примитивных типов напрямую через аргументы URI, включая string, int, long, float и boolean.

Советы для достижения истинной типобезопасности между модулями при использовании глубоких ссылок:

  1. Централизируйте URI: определяйте все межмодульные URI глубоких ссылок в общем модуле :core:navigation в виде констант.
  1. Инкапсулируйте навигацию: создайте интерфейс Navigator (или изолированный класс Directions) в общем модуле. Feature-модули будут вызывать эту высокоуровневую абстракцию, а модуль :app предоставит реализацию, которая обрабатывает построение URI и navController.navigate(). Это фактически возвращает типобезопасность в точку вызова.

4. Следующий рубеж: динамическая сборка через внедрение зависимостей (продвинутый уровень — акцент на Compose)

Ключевая идея для достижения максимально масштабируемой архитектуры: не хардкодить сборку графа в XML.

Вместо того, чтобы вручную включать в основной модуль :app каждый граф, реализуется следующее: каждый feature-модуль отвечает за предоставление своих точек назначения (своих «конструкторов точек входа» или маршрутов) главному приложению через внедрение зависимостей (DI), как правило, используя механизм множественных привязок в библиотеке Hilt (@IntoSet).

Реализация в XML / Compose

  • ХML-навигация (View): полное динамическое построение графа во время выполнения с использованием DI сильно ограничено или невозможно в Jetpack Navigation, основанном на XML. Как правило, вы ограничены статическим включением через <include> или разрешением Deep Link.
  • Compose-навигация (и Navigation 3.x): данный паттерн раскрывается здесь в полной мере. Каждый feature-модуль динамически регистрирует свои composable-назначения (destinations), что делает архитектуру чрезвычайно гибкой и позволяет избежать больших, монолитных определений графа.

Переосмысление разделения на API / Impl (Compose / Navigation 3.x)

  • :feature:settings:api: содержит только высокоуровневый объект NavKey или Route (контракт навигации).
  • :feature:settings:impl: содержит реализацию экрана (Compose) и код Dagger / Hilt, который регистрирует функцию-билдер этого экрана с набором для сборки основного графа.

Модуль :app затем использует DI, чтобы собрать все эти зарегистрированные компоненты в Set и динамически построить полный граф навигации во время выполнения. Это создает максимально масштабируемую, развязанную и тестируемую архитектуру навигации.

Часто задаваемые вопросы (FAQ)

1. В чем основное различие между вложенным графом (<include>) и Deep Link?

  • Вложенный граф через <include> связывает основной граф с файлом графа навигации feature-модуля, требуя знания главного приложения об этом файле.
  • Deep Link, напротив, полностью развязывает модули, позволяя одному модулю переходить к другому без зависимости времени компиляции от ID точки назначения (destination). Нужно знать только строку URI.

2. Что, если мне нужно передавать сложные объекты между модулями?

Передача больших или сложных объектов через аргументы Deep Link (которые ограничены простыми примитивами или строками) категорически не рекомендуется. 

Для сложных данных используйте один из следующих подходов:

  1. Передавайте только ID: передавайте простой id через deep link, а затем используйте общий источник данных (например, репозиторий или общую ViewModel), чтобы получить сложный объект уже в целевом модуле.
  2. Используйте общий модуль: разместите сложный класс данных Parcelable или Serializable в общем модуле :core:model, от которого могут зависеть оба feature-модуля.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Sivavishnu: Beyond Monolith: Mastering Modular Navigation in Android (Kotlin & Jetpack Nav Component)

Предыдущая статьяСоздание простой платформы потоковой передачи данных
Следующая статья5 проверенных методов для создания сверхбыстрых трейдинговых систем с низкой задержкой на Java