Всем привет! Я на Мосбирже занимаюсь мобильной разработкой под Android. Осенью этого года мы начали разрабатывать приложение для платформы личных финансов Финуслуги и воспользовались возможностью делать UI сразу на Jetpack Compose. Как и всегда, сразу встал вопрос выбора архитектуры многомодульности и механизма навигации. Решение должно быть, с одной стороны, достаточно лаконичным и понятным для новых разработчиков. С другой стороны, оно должно быть масштабируемым, чтобы рост числа и размера модулей не создавал неприятностей, таких как раздражающее времени сборки или частые merge-конфликты.
После изучения документации, примеров с Сompose от Google и поиска решений в сети было принято решение использовать библиотеку Jetpack Compose Navigation. Во-первых, она развивается Google. Во-вторых, это достаточно гибкий инструмент, удовлетворяющий современным потребностям: гибкая работа с back stack, простая интеграция с Bottom Nav Bar, анимации перехода, механизм интеграции с диплинками. В-третьих, на наш взгляд, библиотека интуитивно понятная и имеет низкий порог вхождения.
Здесь мы на простом примере хотим поделиться тем, как мы начали делать многомодульное приложение с Jetpack Compose Navigation.
Зачем нужны библиотеки навигации
Зачем нам нужны библиотеки навигации в Compose? Кажется, Compose позволяет легко отрисовать State внутри контейнера.
Что нам нужно будет учесть, если решим написать свое решение для навигации?
- Смена видимого экрана (composable-функция во весь экран) из кода или по факту внешнего события навигации. Здесь прозвучало слово “Экран”, это значит в коде должна быть некоторая модель, описывающая экран.
- Управление стеком экранов — возможность читать и изменять состояние. Возможность делать навигацию вперед и назад. Это значит, что должна быть модель, описывающая стек и методы взаимодействия с ним.
- Сохранение состояния навигации при пересоздании Activity. Обычно в библиотеках навигации для Compose для этого используется SavedStateHandle.
- Управление ЖЦ ViewModel и интеграция с DI, которая поставляет свою ViewModelFactory.
Хорошо если есть:
- Удобная передача аргументов
- Анимации смены экрана
Теория
Основная идея может напоминать web — у каждого экрана есть “ссылка”.
Каждому экрану (composable-функции) ставится в соответствие ссылка — route — строка, которую нужно знать, чтобы произвести навигацию на пункт назначения — destination. В качестве destination может быть composable-экран, диалог или вложенный граф. Это все. Достаточно понимать это и знать несколько классов для работы с этой концепцией.
Рассмотрим основные сущности этой библиотеки. Здесь будет пересказ документации, так что тем, кто уже имеет опыт с этим инструментом, можно переходить дальше.
Destination — composable-экран, диалог или вложенный граф, на который производится навигация.
Route — строка — ссылка до экрана. В отличии с Jetpack Navigation for Fragments навигация происходит только через ссылки. Передаваемые аргументы прописываются в этой же строке по аналогии с web (альтернативой может являться сохранение Bundle в back stack). Пример рассмотрим далее.
NavController — класс, основная сущность, через которую происходит навигация. В "корневой" composable создается один инстанс, singletone в пределах контейнера.
NavHost — контейнер графа навигации — composable-функция, в которой производится связывание route c destination. Это замена описания графа в xml как в Jetpack Navigation for Fragments.
Более подробное погружение в возможности библиотеки было сделано в этой лекции Android Academy Jetpack Compose #6: Navigation и практической части.
Этого достаточно для старта! Приступим к коду..
Шаг 1. Создание проекта
Для работы с Compose нужно поставить Android Studio Arctic Fox и выше. Создаем шаблонный проект "Empty Compose Activity".
Скорее всего IDE предложит вам обновить версии библиотек, это поначалу может привести к конфликтам версий при сборке.
В Github-репозитории примера вы можете посмотреть исходный код и версии библиотек, с которыми проходит сборка.
Двигаемся дальше..
Шаг 2. Описание графа навигации
Описание графа производится в декларативном стиле внутри composable-функции NavHost. У нее в параметрах передается NavHostContoller, созданный выше, и startDestination — route экрана, который будет отрисован первым. Здесь происходит инициализация графа навигации — связывание route с экранами (destinations). К route "home" и "settings" объявляются composable-функции, которые будут вызываться при навигации.
@Composable
fun AppNavGraph(
modifier: Modifier = Modifier,
navController: NavHostController
) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
Box(modifier = modifier) {
Text("home")
}
}
composable("settings") {
Box(modifier = modifier) {
Text("settings")
}
}
...
}
}
Но что там с многомодульностью? Не будем же мы бесконечно прописывать каждый новый экран в один файл, раздувая его до бесконечности. Нам поможет унифицированный подход добавления фичи, который мы рассмотрим на следующем шаге.
Шаг 3. Feature-API
Здесь мы не будем холиварить про то, что называть фичей, где конец одной фичи и начало другой. В этом примере будем называть фичей отдельный модуль с экранами, который идет в паре со своим API-модулем.
Создадим core-модуль с названием feature-api. Интерфейс ниже — основной контракт API фичи. Все API фич — тоже интерфейсы — должны наследоваться от него. API фич дополняются методами, возвращающими routes до нужных экранов с учетом аргументов навигации. Функция registerGraph(...) регистрирует граф навигации фичи либо как отдельные экраны через navGraphBuilder.composable(...), либо как вложенный граф через navGraphBuilder.navigation(..). NavController нужен для навигации, вызываемой в фичах. Modifier содержит проставленные отступы от Bottom Nav Bar.
interface FeatureApi {
fun registerGraph(
navGraphBuilder: NavGraphBuilder,
navController: NavHostController,
modifier: Modifier = Modifier
)
}
Каждая фича состоит из двух модулей: feature-name-api и feature-name-impl. api-модуль должен быть максимально легковесным, так как его могут импортировать другие фичи, чтобы навигироваться на экраны feature-name. impl-модуль содержит всю реализации фичи и про него знает только модуль app, который поставлят реализацию другим фичам через DI.
Для наглядности покажем на схеме иерархию модулей:
Почему мы разрешаем фичам знать про API друг друга? Зачем делим на 2 модуля?
Навигация в Jetpack Compose Navigation основана на ссылках. Каждая фича в своем API отвечает на вопрос, по каким ссылкам открываются ее экраны. И могут быть ситуации, когда из фичи А производится навигация на экран фичи Б и наоборот. В случае "мономодульных" фич возникла бы ситуация циклических зависимостей.
Также мы сознательно решили отказаться от подхода "фичи изолированы, а вся навигация в app или каком-то core-navigation модуле", который встречали в постах на аналогичную тему. Мы делаем большое приложение с потенциально большим количеством модулей. Такой подход привел бы к тому, что был бы некий GOD-класс/объект/модуль, отвечающий за навигацию. Это могло привести к раздуванию отдельного модуля и негативно сказаться на времени сборки, а также приводить к частым merge-конфликтам при росте числа разработчиков.
Рассмотрим пример реализации фичи home-api. Тут добавлен метод, возвращающий route до единственного экрана фичи. В общем случае интерфейс предоставляет методы, а не константы на случай, если route будет содержать аргументы, которые можно будет передавать через аргументы метода.
interface HomeFeatureApi: FeatureApi {
val homeRoute: String
}
Рассмотрим home-impl. В примере "регистрируется" только один экран, но с ростом модуля их потенциально станет много. При этом, добавление нового экрана проводит к изменениям только внутри одного изолированного модуля.
class HomeFeatureImpl : HomeFeatureApi {
override val homeRoute = "home"
override fun registerGraph(
navGraphBuilder: NavGraphBuilder,
navController: NavController,
modifier: Modifier
) {
navGraphBuilder.composable("home") {
HomeScreen(
modifier = modifier,
onNavigateToABFlow = {
navController.navigate(scenarioABRoute)
}
)
}
}
}
Обратите внимание на callback onNavigateToABFlow, который сообщает наверх, что внутри экрана произошло событие, которое должно привести к навигации. В первой версии статьи внутрь передавался navController, что являлось ошибочным архитектурным решением, которое стало проявляться при увеличении количества экранов внутри фичи. Навигация оказалась "размазанной" внутри экранов, и, при появлении бизнес-требований, требовавщих условную, более сложную, навигацию, пришлось выносить всю навигацию на один уровень путем смены передачи navController на callbacks.
Регистрация фичи происходит в app модуле в теле лямбды NavHost c использованием расширения NavGraphBuilder.register:
fun NavGraphBuilder.register(
featureApi: FeatureApi,
navController: NavController,
modifier: Modifier = Modifier
) {
featureApi.registerGraph(
navGraphBuilder = this,
navController = navController,
modifier = modifier
)
}
fun NavHost(
navController = navController,
startDestination = DependencyProvider.homeFeature().homeRoute
) {
register(
DependencyProvider.homeFeature,
navController = navController,
modifier = modifier
)
}
Тут можно заметить еще одну новую сущность — DependencyProvider — это object, примитивное подобие service-locator, который имитирует в нашем упрощенном примере целевой DI. Предполагается, что API фичей будут доступны из DI графа.
Заметим, что данный подход не предлагает свою надстройку над библиотечным механизмом навигации, разработчикам не придется изучать внутренний "велосипед". Добавлен один интерфейс, который помогает разнести экраны фич по модулям и одно необязательное расширение для лаконичности.
Шаг 4. Навигация на экраны других фич
Теперь рассмотрим пример навигации из фичи в фичу. Для примера рассмотрим фичу onboarding, которая позволяет осуществить навигацию на экран фичи home.
class OnboardingFeatureImpl : OnboardingFeatureApi {
override val route = "onboarding"
override fun registerGraph(...) {
navGraphBuilder.composable(route) {
OnboardingScreen(
onNavigateToHome = {
val homeFeature = DependencyProvider.homeFeature()
navController.navigate(homeFeature.homeRoute) {
popUpTo(route) { inclusive = true }
}
},
onNavigateToSettings = {
val settingsFeature = DependencyProvider.settingsFeature()
navController.navigate(settingsFeature.settingsRoute) {
popUpTo(route) { inclusive = true }
}
}
)
}
}
}
...
@Composable
internal fun OnboardingScreen(
onNavigateToHome: () -> Unit,
onNavigateToSettings: () -> Unit
) {
Column(...) {
Text(
text = "Hello world! You're in onboarding screen",
...
)
SimpleButton(
text = "To home",
onClick = onNavigateToHome
)
SimpleButton(
text = "To settings",
onClick = onNavigateToSettings
)
}
}
Здесь OnboardingScreen — это экран фичи, который открывается по route = "onboarding". В обработчике нажатий кнопки с помощью navController текущий экран удаляется из back stack и через псевдо-DI DependencyProvider достается API нужной фичи, которая сообщает route до ее экрана.
Сам экран изолирован от знания механизма навигации и других фичей — он лишь выставляет callbacks, по результатам которых должна быть произведена навигация. Сама навигация производится внутри OnboardingFeatureImpl.
Шаг 5. Навигация внутри фичи
В предыдущем шаге мы осуществили навигацию из фичи onboarding в home. На практике внутри одной фичи будет много экранов, которые логично сделать доступными только в пределах модуля, и не раскрывать их существование в API фичи. Рассмотрим, как можно организовать навигацию внутри фичи, заодно захватив тему вложенных графов и передачи аргументов. Так как отличие "публичного" от "приватного" API фичи только в области видимости, можем переиспользовать подход с FeatureApi внутри фичи.
Время показало, что разбиение навигации фичи на 2 класса излишне. Сам подход FeatureApi в сочетании с принципами декларативной навигации (destination не производят навигацию сами, а выставляют callbacks) позволяет добавлять "приватные" экраны изолированно внутри модуля. Route до этих экранов становятся приватными полями класса. Смотрите обновленный пример.
Код с InternalHomeFeatureApi оставлен для примера того, как делать не нужно :)
В нашем примере "приватные" экраны фичи home — экраны ScreenA и ScreenB, навигация на второй требует аргумент. Тут сделаем приватное API object-ом для упрощения, в бою вы можете соблюсти принцип Dependency Inversion и доставить его реализацию через DI.
internal object InternalHomeFeatureApi : FeatureApi {
private const val scenarioABRoute = "home/scenarioABRoute"
private const val parameterKey = "parameterKey"
private const val screenBRoute = "home/screenB"
private const val screenARoute = "home/screenA"
fun scenarioABRoute() = scenarioABRoute
fun screenA() = screenARoute
fun screenB(parameter: String) = "$screenBRoute/${parameter}"
override fun registerGraph(
navGraphBuilder: NavGraphBuilder,
navController: NavHostController,
modifier: Modifier
) {
navGraphBuilder.navigation(
route = scenarioABRoute,
startDestination = screenARoute
) {
composable(route = screenARoute) {
ScreenA(modifier = modifier, navController = navController)
}
composable(
route = "$screenBRoute/{$parameterKey}",
arguments = listOf(navArgument(parameterKey) { type = NavType.StringType })
) { backStackEntry ->
val arguments = requireNotNull(backStackEntry.arguments)
val argument = arguments.getString(parameterKey)
ScreenB(modifier = modifier, argument = argument.orEmpty())
}
}
}
}
Обращаем внимание, что в методе registerGraph() происходит регистрация вложенного графа (nested graph) — navGraphBuilder.navigation(..). Вложенные графы — способ объединить экраны по определенному принципу в отдельную группу, например отдельный пользовательский сценарий. Они позволяют запустить сценарий зная route, но не зная какой именно экран откроется — это настраивается через параметр startDestination. При этом граф не изолирован — есть возможность произвести навигацию на любой вложенный экран, зная его route.
Также приведен пример, навигации с аргументами.
Добавляем вложенный граф приватного сценария из 2-х экранов:
private const val baseRoute = "home"
private const val scenarioABRoute = "$baseRoute/scenario"
private const val screenBRoute = "$scenarioABRoute/B"
private const val screenARoute = "$scenarioABRoute/A"
private const val argumentKey = "arg"
class HomeFeatureImpl : HomeFeatureApi {
override val homeRoute = baseRoute
override fun registerGraph(..) {
/* Public destination */
navGraphBuilder.composable(baseRoute) {
HomeScreen(
onNavigateToABFlow = {
navController.navigate(scenarioABRoute)
}
)
}
/* Nested graph for internal scenario */
navGraphBuilder.navigation(
route = scenarioABRoute,
startDestination = screenARoute
) {
composable(route = screenARoute) {
ScreenA(
onNavigateNextWithArgument = { argument ->
val encodedArgument = Uri.encode(argument)
navController.navigate(route = "$screenBRoute/$encodedArgument")
}
)
}
composable(
route = "$screenBRoute/{$argumentKey}",
arguments = listOf(
navArgument(argumentKey) {
type = NavType.StringType
}
)
) { backStackEntry ->
val arguments = requireNotNull(backStackEntry.arguments)
val argument = Uri.decode(arguments.getString(argumentKey).orEmpty())
ScreenB(
argument = argument.orEmpty()
)
}
}
}
}
Таким образом мы выработали единообразный подход к построению навигации как между фичами, так и между приватными экранами внутри одной фичи.
Заключение
Здесь мы поделились нашим видением организации многомодульного проекта с Compose Navigation. Мы понимаем, что это решение, как и любое другое, кроме плюсов имеет недостатки. В нашем случае это относительная связанность фич (через API) и необходимость создавать плюс один api-модуль. Мы ожидаем, что на длинной дистанции такой "фреймворк" добавления фич и новых экранов позитивно скажется на скорости вхождения в проект, скорости разработки и убережет от внезапных поломок кода.
Спустя 1.5 года использования данного подхода можно сделать вывод, что решение оказалось удачным для production проекта с множеством фичей и экранов.
Такой подход (FeatureApi) помимо самой навигации позволил удобно решать следующие задачи: производить условную навигацию на основании публичной route фичи; отвечать на вопрос "соответствует ли route экрану X".
Этот подход нагляден, он предполагает хранение знания о публичных экранах фичи в переделах одной сущности.
Основным минусом данного подхода можно назвать его громоздкость и избыточность для простых фич (обычно из 1 destination). В таком случае имеет смысл рассмотреть вариант, предложенный Google.
Будем рады увидеть в комментариях ваш опыт построения архитектуры и навигации в приложениях с Compose!