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

Теперь применим этот подход на практике: используем библиотеку Decompose для создания как простых, так и сложных экранов. Рассмотрим примеры реальных приложений, чтобы понять, как это работает. 

Библиотека Decompose

Компонентный подход универсален и может быть реализован с использованием различных технологических стеков. Среди библиотек, которые облегчают этот подход, выделяется библиотека Decompose, созданная Аркадием Ивановым — разработчиком из Google.

Библиотека Decompose подходит прежде всего для создания функциональных блоков, экранов, потоков и общей структуры приложения. Она менее необходима для UI-элементов, которые обычно требуют минимальной логики.

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

  • ComponentContext — основная сущность в Decompose, служащая сердцем компонента. Она определяет жизненный цикл компонентов, позволяя им создаваться, функционировать и в конечном итоге уничтожаться.
  • childContext — функция, которая позволяет создавать дочерние компоненты.

Библиотека Decompose наиболее эффективна, когда используется вместе с декларативными UI-фреймворками. В примерах для этой серии будет использована Jetpack Compose.

Если вы работаете с более традиционным стеком (XML-layout, Fragment и ViewModel), не волнуйтесь. Компонентный подход — гибкая концепция, не ограниченная конкретным набором библиотек. Освоив его с помощью Decompose, вы сможете адаптировать его принципы к любой технологии.

Создание простого экрана с помощью Decompose

Создадим экран входа в систему с помощью Decompose и Jetpack Compose. Учитывая его простоту, нет необходимости разделять его на отдельные функциональные блоки.

Экран входа в систему является примером простого экрана

Логика компонента

Начнем с логики этого экрана. Создадим интерфейс SignInComponent, а также его реализацию под названием RealSignInComponent. Обоснование внедрения интерфейса будет рассмотрено чуть позже.

Вот код для SignInComponent:

interface SignInComponent {

   val login: StateFlow<String>

   val password: StateFlow<String>

   val inProgress: StateFlow<Boolean>

   fun onLoginChanged(login: String)

   fun onPasswordChanged(password: String)

   fun onSignInClick()
}

И код для RealSignInComponent:

class RealSignInComponent(
   componentContext: ComponentContext,
   private val authorizationRepository: AuthorizationRepository
) : ComponentContext by componentContext, SignInComponent {

   override val login = MutableStateFlow("")

   override val password = MutableStateFlow("")

   override val inProgress = MutableStateFlow(false)

   private val coroutineScope = componentCoroutineScope()

   override fun onLoginChanged(login: String) {
       this.login.value = login
   }

   override fun onPasswordChanged(password: String) {
       this.password.value = password
   }

   override fun onSignInClick() {
       coroutineScope.launch {
           inProgress.value = true
           authorizationRepository.signIn(login.value, password.value)
           inProgress.value = false

           // TODO: перейти к следующему экрану
       }
   }
}

Рассмотрим ключевые моменты:

  • В интерфейсе определяем свойства компонента и методы обработки действий пользователя. С помощью StateFlow эти свойства становятся наблюдаемыми, то есть автоматически уведомляют об изменениях.
  • Передаем ComponentContext в конструктор класса и реализуем тот же интерфейс, используя делегирование (обозначается ключевым словом by). Этот подход является стандартной практикой при создании компонентов с помощью Decompose, что важно запомнить.
  • Используем метод componentCoroutineScope для создания CoroutineScope для выполнения асинхронных операций (корутин). CoroutineScope автоматически отменяется при уничтожении компонента, используя функции жизненного цикла ComponentContext.
  • В методе onSignInClick выполняем процесс входа в систему, используя имя пользователя и пароль. Я упростил этот пример, исключив проверку полей и обработку ошибок. После успешного входа в систему мы обычно переходим к следующему экрану. Однако, поскольку особенности навигации еще не рассмотрены, отметим это в TODO для реализации в будущем.

В целом, код прост. Если вы знакомы с MVVM, он будет для вас достаточно интуитивно понятным.

UI-компонент

Теперь реализуем UI экрана. Для краткости опустим некоторые настройки макета и сосредоточимся только на основных частях:

@Composable
fun SignInUi(component: SignInComponent) {

   val login by component.login.collectAsState(Dispatchers.Main.immediate)
   val password by component.password.collectAsState(Dispatchers.Main.immediate)
   val inProgress by component.inProgress.collectAsState()

   Column {
       TextField(
           value = login, 
           onValueChange = component::onLoginChanged
       )

       TextField(
           value = password,
           onValueChange = component::onPasswordChanged
       )

       if (inProgress) {
           CircularProgressIndicator()
       } else {
           Button(onClick = component::onSignInClick)
       }
   }
}

