Летаем по модулям: Навигация в многомодульном приложении с Jetpack
Почти каждый растущий проект рано или поздно начинает смотреть в сторону многомодульной архитектуры. Разработчики не хотят ждать пока пересобирается полностью весь проект, когда была изменена только одна фича. Многомодульность помогает изолировать фичи приложения друг от друга, тем самым сокращая время сборки. Но такое изолирование накладывает некоторые ограничения на область видимости компонентов. Когда мы используем навигацию из Jetpack в проекте с одним модулем, граф навигации доступен из любого пакета приложения, мы всегда можем явно указать какой action NavController должен выполнить, а также есть доступ к глобальному хосту, если в проекте есть вложенные фрагменты. Но когда модулей становится много, то возникают вопросы: где строить граф навигации, как получать к нему доступ и как не запутаться в зависимостях модулей. Обо всем этом поговорим под катом.
Самое важное о чем надо помнить при проектировании многомодульного приложения — зависимости. Зависимости в дереве зависимостей модулей должны быть быть направлены в одну сторону.
Самым зависимым модулем в многомодульном приложении всегда является модуль app. Он знает почти о всех остальных модулях. В app обычно реализовывают DI с помощью различных фреймворков. Пользуясь такой зависимостью app модуля, в нем можно реализовать граф навигации основного хоста.
Всегда нужно помнить, что app модуль должен реализовывать как можно меньше функционала, так как он является самым зависимым и почти любое изменение в проекте приведёт к пересборке app модуля.
И сразу к реальному примеру. Наш кейс: точкой входа в приложение является экран 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, в многомодульной архитектуре. Если остались вопросы — то я с радостью отвечу на них в комментариях :)