Как стать автором
Обновить

Компонентный подход. Реализуем экраны с помощью библиотеки Decompose

Время на прочтение8 мин
Количество просмотров10K

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

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

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

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

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

Главный экран приложения 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.

Все статьи

  1. Компонентный подход. Боремся со сложностью в Android-приложениях

  2. Компонентный подход. Реализуем экраны с помощью библиотеки Decompose (вы здесь)

  3. Компонентный подход. Организуем навигацию с помощью библиотеки Decompose

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии21

Публикации