Чтобы связать компонент с его UI:

  • Получаем значения из StateFlow, используя функцию collectAsState, и включаем эти значения в UI-элементы. UI будет автоматически обновляться при каждом изменении свойств компонента.
  • Подключаем ввод текста и нажатие кнопок к методам обработки компонента.

Важная информация о терминологии

Со временем термин «компонент» приобрел два различных значения. В широком смысле компонент включает весь код, отвечающий за определенную функциональность. Сюда входят такие сущности, как SignInComponentRealSignInComponentSignInUi и даже AuthorizationRepository. Однако в контексте библиотеки Decompose «компонент» часто относится к конкретному классу или интерфейсу, который управляет логикой, а именно к RealSignInComponent и SignInComponent. Как правило, такое двойное использование не приводит к путанице, поскольку предполагаемый смысл обычно ясен из контекста.

UI-превью 

Введение интерфейса для компонента необходимо для добавления превью в Android Studio, где визуальное превью UI отображается рядом с кодом. Для упрощения этого процесса выполним фиктивную реализацию компонента и свяжем ее с превью:

class FakeSignInComponent : SignInComponent {

override val login = MutableStateFlow("login")
override val password = MutableStateFlow("password")
override val inProgress = MutableStateFlow(false)

override fun onLoginChanged(login: String) = Unit
override fun onPasswordChanged(password: String) = Unit
override fun onSignInClick() = Unit
}

@Preview(showSystemUi = true)
@Composable
fun SignInUiPreview() {
AppTheme {
SignInUi(FakeSignInComponent())
}
}

Корневой ComponentContext

Последний момент, который следует рассмотреть, — где получить ComponentContext, который необходимо предоставить RealSignInComponent.

ComponentContext должен быть создан, но важно отметить, что это делается только один раз для всего приложения, а именно для корневого компонента. Другие компоненты также будут иметь свои ComponentContext’ы, которые будут получены иначе (этот вопрос рассмотрим позже).

В целях данного обсуждения будем считать, что в приложении есть только один экран — экран входа в систему. SignInComponent фактически становится корневым компонентом. Для инициализации ComponentContext используем служебный метод defaultComponentContext из Decompose. Этот метод должен вызываться из Activity, обеспечивая синхронизацию жизненного цикла ComponentContext с жизненным циклом Activity.

Код будет выглядеть следующим образом:

class MainActivity : ComponentActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)

       val rootComponent = RealSignInComponent(defaultComponentContext(), ...)

       setContent {
           AppTheme {
               SignInUi(rootComponent)
           }
       }
   }
}

Компонент для простого экрана готов.

Разбиение сложного экрана на части

Разбиение сложного экрана на части — практичный подход. Для этого потребуется родительский компонент и несколько дочерних компонентов, каждый из которых представляет различные функциональные блоки. Для примера рассмотрим главный экран приложения, предназначенного для подготовки к экзамену по вождению:

Главный экран приложения DMV Genie с его функциональными блоками

На этом экране четко видны отдельные блоки, включая панель инструментов с информацией об успехах, карточку «Next Test» («Следующий тест»), разделы для всех тестов, теории, экзамена и обратной связи.

Дочерние компоненты

Теперь разработаем отдельный компонент для каждого функционального блока. Код для этих компонентов соответствует знакомой схеме и состоит из интерфейса компонента, его реализации и UI.

Например, вот как выглядит компонент панели инструментов:

interface ToolbarComponent {

val passingPercent: StateFlow<Int>

fun onHintClick()
}
class RealToolbarComponent(componentContext: ComponentContext) :
ComponentContext by componentContext, ToolbarComponent {
// логика
}
@Composable
fun ToolbarUi(component: ToolbarComponent) {
   // UI
}

Аналогично создадим NextTestComponentTestsComponentTheoryComponentExamComponentFeedbackComponent и их соответствующие UI.

Родительский компонент

Компонент экрана будет служить родительским для компонентов функционального блока.

Объявим его интерфейс:

interface MainComponent {

   val toolbarComponent: ToolbarComponent

   val nextTestComponent: NextTestComponent

   val testsComponent: TestsComponent

   val theoryComponent: TheoryComponent

   val examComponent: ExamComponent

   val feedbackComponent: FeedbackComponent

}

Как видите, родительский компонент не скрывает свои дочерние компоненты. Напротив, он открыто объявляет их в интерфейсе.

