Компонентный подход. Реализуем экраны с помощью библиотеки Decompose
Это вторая часть из серии статей про компонентный подход. Если вы не читали первую часть Компонентный подход. Боремся со сложностью в 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.