
Это вторая часть из серии статей про компонентный подход. Если вы не читали первую часть Компонентный подход. Боремся со сложностью в Android-приложениях, то рекомендую начать с нее.
Ранее мы обсудили, что компонентный подход — это способ организации приложения в виде иерархии компонентов: UI-элементы ➜ функциональные блоки ➜ экраны ➜ флоу ➜ приложение. Такая структура позволяет эффективно бороться со сложностью экранов и навигации.
Предлагаю опробовать этот подход на практике. Будем использовать библиотеку Decompose для создания простых и сложных экранов. Рассмотрим примеры из реальных приложений. Надеюсь, будет интересно.
Библиотека Decompose
Компонентный подход можно применять с любых технологическим стеком, но есть библиотеки, которые сильно упрощают эту задачу. Одна из них — это библиотека Decompose. Ее автор — разработчик из Google Аркадий Иванов.
Decompose отлично подходит для создания функциональных блоков, экранов, флоу и структуры всего приложения. Он избыточен для UI-элементов, поскольку в них мало логики.
Decompose дает простой и удобный механизм для работы с компонентами. Я не буду описывать все ее возможности, благо что у библиотеки подробная документация. Чтобы создавать экраны с помощью Decompose, нам понадобятся:
ComponentContext— это главная сущность в Decompose, сердце для наших компонентов. Благодаря ему компонент обретает жизненный цикл — создается, функционирует и уничтожается.childContext— позволяет создавать дочерние компоненты.
Decompose лучше всего работает в связке с декларативными UI-фреймворками, поэтому в примерах я буду использовать Jetpack Compose.
Если вы используете классический стек (xml-верстка + 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: navigate to the next screen
}
}
}Пробежимся по основным моментам:
В интерфейсе мы объявили свойства компонента и методы для обработки пользовательских действий. Благодаря
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,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
if (inProgress) {
CircularProgressIndicator()
} else {
Button(onClick = component::onSignInClick)
}
}
}Мы связываем компонент с его UI:
Получаем значения из
StateFlowс помощьюcollectAsStateи используем их в UI-элементах. UI будет перерисовываться автоматически при изменении свойств компонента.Привязываем ввод текста и нажатия на кнопку к методам-обработчикам компонента.
Важное про терминологию
У слова «компонент» закрепилось два значения. В широком смысле компонент это весь код, который отвечает за определенную функциональность. То есть, к компоненту относятся SignInComponent, RealSignInComponent, SignInUi и даже AuthorizationRepository. Но пользователи библиотеки Decompose привыкли называть компонентом и сам класс / интерфейс, отвечающий за логику компонента — RealSignInComponent и SignInComponent. Обычно это не вызывает путаницу, и по контексту понятно, что имеется ввиду.
Превью для UI
Разделение компонента на интерфейс и реализацию нужно, чтобы сделать превью — в Android Studio рядом с кодом будет отображаться, как выглядит UI. Для этого сделаем fake-реализацию компонента и подключим к ней превью:
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, воспользуемся утилитным методом из Decompose defaultComponentContext. Его нужно вызывать из Activity. Жизненный цикл ComponentContext будет привязан к жизненному циклу Activity.
Получится такой код:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val rootComponent = RealSignInComponent(defaultComponentContext(), ...)
setContent {
AppTheme {
SignInUi(rootComponent)
}
}
}
}Компонент для простого экрана готов.
Разбиваем сложный экран на части
Сложный экран имеет смысл разбить на части. Такой экран будет состоять из родительского компонента и нескольких дочерних компонентов — функциональных блоков.
В качестве примера рассмотрим главный экран приложения для подготовки к водительскому экзамену:

На нем хорошо видны отдельные блоки: тулбар с прогрессом, карточка “следующий тест”, все тесты, теория, экзамен, обратная связь.
Дочерние компоненты
Сделаем по компоненту для каждого функционального блока. В их коде нет ничего нового — интерфейс и реализация компонента, UI для него.
Например, так выглядит компонент тулбара:
interface ToolbarComponent {
val passingPercent: StateFlow<Int>
fun onHintClick()
}class RealToolbarComponent(componentContext: ComponentContext) :
ComponentContext by componentContext, ToolbarComponent {
// some logic
}@Composable
fun ToolbarUi(component: ToolbarComponent) {
// some 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. Но это плохое решение! Если так сделать, то компонент обратной связи начнет выполнять не относящуюся к нему обязанность — управлять теоретическими материалами. Продолжая добавлять такие связи между компонентами, мы быстро сделаем их перегруженными и непереиспользуемыми.
Поступим по-другому. Пусть родительский компонент отвечает за межкомпонентное взаимодействие. Для уведомления о нужном событии будем использовать callback.
Организуем код так:
В
TheoryComponentдобавим методunlockBonusTheoryMaterial, который будет открывать доступ к подарочному учебному материалу.В
RealFeedbackComponentчерез конструктор передадим callbackonPositiveFeedbackGiven: () -> Unit. Компонент будет вызывать его в нужный момент.В
RealMainComponentсвяжем эти два компонента друг с другом:
override val feedbackComponent = RealFeedbackComponent(
childContext(key = "feedback"),
onPositiveFeedbackGiven = {
theoryComponent.unlockBonusTheoryMaterial()
}
)
Итого, правила межкомпонентного взаимодействия такие:
Дочерние компоненты не могут взаимодействовать друг с другом напрямую.
Дочерний компонент может уведомлять своего родителя через callback.
Родитель может вызывать метод дочернего компонента напрямую.
Дополнительные материалы
Decompose
Decompose на GitHub — краткое описание библиотеки, issues и discussions, возможность поставить звездочку проекту.
Документация Decompose — узнайте, какие еще возможности дает ComponentContext.
Другие библиотеки
RIBs — одна из первых опенсорс реализаций компонентного подхода для мобильных приложений.
appyx — современная библиотека, но есть минус — библиотека завязана на Jetpack Compose.
Классический стек
Статья “Работа с толстофичами: как разобрать слона на части и собрать обратно” — пример декомпозиции сложного экрана. Cтек ��� фрагменты и MVI.
Продолжение следует
Мы закончили тему сложных экранов. Применяя описанные приемы, вы сможете справиться с экранами любой сложности.
Далее по плану — организация навигации с помощью Decompose.
