
Это вторая часть из серии статей про компонентный подход. Если вы не читали первую часть Компонентный подход. Боремся со сложностью в 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() val password by component.password.collectAsState() 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. Для этого сделаем 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, HomeComponent { 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.
ссылка на оригинал статьи https://habr.com/ru/post/709808/
Добавить комментарий