Летаем по модулям: Навигация в многомодульном приложении с Jetpack


    Почти каждый растущий проект рано или поздно начинает смотреть в сторону многомодульной архитектуры. Разработчики не хотят ждать пока пересобирается полностью весь проект, когда была изменена только одна фича. Многомодульность помогает изолировать фичи приложения друг от друга, тем самым сокращая время сборки. Но такое изолирование накладывает некоторые ограничения на область видимости компонентов. Когда мы используем навигацию из Jetpack в проекте с одним модулем, граф навигации доступен из любого пакета приложения, мы всегда можем явно указать какой action NavController должен выполнить, а также есть доступ к глобальному хосту, если в проекте есть вложенные фрагменты. Но когда модулей становится много, то возникают вопросы: где строить граф навигации, как получать к нему доступ и как не запутаться в зависимостях модулей. Обо всем этом поговорим под катом.


    Граф навигации


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



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


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


    Talk is cheap. Show me the code


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



    Доступ к навигации внутри модуля


    Когда приходит время делать переход с одного экрана на экран в другом модуле, возникает вопрос — как?


    Ведь внутри модуля фичи нет доступа к графу навигации для получения action id, которое должен выполнить NavController.


    Это решается путем внедрения DI с помощью интерфейсов. Вместо того, чтобы модуль фичи зависел от глобального графа навигации из app модуля — мы создадим интерфейс и назовём его ЧтоТоNavCommandProvider, переменные которого — команды навигации.


    SplashNavCommandProvider.kt


    interface SplashNavCommandProvider {
       val toAuth: NavCommand
       val toMain: NavCommand
    }

    Сам интерфейс провайдера команд будет реализовываться в app модуле, а класс команды навигации будет иметь те же поля, что и аргументы для метода NavController.navigate


    NavCommand.kt


    data class NavCommand(
       val action: Int,
       var args: Bundle? = null,
       val navOptions: NavOptions? = null
    )

    Посмотрим как это выглядит на практике. С экрана splash возможно 2 перехода: к экрану авторизации и к экрану основного функционала. В модуле splash создаем интерфейс:


    SplashNavCommandProvider.kt


    interface SplashNavCommandProvider {
       val toAuth: NavCommand
       val toMain: NavCommand
    }

    В модуле app создаем реализацию этого интерфейса и с помощью di фреймворка (у меня это Dagger) предоставляем её через интерфейс splash модулю.


    SplashNavCommandProviderImpl.kt — реализация CommandProvider


    class SplashNavCommandProviderImpl @Inject constructor() : SplashNavCommandProvider {
       override val toAuth: NavCommand = NavCommand(R.id.action_splashFragment_to_authFragment)
       override val toMain: NavCommand = NavCommand(R.id.action_splashFragment_to_mainFragment)
    }

    SplashNavigationModule.kt — DI модуль для предоставления зависимости


    @Module
    interface SplashNavigationModule {
       @Binds
       fun bindSplashNavigator(impl: SplashNavCommandProviderImpl): SplashNavCommandProvider
    }

    AppActivityModule.kt — основной DI модуль приложения


    @Module
    interface AppActivityModule {
       @FragmentScope
       @ContributesAndroidInjector(
           modules = [
               SplashNavigationModule::class
           ]
       )
       fun splashFragmentInjector(): SplashFragment
    …
    }

    В splash модуль реализацию внедряем в MV(сюда) это либо Presenter, либо ViewModel…


    SplashViewModel.kt


    class SplashViewModel @Inject constructor(
       private val splashNavCommandProvider: SplashNavCommandProvider
    ) ...

    Когда логика экрана считает, что пора переходить к другому экрану — мы передаём нашему фрагменту команду и сообщаем что надо выполнить переход на другой экран.


    Можно было бы внедрять реализацию SplashNavCommandProvider прямо во фрагмент, но тогда мы лишаемся возможности тестировать навигацию.


    В самом фрагменте для выполнения перехода надо получить NavController. Если текущий экран не вложенный фрагмент, то просто получаем NavController методом findNavController() и вызываем у него метод navigate:


    findNavController().navigate(toMain)

    Можно сделать немного удобнее, написав экстеншен для фрагмента


    FragmentExt.kt


    fun Fragment.navigate(navCommand: NavCommand) {
       findNavController().navigate(navCommand.action, navCommand.args, navCommand.navOptions)
    }

    Почему только для фрагмента? Потому что я использую подход SingleActivity, если у вас их несколько, то можно создать экстеншены еще и для Activity.


    Тогда навигация внутри фрагмента будет выглядеть так


    navigate(toMain)

    Вложенные фрагменты


    Навигация во вложенных фрагментах может быть двух видов:


    • Переход во вложенном контейнере
    • Переход в контейнере на один или несколько уровней выше. Например, глобальный хост активити

    В первом случае все просто, нам подойдёт экстеншен который мы написали выше. А для выполнения перехода во втором случае необходимо получить NavController нужного хоста. Для этого внутри модуля надо получить id этого хоста. Так как к нему есть доступ только у модуля, в котором реализован граф навигации этого хоста, то создадим зависимость и внедрим её в модули фич, где нужен доступ к конкретному NavController, через Dagger.


    GlobalHostModule.kt — DI модуль для предоставления зависимости id глобального хоста


    @Provides
    @GlobalHost
    fun provideGlobalHostId(): Int = R.id.host_global

    AppActivityModule.kt — основной DI модуль приложения


    @FragmentScope
    @ContributesAndroidInjector(
       modules = [
           GlobalHostModule::class,
           ProfileNavigationModule::class,
           ...
       ]
    )
    fun profileKnownFragmentInjector(): ProfileKnownFragment

    Внедрение зависимости id хоста во фрагмент


    @Inject
    @GlobalHost
    var hostId = 0

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


    GlobalHost.kt


    @Qualifier
    @Retention(AnnotationRetention.RUNTIME)
    annotation class GlobalHost

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


    FragmentExt.kt


    fun Fragment.navigate(navCommand: NavCommand, hostId: Int? = null) {
       val navController = if (hostId == null) {
           findNavController()
       } else {
           Navigation.findNavController(requireActivity(), hostId)
       }
       navController.navigate(navCommand.action, navCommand.args, navCommand.navOptions)
    }

    Код во фрагменте


    navigate(toAuth, hostId)

    Это были основные моменты организации навигации, используя Jetpack, в многомодульной архитектуре. Если остались вопросы — то я с радостью отвечу на них в комментариях :)

    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 11

      0

      Офтоп. Почему нет учебников или статей между: как создать первое приложение и вот такими статьями? Или после туториалов сразу наступает полное понимание архитектуры? Сам с этим столкнулся, начинаешь по чуть-чуть, а потом сразу десятки модулей.

        0
        Есть много статей-туториалов на тему навигации с Jetpack, внедрения зависимостей, многомодульности и многое другое. Понимание как это все композировать — результат проб и ошибок каждого разработчика, не у всех есть время и желание писать статьи обо всем этом. Лучший способ получить опыт в профессиональной разработке — попасть в команду, где большая часть команды — сильнее тебя. Сложные задачи и советы опытных ребят дают хороший буст)
        0
        Подход достаточно логичный, при отсутствии DI фреймфорка можно просто через какую-нибудь init() функцию подсунуть реализацию нужных интерфейсов. Не сталкивались ли с проблемой навигации в dynamic-feature модулях? Там ведь зависимость обратная, все модули зависят от app и никак друг с другом не связаны.
          0
          Я сам ни разу не работал с dynamic delivery, но все что нужно сделать, чтобы этот подход работать — создать новый зависимый модуль вместо app. На тему навигации с dynamic delivery есть статья, причем подход с гад модулем автор считает громоздким)
          0
          Очень полезная и интересная статья! Спасибо!
            0

            в случае с Dynamic Delivery Feature тоже модуль app самый зависимый?

              0
              В случае с Dynamic Delivery Feature модуль app является самым зависимым — как core. Соответственно нужен новый модуль, который будет знать о всех фичах, такой как app сейчас. Есть статья на эту тему, там объясняется как построить навигацию с dynamic delivery, причем подход с гад модулем автор считает громоздким)
              0

              Привет, а можно ссылку на гит к коду из статьи, если есть .

              0
              Зачем писать свой
              NavCommand
              вместо использования плагина Safe Args? И вообще как с ним быть в подобной реализации?
                0
                Свои NavCommand нужны для одновременного внедрения
                1. id action
                2. args
                3. navOptions
                для выполнения одной команды навигации.
                Если использовать Safe Args, то первые два параметра можно можно объединить в один, но если понадобится navOptions, то его придется передавать отдельно, поэтому NavCommand класс полезен)

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое