Это вторая часть из серии статей про компонентный подход. Если вы не читали первую часть Компонентный подход. Боремся со сложностью в 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,
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.
Комментарии (15)
ws233
11.01.2023 09:43Можно еще отметить, что компонент обязан удовлетворять шаблону проектирования "Composite" ("Компоновщик"). Этот шаблон накладывает определенные ограничение на то, кто и как собирает компонент целиком. Спойлер - сам компонент себя собирает. Также этот шаблон накладывает ограничения на то, как внутренности компонента видны снаружи компонента, на то, каким вообще должен быть интерфейс компонента, и на то, как взаимодействуют вложенные и родительский компоненты друг с другом. Это тоже немаловажный момент при проектировании компонентов. Спойлера тут не будет. Предлагаю всем заинтересованным самим изучить шаблон и сообразить, как его применять на практике для проектирования компонентов.
Еще для взаимодействия дочерних и родительских компонентов крайне рекомендуется использовать шаблон проектирования "Команда". Он идеально сочетается с шаблоном "Компоновщик". И совсем высший пилотаж – это добавить в архитектуру компонента шаблон "Chain of response" или "Responder chain" (как его называет Apple), который еще прекраснее ложится на предыдущие 2 шаблона и, наверняка, должен быть реализован в Android из коробки (я iOS-разработчик, поэтому пока лишь делаю такое предположение). Эти 2 шаблона помогут вам сделать вашу схему гораздо менее связной и более переиспользуемой. Это про callback'и общения дочерних компонентов с родительскими и выше до контроллера (или презентера, или вьюмодели). Callback на самом деле в вашей схеме все очень сильно портит.
a_artikov Автор
11.01.2023 15:34+1Спасибо за дополнение.
Я не пробовал использовать упомянутые вами паттерны для реализации компонентного подхода. Будет интересно попробовать.
qwert2603
Спасибо за статью! Тоже не раз убеждался, что подобные компоненты сильно упрощают код.
После прочтения осталось пара вопросов:
почему в
SignInComponent
добавлено несколькоStateFlow
для полей-состояний вместо одного? Хранение состояния "по частям" может привести к проблемам с консистентностью, а единый объект-стейт будет проще логировать и упростит отладку.как лучше упростить создание дочерних компонентов с помощью DI? Сейчас в
RealMainComponent
дочерние компоненты создаются вручную, и при появлении зависимостей у дочерних компонентов (например, интеракторы и репозитории) их создание превратится в бойлерплейт.a_artikov Автор
Привет, Саша.
Я сделал
login
,password
иinProgress
отдельными полями, потому что они могут изменяться независимо друг от друга. Если делать состояние компонента единым объектом, то это уже будет ближе к MVI, нежели к MVVM. Я не фанат MVI и редко его использую. Но, если тебе нравится MVI, то, конечно, его можно применять с компонентным подходом.В реальных приложениях я использую DI-фреймворк Koin. Для этого я создаю класс
ComponentFactory
— обертку поверх DI-контейнера с методами для создания компонентов. СамComponentFactory
тоже зарегистрирован в DI, поэтому компоненты могут получить к нему доступ. Вот gist с примером.kavaynya
Это больше дело привычки и удобства, чем приверженность какой-то архитектуре. Разделение на отдельные поля действительно удобен, если используешь databinding. А в Compose уже не столь удобно.
За это большое спасибо, стоит изучить и перейти уже на decompose, ибо с google navigation не так удобно
a_artikov Автор
Расскажите, пожалуйста, с какими неудобствами вы столкнулись, используя MVVM с Jetpack Compose?
kavaynya
Не с MVVM, а с выделением в отдельное поле, вместо объединения в один class.
Самое простое валидация по полям
login
,password
и включение кнопки "войти".Когда они у вам в одном классе, то достаточно написать расширение для этого класса. А когда они разделены, вы либо их будете в UI проверять, что его будет нагромождать, либо будете создавать новое поле, которое будет подписываться на нужные поля.
a_artikov Автор
Выносить логику в UI, конечно, не стоит.
А вот второй вариант - создать еще одно поле, которое будет автоматически вычисляться на основе других полей, по-моему, хороший. Вопрос в том, как это сделать удобнее.
Если вы используете
StateFlow
, то нужно написать утилитную функцию (назовем ееderived
) для комбинирования flow. С ней код получится таким:Или можно для состояния компонентов использовать
State
из Jetpack Compose и встроенную функциюderivedStateOf
. Получится так:kavaynya
Это я и имел ввиду, когда писал следующее (виноват, что написал не так конкретно, как вы)
либо будете создавать новое поле, которое будет подписываться на нужные поля
P.s. для комбинирования нескольких
Flow
используетсяcombine
P.s.s со
State
из Compose ваш пример сработает лишь раз, во время создания класса. Чтобы динамично считывать значения изState
не из compose-функции надо использоватьsnapshotFlow { }
, и тогда надо использовать первый вариант сcombine
a_artikov Автор
combine
возвращаетFlow
. Утилитаderived
будет возвращатьStateFlow
, чтоб было удобнее.Про
derivedStateOf
вы ошибаетесь. Она неявно подписывается на поля из лямбда-функции и потом автоматически пересчитывается при изменении любого из них.kavaynya
О каком удобстве вы говорите. Из Compose-функции можно одинаково успешно подписываться и на
StateFlow
иFlow
derivedStateOf
делает это в Compose-функции. И то с помощьюremember
https://developer.android.com/jetpack/compose/side-effects#derivedstateofa_artikov Автор
В отличие от
StateFlow
уFlow
нельзя получить текущее значение. Это значит, что вcollectAsState
мы будем обязаны передать аргументinitial
- в этом неудобство.Про
derivedStateOf
просто проверьте, если не верите.kavaynya
Получить текущее состояние у
Flow
можно, надо вызватьfirst()
. Но этоsuspend
функция, что не всегда удобно, не могу не согласиться. Но значение по умолчанию, всегда надо передавать вStateFlow
. Поэтому достаточно вызватьstateIn
у любойFlow
. За своей функцией это можно скрыть, но это дело привычки.Про
derivedStateOf
не поверю, ибо только на днях с ним экспериментировал.qwert2603
Привет!
Мне тоже ближе MVVM, и использование единого стейта из MVI позволяет получить лучшее из обоих подходов. С единым стейтом включение кнопки "войти" будет всего-лишь полем в классе стейта без необходимости создавать отдельный
StateFlow
или использоватьderivedStateOf
.Кроме того, использование единого стейта для компонента очень удобно для написания проверок в unit-тестах. Достаточно будет проверить только 1 значение вместо нескольких.
Также в другом комменте писали про складывание кол-ва состояний. И при использовании единого класса стейта гораздо проще будет ограничить невалидные состояния (кинуть exception в конструкторе), чем при разделении стейта на части.
ws233
Только учитывайте, что одно общее состояние у Вас все равно складывается, как композиция конкретных состояний дочерних элементов. Число этих состояний равно произведению чисел состояний каждого конкретного дочернего элемента. 3 дочерних компонента по 3 состояния в каждом дают конечный автомат из 27 состояний. Теория комбинаторики же. Попытка все 27 впихнуть в 1 конечный автомат сделает Вам только больнее. А вот конечный автомат, который объединяет состояния трех вложенных компонентов будет иметь гораздо меньше состояний. Шаблон "Компоновщик" тоже очень помогает понять, как правильно работать с вложенными компонентами.