
Эта статья является второй в серии, посвященной компонентному подходу. Если вы еще не читали первую часть, начните с нее. В ней мы рассмотрели, как компонентный подход организует приложение в иерархическую структуру: 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 будет автоматически обновляться при каждом изменении свойств компонента.
- Подключаем ввод текста и нажатие кнопок к методам обработки компонента.
Важная информация о терминологии
Со временем термин «компонент» приобрел два различных значения. В широком смысле компонент включает весь код, отвечающий за определенную функциональность. Сюда входят такие сущности, как SignInComponent, RealSignInComponent, SignInUi и даже 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)
}
}
}
}
Компонент для простого экрана готов.
Разбиение сложного экрана на части
Разбиение сложного экрана на части — практичный подход. Для этого потребуется родительский компонент и несколько дочерних компонентов, каждый из которых представляет различные функциональные блоки. Для примера рассмотрим главный экран приложения, предназначенного для подготовки к экзамену по вождению:

На этом экране четко видны отдельные блоки, включая панель инструментов с информацией об успехах, карточку «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
}
Аналогично создадим NextTestComponent, TestsComponent, TheoryComponent, ExamComponent, FeedbackComponent и их соответствующие 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.
Читайте также:
- Реализация функции Pull-to-refresh с помощью Compose Material 3
- Как создать импульсный эффект в Jetpack Compose
- Почему имена Android-пакетов имеют вид com.xyz.abc?
Читайте нас в Telegram, VK и Дзен
Перевод статьи Artur Artikov: Component-based Approach. Implementing Screens with the Decompose Library





