Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Это третья часть из серии статей про компонентный подход. В предыдущей статье мы рассмотрели, как реализовать сложный экран, разбив его на набор простых компонентов. Применим эту же идею, чтобы организовать сложную навигацию.
В статье много практики. Сначала я покажу, как с помощью Decompose и Jetpack Compose создавать отдельные флоу приложения. Далее обсудим реализацию bottom-навигации. И, наконец, объединим несколько флоу воедино, чтоб получить навигацию по всему приложению.
Я покажу примеры из реального приложения. Вы увидите, что предлагаемый подход хорошо подходит для больших приложений с десятками, а то и сотнями экранов.
Приложение со сложной навигацией
Библиотека Decompose очень выручила мою команду, когда нужно было организовать сложную навигацию. Мы делали приложение для крупной технологической компании Sever Minerals. Это приложение — личный кабинет сотрудников. В нём они выполняют свои рабочие задачи: проходят обучение, узнают новости компании, планируют встречи, оформляют отпуска, выписывают справки и т. д. Всего 10 сценариев и около 80-ти уникальных экранов.
Флоу
Разберёмся, как с помощью Decompose создавать флоу. В качестве примера рассмотрим флоу «Новые сотрудники». Он состоит всего из двух экранов: список сотрудников и детальная информация о сотруднике. При нажатии на элемент списка открывается экран с детальной информацией.
Создаём экраны
Реализацию флоу лучше начинать с создания экранов. Как создавать экраны, мы уже обсудили в предыдущей статье. Напомню, что код экрана состоит из трех частей: интерфейс компонента, реализация компонента и UI.
Например, такой код получится для экрана со списком сотрудников:
Интерфейс компонента:
interface EmployeeListComponent {
val employeeListState: StateFlow<EmployeeListState>
fun onEmployeeClick(employeeId: EmployeeId)
}
Реализация компонента (метод onEmployeeClick
рассмотрим чуть позже):
class RealEmployeeListComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext, EmployeeListComponent {
// some logic
}
UI:
@Composable
fun EmployeeListUi(component: EmployeeListComponent) {
// some UI
}
Аналогично создадим EmployeeDetailsComponent
, RealEmployeeDetailsComponent
, EmployeeDetailsUi
.
Создаём компонент для флоу
Сам флоу «Новые сотрудники» также является компонентом. Его задача — управлять стеком дочерних компонентов.
Так выглядит его интерфейс:
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 хранит два синхронизированных друг с другом стека — стек конфигураций и стек компонентов.
Конфигурация — это небольшой объект, который описывает тип компонента и его входные параметры. Конфигурации реализуют интерфейс Parcelable
, то есть их можно сохранять в постоянную память, а потом загружать из неё.
Пример конфигураций:
private sealed interface ChildConfig : Parcelable {
@Parcelize
object List : ChildConfig
@Parcelize
data class Details(val employeeId: EmployeeId) : ChildConfig
}
На основе конфигураций создаются сами компоненты. Мы должны передать в Decompose специальную функцию (фабрику компонентов), которая принимает конфигурацию и возвращает созданный компонент.
Пример такой функции:
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 сохраняет и восстанавливает стек конфигураций (которые, я напомню, являются Parcelable
). А восстановив конфигурации, он создаёт и сами компоненты.
К счастью, 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,
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 : Parcelable {
@Parcelize
object List : ChildConfig
@Parcelize
data class Details(val employeeId: EmployeeId) : ChildConfig
}
}
Пробежимся по основным моментам:
Объект
navigation
позволяет манипулировать стеком конфигурации. Мы обсудим его подробнее в следующем разделе.Метод
childStack
создаёт стек навигации. Он возвращаетValue<ChildStack>
.Value
— это тип из Decompose. Для удобства преобразуем его вStateFlow
экстеншеном toStateFlow.Начальное состояние стека задается параметром
initialConfiguration
.Благодаря опции
handleBackButton = true
, стек автоматически обрабатывает нажатие системной кнопки Back — удаляет элемент с вершины стека.Метод
createChild
— это упомянутая ранее фабрика компонентов. Обратите внимание, что помимо конфигурации этот метод также принимаетComponentContext
. При каждом вызове будет приходить новый дочерний контекст.В конце кода объявлены конфигурации. Каждому типу компонента соответствует свой класс-конфигурация.
Вызываем метод навигации
StackNavigation
предоставляет методы для управления стеком навигации: push(configuration)
, pop()
, replaceCurrent(configuration)
и др. Вызывая нужный метод, мы можем как угодно менять стек.
Вернёмся к нашему примеру. Сделаем так, чтоб при нажатии на элемент списка происходил переход на экран с детальной информацией о сотруднике.
Обработчик действия пользователя onEmployeeClick
находится в компоненте EmployeeListComponent
, а за управление стеком навигации отвечает его родитель — NewEmployeesComponent
. Воспользуемся callback-ом, чтоб уведомить родителя о произошедшем событии.
Добавим callback onEmployeeSelected
в конструктор компонента и вызовем его при нажатии на элемент списка:
class RealEmployeeListComponent(
componentContext: ComponentContext,
val onEmployeeSelected: (EmployeeId) -> Unit
) : ComponentContext by componentContext, EmployeeListComponent {
// some logic
override fun onEmployeeClick(employeeId: EmployeeId) {
onEmployeeSelected(employeeId)
}
}
А в компоненте RealNewEmployeesComponent
будем вызывать метод навигации из этого callback-а:
is ChildConfig.List -> {
NewEmployeesComponent.Child.List(
RealEmployeeListComponent(
componentContext,
onEmployeeSelected = { employeeId ->
navigation.push(ChildConfig.Details(employeeId))
}
)
)
}
Подключаем UI
Реализуем UI с помощью функции 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)
}
}
}
Отображаем UI нужного экрана в зависимости от типа компонента.
Флоу готов. Мы сделали флоу из двух экранов. Флоу с любым другим количеством экранов делается аналогично.
Bottom-навигация
Bottom-навигацию тоже можно рассматривать как флоу. Компонент с боттом-баром будет переключать несколько дочерних компонентов.
Но как организовать такую навигацию? Переключение экранов работает не по принципу стека. Если пользователь с вкладки «Главная» переключился на «Сервисы», а потом обратно на «Главную», то нет смысла удалять компонент для «Сервисов», ведь пользователь в любой момент может вновь вернуться на «Сервисы». Хотелось бы переиспользовать уже созданные компоненты.
Оказывается, СhildStack
поможет нам и с этой задачей. Секрет в том, что СhildStack
это не совсем стек. Он стек в том смысле, что имеет выделенный активный элемент — вершину стека. Но с точки зрения поддерживаемых операций — он список.
Находясь на вкладке «Сервисы», нам не нужно делать pop
, чтоб вернуться на «Главную». Вместо этого мы выдернем компонент «Главная» из стека и поместим его на вершину. В Decompose есть специальный метод для этого bringToFront
.
Получится такой код для переключения между вкладками:
override fun onTabSelected(tab: HomeTab) {
val configuration = tab.toConfiguration()
navigation.bringToFront(configuration)
}
Навигация во всем приложении
Ранее мы научились делать отдельные флоу. Теперь научимся объединять несколько флоу в единое приложение.
Допустим, у нас уже готовы несколько флоу: авторизация (AuthorizationComponent
), домашний экран c bottom-навигацией (HomeComponent
), новые сотрудники (NewEmployeesComponent
). Нужно объединить эти флоу.
Требования такие:
Приложение стартует с флоу авторизации.
После прохождения авторизации пользователь попадает на домашний экран.
На вкладке «Главная» есть кнопка, по нажатию на которую открывается флоу «Новые сотрудники».
На самом деле, объединение флоу можно объяснить одной фразой: приложение собирается из флоу точно так же, как флоу собирается из экранов. То есть, мы просто используем childStack
, только вместо экранов будут целые флоу. Но, всё-таки, тут есть неочевидные нюансы, поэтому давайте разберёмся подробнее.
Главный компонент в приложении принято называть RootComponent
. Он управляет компонентами-флоу:
interface RootComponent {
val childStack: StateFlow<ChildStack<*, Child>>
sealed interface Child {
class Authorization(val component: AuthorizationComponent) : Child
class Home(val component: HomeComponent) : Child
class NewEmployees(val component: NewEmployeesComponent) : Child
}
}
У компонентов-флоу появятся callback-и. Раньше мы уже делали callback-и в компонентах-экранах, чтобы те уведомляли свой флоу о событиях. А теперь ещё и флоу будут уведомлять о событиях root-компонент. Причём, когда нужно сменить флоу, будет происходить двойное пробрасывание события через callback-и. Например, для флоу авторизации по цепочке вызовется сначала 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,
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.Home)
}
)
)
}
is ChildConfig.Home -> {
RootComponent.Child.Home(
RealHomeComponent(
componentContext,
onNewEmployeesRequested = {
navigation.push(NewEmployees)
}
)
)
}
is ChildConfig.NewEmployees -> {
RootComponent.Child.NewEmployees(
RealNewEmployeesComponent(componentContext)
)
}
}
private sealed interface ChildConfig : Parcelable {
@Parcelize
object Authorization: ChildConfig
@Parcelize
object Home : ChildConfig
@Parcelize
object NewEmployees : ChildConfig
}
}
Код очень похож на реализацию обычных флоу. В callback-ах onAuthorizationFinished
и onNewEmployeesRequested
реализована нужная логика. Для перехода на флоу Home мы применили метод replaceAll
, а не push
, чтоб нельзя было вернуться назад на авторизацию.
Больше уровней навигации
Применяя описанный подход, моя команда реализовала всю навигацию в приложении Sever Minerals for Employees. Root-компонент отвечал за глобальную навигацию — переключение флоу. А компоненты-флоу выполняли переходы между экранами. В root-компоненте получилось 10 дочерних компонентов и около 300 строк несложного кода.
Это базовая идея, и вы можете расширять её. Делайте разное количество уровней навигации в зависимости от масштаба и требований вашего приложения.
Например, можно использовать вложенные флоу. Представим, что в приложении в нескольких сценариях пользователь может указать адрес своего дома. Указание адреса состоит из нескольких шагов: выбор города из списка, ввод улицы и номера дома, альтернативный шаг с выбором дома на карте. Вынесите это отдельный флоу. Подключите этот флоу не к root-компоненту, а к тем флоу, где требуется указание адреса. В результате вы избежите дублирования кода и не перегрузите root-компонент.
Ещё вариант, как можно упростить root-компонент, это разделить его на два дочерних компонента: один — для неавторизованной зоны, а другой — для авторизованной:
Решение о таком разделении нужно принимать взвешенно. Оно сработает, только если заранее известно, на какие экраны сможет попасть авторизованный пользователь, а на какие нет.
Вложенную навигацию принято считать сложной темой. Часто разработчики делают плоскую иерархию, стремясь избежать проблем. Но с компонентным подходом вложенность это не проблема, а, наоборот, инструмент для борьбы со сложностью. Разбивайте код на простые компоненты, не бойтесь добавлять больше уровней вложенности, и тогда вам будут подвластны приложения любых масштабов.
Дополнительные материалы
Decompose
Back button handling — про обработку кнопки «Назад».
How to return a result to a previous component? — обсуждение и пример кода, как возвращать данные на предыдущий экран.
Navigation overview — про другие виды навигации (Child Overlay и Generic Navigation).
Видео на Android Broadcast — информация про библиотеку на русском языке с live coding-ом (внимание: в примерах кода устаревшее апи, вместо router теперь childStack).
Статья “Fully cross-platform Kotlin applications (almost)” — как с помощью Decompose создать приложение под Android и Desktop JVM на базе общего кода.
Доклад на Droidcon "Decompose your Kotiln Multiplatform project into feature modules" — как Decompose позволяет улучшить архитектуру приложения.
Примеры
Официальный пример для Decompose — демонстрирует все возможности Decompose. Показано, как сделать master-detail навигацию и показ диалоговых окон. Поддерживает платформы: Android, iOS, Desktop, Web.
Todoapp — кроссплатформенное приложение Todo List на Decompose.
MobileUp-Android-Template — шаблон Android-проекта от компании MobileUp. Демонстрирует нашу архитектуру и технологический стек.
Что дальше?
Вы прочитали последнюю из трех запланированных статей про компонентный подход. Я надеюсь, для вас это станет не концом, а началом погружения в эту тему.
Конечно, есть еще множество тем, прямо или косвенно относящихся к компонентному подходу. Как сделать загрузку данных, когда экран разбит на десяток независимых компонентов? Как обрабатывать ошибки? Как писать тесты для компонентов? Как делить компоненты на модули? Как написать кроcсплатформенное (KMM) приложение на Decompose? Дайте знать, про что вам было бы интересно прочитать.