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

В статье много практики. Сначала я покажу, как с помощью Decompose и Jetpack Compose создавать отдельные флоу приложения. Далее обсудим реализацию bottom-навигации. И, наконец, объединим несколько флоу воедино, чтоб получить навигацию по всему приложению.

Я покажу примеры из реального приложения. Вы увидите, что предлагаемый подход хорошо подходит для больших приложений с десятками, а то и сотнями экранов.

Приложение со сложной навигацией

Библиотека Decompose очень выручила мою команду, когда нужно было организовать сложную навигацию. Мы делали приложение для крупной технологической компании Sever Minerals. Это приложение — личный кабинет сотрудников. В нём они выполняют свои рабочие задачи: проходят обучение, узнают новости компании, планируют встречи, оформляют отпуска, выписывают справки и т. д. Всего 10 сценариев и около 80-ти уникальных экранов.

Главный экран приложения Sever Minerals for Employees
Главный экран приложения Sever Minerals for Employees
Флоу «Опросы»
Флоу «Опросы»
Флоу «Заказ справок»
Флоу «Заказ справок»
Масштабы всего приложения. Экранов так много, что на общем скриншоте их невозможно рассмотреть.
Масштабы всего приложения. Экранов так много, что на общем скриншоте их невозможно рассмотреть.

Флоу

Разберёмся, как с помощью 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
Дочерний компонент уведомляет своего родителя через 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-навигацию тоже можно рассматривать как флоу. Компонент с боттом-баром будет переключать несколько дочерних компонентов.

Bottom-навигация в приложении Sever Minerals for Employees
Bottom-навигация в приложении Sever Minerals for Employees

Но как организовать такую навигацию? Переключение экранов работает не по принципу стека. Если пользователь с вкладки «Главная» переключился на «Сервисы», а потом обратно на «Главную», то нет смысла удалять компонент для «Сервисов», ведь пользователь в любой момент может вновь вернуться на «Сервисы». Хотелось бы переиспользовать уже созданные компоненты.

Оказывается, С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, как показано на схеме:

Двойное пробрасывание событий через callback-и
Двойное пробрасывание событий через callback-и

Реализация 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-компонент, это разделить его на два дочерних компонента: один — для неавторизованной зоны, а другой — для авторизованной:

Схема из доклада “RIBs - Uber's new mobile architecture that scales to hundreds of engineers by Tuomas Artman”
Схема из доклада “RIBs - Uber's new mobile architecture that scales to hundreds of engineers by Tuomas Artman”

Решение о таком разделении нужно принимать взвешенно. Оно сработает, только если заранее известно, на какие экраны сможет попасть авторизованный пользователь, а на какие нет.

Вложенную навигацию принято считать сложной темой. Часто разработчики делают плоскую иерархию, стремясь избежать проблем. Но с компонентным подходом вложенность это не проблема, а, наоборот, инструмент для борьбы со сложностью. Разбивайте код на простые компоненты, не бойтесь добавлять больше уровней вложенности, и тогда вам будут подвластны приложения любых масштабов.

Дополнительные материалы

Decompose

Примеры

  • Официальный пример для Decompose — демонстрирует все возможности Decompose. Показано, как сделать master-detail навигацию и показ диалоговых окон. Поддерживает платформы: Android, iOS, Desktop, Web.

  • Todoapp — кроссплатформенное приложение Todo List на Decompose.

  • MobileUp-Android-Template — шаблон Android-проекта от компании MobileUp. Демонстрирует нашу архитектуру и технологический стек.

Что дальше?

Вы прочитали последнюю из трех запланированных статей про компонентный подход. Я надеюсь, для вас это станет не концом, а началом погружения в эту тему.

Конечно, есть еще множество тем, прямо или косвенно относящихся к компонентному подходу. Как сделать загрузку данных, когда экран разбит на десяток независимых компонентов? Как обрабатывать ошибки? Как писать тесты для компонентов? Как делить компоненты на модули? Как написать кроcсплатформенное (KMM) приложение на Decompose? Дайте знать, про что вам было бы интересно прочитать.

Комментарии (0)