В реализации будем использовать метод childContext из Decompose:

class RealMainComponent(
   componentContext: ComponentContext
) : ComponentContext by componentContext, MainComponent {

   override val toolbarComponent = RealToolbarComponent(
      childContext(key = "toolbar")
   )

   override val nextTestComponent = RealNextTestComponent(
       childContext(key = "nextTest")
   )

   override val testsComponent = RealTestsComponent(
       childContext(key = "tests")
   )

   override val theoryComponent = RealTheoryComponent(
       childContext(key = "theory")
   )

   override val examComponent = RealExamComponent(
       childContext(key = "exam")
   )

   override val feedbackComponent = RealFeedbackComponent(
       childContext(key = "feedback")
   )
}

Метод childContext создает новый дочерний ComponentContext. Важно, чтобы у каждого дочернего компонента был свой контекст. В Decompose требуется, чтобы эти дочерние контексты имели уникальные имена, что достигается с помощью параметра key.

Теперь остается добавить UI, чтобы все было готово:

@Composable
fun MainUi(component: MainComponent) {
   Scaffold(
       topBar = { ToolbarUi(component.toolbarComponent) }
   ) {
       Column(Modifier.verticalScroll()) {
           NextTestUi(component.nextTestComponent)

           TestsUi(component.testsComponent)

           TheoryUi(component.theoryComponent)

           ExamUi(component.examComponent)

           FeedbackUi(component.feedbackComponent)
       }
   }
}

В результате код компонента стал одновременно простым и компактным. Мы бы не добились этого, не разбив экран на части.

Организация взаимодействия между компонентами

В идеале дочерние компоненты должны быть полностью независимы друг от друга. Однако это не всегда возможно. Иногда событие в одном компоненте может потребовать определенного действия в другом.

Рассмотрим экран из предыдущего примера. Представим ситуацию, когда, оставив положительный отзыв, пользователь получает бесплатный учебный материал в блоке «Теория» (это требование не является частью самого приложения; я привел его в иллюстративных целях).

Нам нужно организовать взаимодействие между FeedbackComponent и TheoryComponent. Первая мысль, которая может прийти на ум, — создать ссылку на TheoryComponent из RealFeedbackComponent. Однако это неоптимальное решение! Оно привело бы к тому, что FeedbackComponent стал бы выполнять задачи, выходящие за рамки его основной функции, такие как управление теоретическими материалами. Если продолжить добавлять прямые связи между компонентами, они быстро станут перегруженными и не пригодными для повторного использования.

Применим другой подход. Возложим ответственность за межкомпонентное взаимодействие на родительский компонент. Чтобы сигнализировать о нужном событии, будем использовать обратный вызов.

Вот как будем структурировать код:

  • В TheoryComponent представим метод под названием unlockBonusTheoryMaterial, который позволит получить доступ к бонусным учебным материалам.
  • В RealFeedbackComponent передадим функцию обратного вызова onPositiveFeedbackGiven: () -> Unit через конструктор. Эта функция будет запущена компонентом в соответствующее время.
  • Затем в RealMainComponent установим связь между этими двумя компонентами:
override val feedbackComponent = RealFeedbackComponent(
childContext(key = "feedback"),
onPositiveFeedbackGiven = {
theoryComponent.unlockBonusTheoryMaterial()
}
)
Межкомпонентное взаимодействие

В заключение приведу рекомендации по взаимодействию между компонентами:

  • Дочерние компоненты не должны взаимодействовать напрямую друг с другом.
  • Дочерний компонент может уведомлять свой родительский компонент с помощью обратного вызова.
  • Родительскому компоненту разрешено напрямую вызывать метод дочернего компонента.

Материалы, рекомендованная для углубленного изучения

Decompose

  • Decompose на GitHub: краткий обзор библиотеки, проблемы, обсуждения и возможность запуска проекта.
  • Документация по Decompose: узнайте о дополнительных возможностях, предоставляемых ComponentContext.

Другие библиотеки

  • RIBs: одна из первых реализаций компонентного подхода для мобильных приложений с открытым исходным кодом.
  • appyx: современная библиотека с существенным ограничением — интегрирована исключительно с Compose Multiplatform.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Artur Artikov: Component-based Approach. Implementing Screens with the Decompose Library

Предыдущая статьяКод на Python медленный? Вот 5 простых решений, чтобы быстро его ускорить
Следующая статьяФункциональное программирование Java: элегантное применение Predicate и Function