Компонентный подход. Реализуем экраны с помощью библиотеки 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()    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)            }        }    } }

Компонент для простого экрана готов.

Разбиваем сложный экран на части

Сложный экран имеет смысл разбить на части. Такой экран будет состоять из родительского компонента и нескольких дочерних компонентов — функциональных блоков.

В качестве примера рассмотрим главный экран приложения для подготовки к водительскому экзамену:

Главный экран приложения DMV Genie и функциональные блоки на нем
Главный экран приложения DMV Genie и функциональные блоки на нем

На нем хорошо видны отдельные блоки: тулбар с прогрессом, карточка “следующий тест”, все тесты, теория, экзамен, обратная связь.

Дочерние компоненты

Сделаем по компоненту для каждого функционального блока. В их коде нет ничего нового — интерфейс и реализация компонента, 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 через конструктор передадим callback onPositiveFeedbackGiven: () -> Unit. Компонент будет вызывать его в нужный момент.

  • В RealMainComponent свяжем эти два компонента друг с другом:

override val feedbackComponent = RealFeedbackComponent(    childContext(key = "feedback"),    onPositiveFeedbackGiven = {        theoryComponent.unlockBonusTheoryMaterial()    } )
Межкомпонентное взаимодействие
Межкомпонентное взаимодействие

Итого, правила межкомпонентного взаимодействия такие:

  • Дочерние компоненты не могут взаимодействовать друг с другом напрямую.

  • Дочерний компонент может уведомлять своего родителя через callback.

  • Родитель может вызывать метод дочернего компонента напрямую.

Дополнительные материалы

Decompose

  • Decompose на GitHub  — краткое описание библиотеки, issues и discussions, возможность поставить звездочку проекту.

  • Документация Decompose — узнайте, какие еще возможности дает ComponentContext.

Другие библиотеки

  • RIBs — одна из первых опенсорс реализаций компонентного подхода для мобильных приложений.

  • appyx — современная библиотека, но есть минус — библиотека завязана на Jetpack Compose.

Классический стек

Продолжение следует

Мы закончили тему сложных экранов. Применяя описанные приемы, вы сможете справиться с экранами любой сложности.

Далее по плану — организация навигации с помощью Decompose.


ссылка на оригинал статьи https://habr.com/ru/post/709808/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *