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

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

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

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

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

Главный экран приложения 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, 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 через конструктор передадим 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.

Комментарии (15)


  1. qwert2603
    10.01.2023 17:46
    +2

    Спасибо за статью! Тоже не раз убеждался, что подобные компоненты сильно упрощают код.

    После прочтения осталось пара вопросов:

    1. почему в SignInComponent добавлено несколько StateFlow для полей-состояний вместо одного? Хранение состояния "по частям" может привести к проблемам с консистентностью, а единый объект-стейт будет проще логировать и упростит отладку.

    2. как лучше упростить создание дочерних компонентов с помощью DI? Сейчас в RealMainComponent дочерние компоненты создаются вручную, и при появлении зависимостей у дочерних компонентов (например, интеракторы и репозитории) их создание превратится в бойлерплейт.


    1. a_artikov Автор
      11.01.2023 00:53
      +1

      Привет, Саша.

      1. Я сделал login, password и inProgress отдельными полями, потому что они могут изменяться независимо друг от друга. Если делать состояние компонента единым объектом, то это уже будет ближе к MVI, нежели к MVVM. Я не фанат MVI и редко его использую. Но, если тебе нравится MVI, то, конечно, его можно применять с компонентным подходом.

      2. В реальных приложениях я использую DI-фреймворк Koin. Для этого я создаю класс ComponentFactory — обертку поверх DI-контейнера с методами для создания компонентов. Сам ComponentFactory тоже зарегистрирован в DI, поэтому компоненты могут получить к нему доступ. Вот gist с примером.


      1. kavaynya
        11.01.2023 09:19

        1. Это больше дело привычки и удобства, чем приверженность какой-то архитектуре. Разделение на отдельные поля действительно удобен, если используешь databinding. А в Compose уже не столь удобно.

        2. За это большое спасибо, стоит изучить и перейти уже на decompose, ибо с google navigation не так удобно


        1. a_artikov Автор
          11.01.2023 13:18

          Расскажите, пожалуйста, с какими неудобствами вы столкнулись, используя MVVM с Jetpack Compose?


          1. kavaynya
            11.01.2023 13:32
            +1

            Не с MVVM, а с выделением в отдельное поле, вместо объединения в один class.
            Самое простое валидация по полям loginpassword и включение кнопки "войти".
            Когда они у вам в одном классе, то достаточно написать расширение для этого класса. А когда они разделены, вы либо их будете в UI проверять, что его будет нагромождать, либо будете создавать новое поле, которое будет подписываться на нужные поля.


            1. a_artikov Автор
              11.01.2023 15:24

              Выносить логику в UI, конечно, не стоит.

              А вот второй вариант - создать еще одно поле, которое будет автоматически вычисляться на основе других полей, по-моему, хороший. Вопрос в том, как это сделать удобнее.

              Если вы используете StateFlow, то нужно написать утилитную функцию (назовем ееderived) для комбинирования flow. С ней код получится таким:

                  override val login = MutableStateFlow("")
              
                  override val password = MutableStateFlow("")
              
                  override val signInButtonEnabled = derived(login, password) { login, password ->
                      login.isNotBlank() && password.isNotBlank()
                  }

              Или можно для состояния компонентов использовать State из Jetpack Compose и встроенную функцию derivedStateOf. Получится так:

                  override val login by mutableStateOf("")
              
                  override val password by mutableStateOf("")
              
                  override val signInButtonEnabled by derivedStateOf {
                      login.isNotBlank() && password.isNotBlank()
                  }


              1. kavaynya
                11.01.2023 15:40

                Это я и имел ввиду, когда писал следующее (виноват, что написал не так конкретно, как вы)

                либо будете создавать новое поле, которое будет подписываться на нужные поля

                P.s. для комбинирования нескольких Flow используется combine

                P.s.s со State из Compose ваш пример сработает лишь раз, во время создания класса. Чтобы динамично считывать значения из State не из compose-функции надо использовать snapshotFlow { }, и тогда надо использовать первый вариант с combine


                1. a_artikov Автор
                  11.01.2023 15:47

                  combine возвращает Flow. Утилита derived будет возвращать StateFlow, чтоб было удобнее.

                  Про derivedStateOf вы ошибаетесь. Она неявно подписывается на поля из лямбда-функции и потом автоматически пересчитывается при изменении любого из них.


                  1. kavaynya
                    11.01.2023 15:58

                    О каком удобстве вы говорите. Из Compose-функции можно одинаково успешно подписываться и на StateFlow и Flow

                    derivedStateOf делает это в Compose-функции. И то с помощью remember https://developer.android.com/jetpack/compose/side-effects#derivedstateof


                    1. a_artikov Автор
                      11.01.2023 16:14

                      В отличие от StateFlow у Flow нельзя получить текущее значение. Это значит, что в collectAsState мы будем обязаны передать аргумент initial - в этом неудобство.

                      Про derivedStateOf просто проверьте, если не верите.


                      1. kavaynya
                        11.01.2023 16:22

                        Получить текущее состояние у Flow можно, надо вызвать first(). Но это suspend функция, что не всегда удобно, не могу не согласиться. Но значение по умолчанию, всегда надо передавать в StateFlow. Поэтому достаточно вызвать stateIn у любой Flow. За своей функцией это можно скрыть, но это дело привычки.
                        Про derivedStateOf не поверю, ибо только на днях с ним экспериментировал.


      1. qwert2603
        12.01.2023 16:43
        +1

        Привет!

        1. Мне тоже ближе MVVM, и использование единого стейта из MVI позволяет получить лучшее из обоих подходов. С единым стейтом включение кнопки "войти" будет всего-лишь полем в классе стейта без необходимости создавать отдельный StateFlow или использовать derivedStateOf.

        Кроме того, использование единого стейта для компонента очень удобно для написания проверок в unit-тестах. Достаточно будет проверить только 1 значение вместо нескольких.

        Также в  другом комменте писали про складывание кол-ва состояний. И при использовании единого класса стейта гораздо проще будет ограничить невалидные состояния (кинуть exception в конструкторе), чем при разделении стейта на части.


    1. ws233
      11.01.2023 10:02

      1. Только учитывайте, что одно общее состояние у Вас все равно складывается, как композиция конкретных состояний дочерних элементов. Число этих состояний равно произведению чисел состояний каждого конкретного дочернего элемента. 3 дочерних компонента по 3 состояния в каждом дают конечный автомат из 27 состояний. Теория комбинаторики же. Попытка все 27 впихнуть в 1 конечный автомат сделает Вам только больнее. А вот конечный автомат, который объединяет состояния трех вложенных компонентов будет иметь гораздо меньше состояний. Шаблон "Компоновщик" тоже очень помогает понять, как правильно работать с вложенными компонентами.


  1. ws233
    11.01.2023 09:43

    Можно еще отметить, что компонент обязан удовлетворять шаблону проектирования "Composite" ("Компоновщик"). Этот шаблон накладывает определенные ограничение на то, кто и как собирает компонент целиком. Спойлер - сам компонент себя собирает. Также этот шаблон накладывает ограничения на то, как внутренности компонента видны снаружи компонента, на то, каким вообще должен быть интерфейс компонента, и на то, как взаимодействуют вложенные и родительский компоненты друг с другом. Это тоже немаловажный момент при проектировании компонентов. Спойлера тут не будет. Предлагаю всем заинтересованным самим изучить шаблон и сообразить, как его применять на практике для проектирования компонентов.

    Еще для взаимодействия дочерних и родительских компонентов крайне рекомендуется использовать шаблон проектирования "Команда". Он идеально сочетается с шаблоном "Компоновщик". И совсем высший пилотаж – это добавить в архитектуру компонента шаблон "Chain of response" или "Responder chain" (как его называет Apple), который еще прекраснее ложится на предыдущие 2 шаблона и, наверняка, должен быть реализован в Android из коробки (я iOS-разработчик, поэтому пока лишь делаю такое предположение). Эти 2 шаблона помогут вам сделать вашу схему гораздо менее связной и более переиспользуемой. Это про callback'и общения дочерних компонентов с родительскими и выше до контроллера (или презентера, или вьюмодели). Callback на самом деле в вашей схеме все очень сильно портит.


    1. a_artikov Автор
      11.01.2023 15:34
      +1

      Спасибо за дополнение.
      Я не пробовал использовать упомянутые вами паттерны для реализации компонентного подхода. Будет интересно попробовать.