
Это 3-я часть из цикла статей, посвященных компонентному подходу. В предыдущей части речь шла о том, как построить сложный экран, разбив его на простые компоненты. Теперь применим ту же идею для организации сложной навигации.
Данная статья носит практический характер. Сначала сосредоточимся на создании отдельных потоков приложений с помощью Decompose и Jetpack Compose. Затем перейдем к нижней навигации. Наконец, соберем все вместе, чтобы настроить навигацию во всем приложении.
В процессе работы будем использовать примеры из реального приложения, чтобы увидеть, как компонентный подход позволяет масштабироваться в случае больших проектов с десятками или даже сотнями экранов.
Приложение со сложной навигацией
Библиотека Decompose стала спасением для нашей команды, когда понадобилось организовать сложную навигацию. Мы создавали приложение для крупной технологической компании Sever Minerals. Оно должно было служить порталом для сотрудников, на котором они могли бы решать свои задачи: проходить обучение, следить за новостями компании, планировать встречи, запрашивать отгулы, получать официальные документы и многое другое. Всего в приложении планировалось создать 10 рабочих процессов и около 80 уникальных экранов.




Поток
Посмотрим, как создать поток с помощью Decompose. В качестве примера возьмем поток «New Employees» («Новые сотрудники»). В этом потоке всего два экрана: один для списка сотрудников и один для подробной информации о них. При нажатии на элемент списка открывается экран подробной информации.

Создание экранов
Лучше всего начинать реализацию потока с создания экранов. О том, как создавать экраны, уже рассказывалось в предыдущей статье. Напомним, что код экрана состоит из трех частей: интерфейса компонента, реализации компонента и пользовательского интерфейса.
Например, ниже показано, как может выглядеть код для экрана списка сотрудников.
Интерфейс компонента:
interface EmployeeListComponent {
val employeeListState: StateFlow<EmployeeListState>
fun onEmployeeClick(employeeId: EmployeeId)
}
Реализация компонента (метод onEmployeeClick рассмотрим чуть позже):
class RealEmployeeListComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext, EmployeeListComponent {
// логика
}
Пользовательский интерфейс:
@Composable
fun EmployeeListUi(component: EmployeeListComponent) {
// UI
}
Аналогичным образом создадим EmployeeDetailsComponent, RealEmployeeDetailsComponent и EmployeeDetailsUi.
Создание компонента для потока
Сам поток «New Employees» («Новые сотрудники») также является компонентом. Его роль заключается в управлении стеком дочерних компонентов.
Вот как выглядит его интерфейс:
interface NewEmployeesComponent {
val childStack: StateFlow<ChildStack<*, Child>>
sealed interface Child {
class List(val component: EmployeeListComponent) : Child
class Details(val component: EmployeeDetailsComponent) : Child
}
}
Свойство childStack представляет стек компонентов. Sealed-интерфейс Child определяет типы компонентов, которые могут находиться в этом стеке.
Чтобы двигаться дальше, подробнее рассмотрим, как Decompose хранит стек компонентов. Decompose поддерживает два синхронизированных стека — стек конфигураций и стек компонентов.
Конфигурация — небольшой объект, определяющий тип компонента и его входные параметры. Конфигурации аннотируются @Serializable. Это означает, что их можно сохранять в постоянном хранилище и позже перезагружать из него.
Вот конфигурации для наших экранов:
private sealed interface ChildConfig {
@Serializable
data object List : ChildConfig
@Serializable
data class Details(val employeeId: EmployeeId) : ChildConfig
}
Компоненты создаются из конфигураций. Нам нужно снабдить Decompose специальной функцией, называемой component factory (фабрикой компонентов), которая принимает конфигурацию и возвращает соответствующий компонент.
Вот пример такой функции:
private fun createChild(
config: ChildConfig,
componentContext: ComponentContext
): NewEmployeesComponent.Child = when (config) {
is ChildConfig.List -> {
NewEmployeesComponent.Child.List(
RealEmployeeListComponent(componentContext)
)
}
is ChildConfig.Details -> {
NewEmployeesComponent.Child.Details(
RealEmployeeDetailsComponent(componentContext)
)
}
}
Decompose управляет созданием компонентов из конфигураций. Мы не можем изменять стек компонентов напрямую. Вместо этого работаем со стеком конфигураций, а Decompose автоматически обновляет стек компонентов соответствующим образом.

Зачем нужны сложности с двумя стеками? Почему бы просто не оставить один стек компонентов? Причина кроется в том, как работает система Android. Когда пользователь сворачивает приложение, оно может быть удалено из памяти. Когда он возвращается, приложению необходимо восстановить стек экранов и данные на них. Здесь на помощь приходят конфигурации. Decompose сохраняет и восстанавливает конфигурации (которые, как уже отмечалось, являются сериализуемыми (Serializable), после чего воссоздает компоненты.
К счастью, Decompose скрывает эту сложную двухстековую логику в классе ChildStack. Все, что нам нужно сделать, это объявить конфигурации (используя sealed-интерфейс ChildConfig) и определить фабрику компонентов (метод createChild).
Вот как будет выглядеть код компонента:
class RealNewEmployeesComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext, NewEmployeesComponent {
private val navigation = StackNavigation<ChildConfig>()
override val childStack: StateFlow<ChildStack<*, NewEmployeesComponent.Child>> = childStack(
source = navigation,
initialConfiguration = ChildConfig.List,
serializer = ChildConfig.serializer(),
handleBackButton = true,
childFactory = ::createChild
).toStateFlow(lifecycle)
private fun createChild(
config: ChildConfig,
componentContext: ComponentContext
): NewEmployeesComponent.Child = when (config) {
is ChildConfig.List -> {
NewEmployeesComponent.Child.List(
RealEmployeeListComponent(componentContext)
)
}
is ChildConfig.Details -> {
NewEmployeesComponent.Child.Details(
RealEmployeeDetailsComponent(componentContext)
)
}
}
private sealed interface ChildConfig {
@Serializable
data object List : ChildConfig
@Serializable
data class Details(val employeeId: EmployeeId) : ChildConfig
}
}
Рассмотрим основные моменты:
- Объект
navigationпозволяет манипулировать стеком конфигурации. Более подробно рассмотрим его в следующем разделе.
- Метод
childStackсоздает стек навигации, возвращаяValue<ChildStack>.Value— тип из Decompose. Для удобства преобразуем его вStateFlowс помощью расширения toStateFlow .
- Начальное состояние стека задается параметром
initialConfiguration.
serializerобрабатывает сохранение и восстановление конфигураций.
- С параметром
handleBackButton = trueстек автоматически обрабатывает системную кнопку Back, удаляя верхний элемент из стека.
- Метод
createChild— фабрика компонентов, о которой говорилось ранее. Обратите внимание, что, помимо конфигурации, этот метод также принимаетComponentContext. При каждом вызове получаем новый дочерний контекст.
- В конце кода объявляются конфигурации. Каждый тип компонента имеет свой класс конфигурации.
Вызов метода навигации
StackNavigation предоставляет методы для управления стеком навигации: push(configuration), pop(), replaceCurrent(configuration) и другие. Они позволяют настраивать стек так, как нам нужно.
Вернемся к нашему примеру: нужно, чтобы при нажатии на элемент списка приложение переходило на экран с информацией о сотруднике.
Обработчик действия onEmployeeClick находится в EmployeeListComponent, но стек навигации управляется его родительским компонентом NewEmployeesComponent. Чтобы справиться с этим, используем обратный вызов для уведомления родительского компонента о нажатии на элемент списка, что позволит ему вызвать метод навигации.

Сначала добавляем обратный вызов onEmployeeSelected в конструктор компонента и вызываем его при нажатии на элемент списка:
class RealEmployeeListComponent(
componentContext: ComponentContext,
val onEmployeeSelected: (EmployeeId) -> Unit
) : ComponentContext by componentContext, EmployeeListComponent {
override fun onEmployeeClick(employeeId: EmployeeId) {
onEmployeeSelected(employeeId)
}
}
В компоненте RealNewEmployeesComponent используем этот обратный вызов для запуска навигации при выборе сотрудника:
is ChildConfig.List -> {
NewEmployeesComponent.Child.List(
RealEmployeeListComponent(
componentContext,
onEmployeeSelected = { employeeId ->
navigation.push(ChildConfig.Details(employeeId))
}
)
)
}
Подключение пользовательского интерфейса
Реализуем пользовательский интерфейс с помощью функции Children из Decompose:
@Composable
fun NewEmployeesUi(component: NewEmployeesComponent) {
val childStack by component.childStack.collectAsState()
Children(childStack) { child ->
when (val instance = child.instance) {
is NewEmployeesComponent.Child.List -> EmployeeListUi(instance.component)
is NewEmployeesComponent.Child.Details -> EmployeeDetailsUi(instance.component)
}
}
}
Эта настройка отображает соответствующий экран в зависимости от типа компонента.
Поток готов. Мы создали поток с двумя экранами, но тот же подход применим к потокам с любым количеством экранов.
Нижняя навигация
Нижняя навигация также может рассматриваться как поток. Компонент с нижней панелью будет переключаться между несколькими дочерними компонентами.

Как настроить подобную навигацию? Переход между вкладками не подчиняется структуре стека. Например, если пользователь переходит с вкладки «Home» («Главная») на «Services» («Услуги»), а затем обратно на «Home», нет необходимости удалять компонент «Services» — ведь пользователь может вернуться к нему в любой момент. В идеале нам нужно повторно использовать уже созданные компоненты.
Оказывается, ChildStack пригождается и здесь. Дело в том, что ChildStack — не совсем стек. Он похож на стек тем, что имеет активный элемент на «вершине», но с точки зрения функциональности ведет себя скорее как список.
Так, вместо того чтобы использовать pop для возврата в «Home» из «Services», можно просто вернуть компонент «Home» на вершину. В Decompose есть встроенный метод для этого: bringToFront.
Вот код для переключения между вкладками:
override fun onTabSelected(tab: MainTab) {
val configuration = tab.toConfiguration()
navigation.bringToFront(configuration)
}
Навигация по всему приложению
Ранее мы научились создавать отдельные потоки. Теперь объединим несколько потоков в одно приложение.
Допустим, у нас уже есть несколько готовых потоков: поток авторизации (AuthorizationComponent), нижняя навигация (MainComponent) и поток новых сотрудников (NewEmployeesComponent). Наша цель — объединить эти потоки.

Требования:
- Приложение начинается с потока авторизации.
- После завершения авторизации пользователь попадает на главный экран.
- На главном экране есть кнопка, при нажатии на которую открывается поток «New Employees» («Новые сотрудники»).
Суть объединения потоков можно выразить одним предложением: приложение создается из потоков так же, как поток создается из экранов. Другими словами, используем childStack — только на этот раз работаем не с экранами, а с целыми потоками. Однако здесь есть несколько тонкостей, которым следует уделить особое внимание.
Главный компонент в приложении обычно называется RootComponent. Он управляет компонентами потока:
interface RootComponent {
val childStack: StateFlow<ChildStack<*, Child>>
sealed interface Child {
class Authorization(val component: AuthorizationComponent) : Child
class Main(val component: MainComponent) : Child
class NewEmployees(val component: NewEmployeesComponent) : Child
}
}
Компоненты потока также будут иметь обратные вызовы. Ранее мы добавили обратные вызовы в компоненты экрана, чтобы они могли уведомлять свой поток о событиях. Теперь потоки также будут уведомлять корневой компонент о событиях. При переключении между потоками будет создаваться двойная цепочка обратных вызовов. Например, в потоке авторизации сначала сработает onSmsCodeVerified, а затем onAuthorizationFinished, как показано на диаграмме:

Реализация RootComponent:
class RealRootComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext, RootComponent {
private val navigation = StackNavigation<ChildConfig>()
override val childStack: StateFlow<ChildStack<*, RootComponent.Child>> = childStack(
source = navigation,
initialConfiguration = ChildConfig.Authorization,
serializer = ChildConfig.serializer(),
handleBackButton = true,
childFactory = ::createChild
).toStateFlow(lifecycle)
private fun createChild(
config: ChildConfig,
componentContext: ComponentContext
): RootComponent.Child = when (config) {
is ChildConfig.Authorization -> {
RootComponent.Child.Authorization(
RealAuthorizationComponent(
componentContext,
onAuthorizationFinished = {
navigation.replaceAll(ChildConfig.Main)
}
)
)
}
is ChildConfig.Main -> {
RootComponent.Child.Main(
RealMainComponent(
componentContext,
onNewEmployeesRequested = {
navigation.push(ChildConfig.NewEmployees)
}
)
)
}
is ChildConfig.NewEmployees -> {
RootComponent.Child.NewEmployees(
RealNewEmployeesComponent(componentContext)
)
}
}
private sealed interface ChildConfig : Parcelable {
@Serializable
data object Authorization : ChildConfig
@Serializable
data object Main : ChildConfig
@Serializable
data object NewEmployees : ChildConfig
}
}
Этот код очень похож на код реализации обычных потоков. Необходимая логика реализована в обратных вызовах onAuthorizationFinished и onNewEmployeesRequested. Для перехода к основному потоку используем метод replaceAll вместо push, чтобы пользователь не смог вернуться к экрану авторизации.
Добавление уровней навигации
Используя этот подход, моя команда реализовала всю навигацию приложения для сотрудников Sever Minerals. Корневой компонент управлял глобальной навигацией — переключением между потоками, а компоненты потоков выполняли переходы между своими экранами. В корневом компоненте мы получили 10 дочерних компонентов и около 300 строк простого кода.
Это базовая концепция, которую можно расширять по мере необходимости, добавляя уровни навигации в зависимости от масштаба и требований приложения.
Например, можно использовать вложенные потоки. Предположим, пользователю нужно ввести домашний адрес в нескольких частях приложения. Этот процесс состоит из нескольких шагов: выбор города из списка, ввод улицы и номера дома, а также выбор дома на карте (опционально). Можно сделать этот процесс отдельным потоком. Вместо того чтобы подключать его к корневому компоненту, свяжите его с потоками, в которых требуется ввод адреса. Это позволит избежать дублирования кода и сохранить простоту корневого компонента.
Еще один способ упростить корневой компонент — разделить его на два дочерних компонента: один для области без аутентификации, другой — для области с аутентификацией.

Решение разделить компоненты таким образом должно приниматься после тщательного исследования. Оно сработает только в том случае, если вы заранее знаете, к каким экранам авторизованный пользователь может получить доступ, а к каким — нет.
Вложенная навигация часто воспринимается как сложная тема. Многие разработчики выбирают плоские иерархии, чтобы избежать потенциальных проблем. Но при компонентном подходе вложенность не является недостатком — напротив, это инструмент для управления сложностью. Разбейте код на простые компоненты с возможностью добавлять новые уровни вложенности — и будете готовы к работе с приложениями любого масштаба.
Читайте также:
- Реализация функции Pull-to-refresh с помощью Compose Material 3
- Как создать анимацию мерцающего текста в Jetpack Compose
- Jetpack Compose: настройка Retrofit и Ktor с помощью Dagger Hilt для внедрения зависимостей
Читайте нас в Telegram, VK и Дзен
Перевод статьи Artur Artikov: Component-based Approach. Organizing Navigation with the Decompose Library





