Site icon AppTractor

Введение в Trio: часть 1

В Airbnb мы разработали Android-фреймворк для экранной архитектуры Jetpack Compose, который мы назвали Trio. Trio построен на нашей библиотеке с открытым исходным кодом Mavericks, которая используется для поддержания навигации и состояния приложения внутри ViewModel.

Компания Airbnb начала разработку Trio более двух лет назад и использует его в продакшене уже более полутора лет. На нем построена значительная часть работающих экранов в Android-приложении Airbnb, и он позволил нашим инженерам создавать 100% функций в Compose UI.

В этой серии статей мы рассмотрим, как Mavericks можно использовать в современных приложениях на базе Compose. Мы обсудим проблемы архитектуры на базе Compose и то, как Trio пытался их решить. В частности, мы рассмотрим такие концепции, как:

Эта серия состоит из трех частей. В первой части рассматривается высокоуровневая архитектура Trio. Во второй  части будет подробно описана система навигации Trio, и в третье мы расскажем, как Trio использует Props для связи между экранами.

Общие сведения о Mavericks

Чтобы понять архитектуру Trio, важно знать основы Mavericks, на базе которого построен Trio. Изначально Airbnb выложил Mavericks в открытый доступ в 2018 году, чтобы упростить и стандартизировать управление состоянием в Jetpack ViewModel. Ознакомьтесь с этим постом, представляющим запуск Mavericks («MvRx») для более глубокого погружения.

Используемая практически во всех сотнях экранов приложения Airbnb для Android (и во многих других компаниях!), Mavericks — это библиотека управления состоянием, которая отделена от пользовательского интерфейса и может быть использована с любой системой пользовательского интерфейса. Основная концепция заключается в том, что экранный пользовательский интерфейс моделируется как функция состояния. Это гарантирует, что даже самый сложный экран может быть отрисован безопасным для потоков способом, не зависящим от порядка событий, приведших к его появлению, и легко поддающимся пониманию и тестированию.

Для достижения этой цели Mavericks применяет шаблон, согласно которому все данные, отображаемые ViewModel, должны содержаться в одном классе данных MavericksState. В простом примере со счетчиком состояние будет содержать текущий счет.

data class CounterState(
  val count: Int = 0
) : MavericksState

Свойства состояния могут быть обновлены в ViewModel только через вызов функции setState. Функция setState принимает лямбду “переходник” (reducer), которая, учитывая предыдущее состояние, выводит новое состояние. Мы можем использовать этот переходник для увеличения счета, просто добавив 1 к предыдущему значению.

class CounterViewModel : MavericksViewModel<CounterState>(...) {
  fun incrementCount() {
    setState {
      // this = previous state
      this.copy(count = count + 1)
    }
  }
}

Базовая модель MavericksViewModel регистрирует все вызовы setState и выполняет их последовательно в фоновом потоке. Это гарантирует безопасность потоков при одновременном внесении изменений в нескольких местах и гарантирует, что изменения нескольких свойств состояния будут атомарными, так что пользовательский интерфейс никогда не получит состояние, которое обновлено лишь частично.

MavericksViewModel отображает изменения состояния через свойство Coroutine Flow. В паре с реактивным пользовательским интерфейсом, например Compose, мы можем собирать последнее значение состояния и гарантировать, что пользовательский интерфейс будет обновляться при каждом изменении состояния.

counterViewModel.stateFlow.collectAsState().count

Этот однонаправленный цикл можно представить с помощью следующей диаграммы:

Проблемы с архитектурой на основе Фрагментов

Хотя Mavericks хорошо работает с управлением состояниями, мы все же столкнулись с некоторыми проблемами при разработке пользовательского интерфейса в Android, связанными с тем, что мы использовали архитектуру на основе Фрагментов, интегрированную с Mavericks. При таком подходе ViewModels в основном привязаны к Активити и разделяются между Фрагментами с помощью инъекций. Представления Фрагментов обновляются при изменении состояния ViewModel и обращаются к ViewModel для внесения изменений в состояние. Менеджер Фрагментов самостоятельно управляет навигацией, когда фрагменты нужно подтолкнуть или вытолкнуть.

Из-за такой архитектуры мы столкнулись с некоторыми трудностями, которые и послужили стимулом для создания Trio.

  1. Область действия — Совместное использование ViewModel несколькими Фрагментами основано на неявной инъекции ViewModel. Таким образом, неясно, какой фрагмент отвечает за первоначальное создание модели Активити ViewModel или за предоставление ей начальных аргументов.
  2. Коммуникация — сложно обмениваться данными между Фрагментами напрямую и с обеспечением безопасности типов. Опять же, поскольку ViewModel инжектируются, трудно обеспечить их прямое взаимодействие, и мы не можем контролировать порядок их создания.
  3. Навигация — Навигация осуществляется через менеджер фрагментов и должна происходить во фрагменте. Однако изменение состояния происходит в ViewModel. Это приводит к проблемам синхронизации между состояниями ViewModel и навигации. Сложно скоординировать сценарии типа «если — то», например, вызов навигации только после обновления значения состояния во ViewModel.
  4. Тестируемость — сложно изолировать пользовательский интерфейс для тестирования, поскольку он обернут во Фрагмент. Скриншот-тесты подвержены расслоению, а для мокирования состояния ViewModel требуется много непрямых действий, поскольку ViewModel внедряются во Фрагмент с помощью делегатов свойств.
  5. Реактивность — Mavericks обеспечивает однонаправленный поток состояний в представление, что полезно для согласованности и тестирования, но система представлений не очень хорошо приспособлена для реактивного обновления изменений состояния, и может быть сложно или неэффективно обновлять представление инкрементально при каждом изменении состояния.

Хотя некоторые из этих проблем можно было бы решить с помощью более совершенной архитектуры, основанной на Фрагментах, мы обнаружили, что Фрагменты слишком ограничивают возможности Compose, и решили полностью отказаться от них.

Почему мы создали Trio

В 2021 году наша команда начала изучать возможность внедрения Jetpack Compose и полного отказа от Фрагментов. Полностью перейдя на Compose, мы смогли бы лучше подготовиться к будущему Android-разработки и избавиться от многолетнего технического долга.

Продолжение использования Mavericks было важно для нас, поскольку у нас есть большой внутренний опыт работы с ним, и мы не хотели еще больше усложнять архитектурную миграцию, меняя подход к управлению состояниями. Мы увидели возможность переосмыслить, как Mavericks может поддерживать современное Android-приложение, и решить проблемы, с которыми мы столкнулись в нашей предыдущей архитектуре.

Используя Фрагменты, мы не могли гарантировать безопасное для типов взаимодействие между экранами в рантайме. Мы хотели иметь возможность кодифицировать ожидания относительно того, как ViewModel используются и шарятся, и как выглядят интерфейсы между экранами.

Мы также не чувствовали, что наши потребности полностью удовлетворяются компонентом Jetpack Navigation, особенно учитывая нашу сильно модульную кодовую базу и большое приложение. Компонент Navigation не является безопасным для типов, требует определения графа навигации в одном месте и не позволяет нам размещать состояние в нашей ViewModel. Мы искали новую архитектуру, которая могла бы обеспечить лучшую безопасность типов и поддержку модульности.

Наконец, нам нужна была архитектура, которая улучшила бы тестируемость, например, более стабильные скриншот и UI тесты, а также более простое тестирование навигации.

Мы рассматривали библиотеки с открытым исходным кодом Workflow и RIBs, но решили не использовать их, потому что они не были Compose-first и не были совместимы с Mavericks и другими уже существующими внутренними фреймворками.

Учитывая эти требования, мы решили разработать собственное решение, которое назвали Trio.

Архитектура Trio

Trio — это фреймворк для создания фич. Он помогает нам определять и управлять границами и состоянием в Compose UI. Trio также стандартизирует то, как состояние поднимается из Compose UI и как обрабатываются события, обеспечивая однонаправленный поток данных в Mavericks. Дизайн был вдохновлен библиотекой Workflow от Square — Trio отличается тем, что он был разработан специально для Compose и использует ViewModel Mavericks для управления состоянием и событиями.

Автономные блоки называются «Trio», по имени трех основных классов, которые они содержат. Каждый Trio имеет свою собственную ViewModel, State и UI, а также может взаимодействовать с другими Trio и быть вложенным в них. На следующей диаграмме показано, как эти компоненты работают вместе. ViewModel вносит изменения в состояние с помощью редукторов Mavericks, UI получает последнее значение состояния для рендеринга, а события направляются обратно во ViewModel для дальнейшего обновления состояния.

Если вы уже знакомы с Mavericks, этот паттерн должен выглядеть очень похоже! Использование ViewModel и State очень похоже на то, что мы делали с Фрагментами. Новым является то, как мы встраиваем ViewModel в Compose UI и добавляем Роутинг и взаимодействие на основе Props через Trio.

Трио вложены друг в друга для формирования кастомных, гибких навигационных иерархий. «Родительские» Trio создают дочерние Trio с начальными аргументами через Router и хранят их в своем State. Затем родитель может динамически взаимодействовать со своими дочерними элементами через поток реквизитов (Props), которые предоставляют данные, зависимости и функциональные колбеки.

Фреймворк помогает нам гарантировать безопасность типов при навигации и взаимодействии между Trio, особенно при переходе через границы модулей.

Каждый Trio можно протестировать отдельно, инстанцировав его с мокированными аргументами, состоянием и реквизитами. В сочетании с рендерингом на основе состояний Compose и паттернами неизменяемого состояния Maverick это обеспечивает контролируемую и детерминированную среду тестирования.

Класс Trio

Создание новой реализации Trio требует подклассификации базового класса Trio. Класс Trio типизирован для определения Args, Props, State, ViewModel и UI; это позволяет нам гарантировать безопасную для типов навигацию и межэкранное взаимодействие.

class CounterScreen : Trio<
  CounterArgs, 
  CounterProps, 
  CounterState,     
  CounterViewModel, 
  CounterUI
>

Trio создается либо с начальным набором аргументов, либо с начальным состоянием, которые упаковываются в sealed класс под названием Initializer. В проде Initializer будет содержать только Args, переданные из другого экрана, но в разработке мы можем поместить в Initializer имитацию состояния, чтобы экран мог загружаться автономно, независимо от обычной иерархии навигации.

class CounterScreen(
  initializer: Initializer<CounterArgs, CounterState>
)

Затем, в теле нашего подкласса, мы определяем, как мы хотим создать State, ViewModel и UI, учитывая начальные значения Args и Props.

И Args, и Props предоставляют входные данные, с той разницей, что Args статичны, а Props динамичны. Args гарантируют стабильность статической информации, такой как идентификаторы, используемые для запуска экрана, в то время как Props позволяют нам подписываться на данные, которые могут меняться с течением времени.

override fun createInitialState(args: CounterArgs, props:  CounterProps) {
  return CounterState(args.count)
}

Trio предоставляет инициализатор для создания нового экземпляра ViewModel, передавая необходимую информацию, такую как уникальный ID Trio, поток реквизитов и ссылку на родительскую Активити. Зависимости из графа зависимостей приложения также могут быть переданы ViewModel через его конструктор.

override fun createViewModel(
  initializer: Initializer<CounterProps, CounterState>
) {
  return CounterViewModel(initializer)
}

Наконец, класс UI оборачивает composable код, используемый для рендеринга Trio. Класс UI получает поток последних состояний от ViewModel, а также использует ссылку на ViewModel для обращений к ней при обработке событий UI.

override fun createUI(viewModel: CounterViewModel ): CounterUI {
  return CounterUI(viewModel)
}

Нам нравится, что группировка всех этих фабричных функций в классе Trio позволяет наглядно показать, как создается каждый класс, и стандартизировать, куда смотреть, чтобы понять зависимости. Однако это также может показаться избыточным. В качестве улучшения мы часто используем отражение для создания UI-класса, а для автоматизации создания ViewModel с зависимостями Dagger мы используем Assisted Inject.

В результате декларация Trio в целом выглядит следующим образом:

class CounterScreen(
  initializer: Initializer<CounterArgs, CounterState>
) : Trio<
  CounterArgs, 
  CounterProps, 
  CounterState,     
  CounterViewModel, 
  CounterUI
>(initializer) {

  override fun createInitialState(CounterArgs, CounterProps) {
    return CounterState(args.count)
  }
}

UI-класс

UI-класс Trio реализует единственную Composable функцию под названием Content, которая определяет пользовательский интерфейс, отображаемый Trio. Кроме того, функция Content имеет тип приемника TrioRenderScope. Это скоуп Compose-анимации, который позволяет нам настраивать анимацию Trio при его отображении.

class CounterUI(
  override val viewModel: CounterViewModel
) : UI<CounterState, CounterViewModel> {
   
  @Composable
  override fun TrioRenderScope.Content(state: CounterState) {
    Column {
      TopAppBar()
      Button(
        text = state.count,
        modifier = Modifier.clickable {
          viewModel.incrementCount()
        }
      )
      ...
    }
  }
}

Функция Content перекомпонуется каждый раз, когда изменяется состояние ViewModel. UI направляет все события пользовательского интерфейса, такие как тапы, обратно во ViewModel для обработки.

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

Рендеринг Trio

Получив экземпляр Trio, мы можем отрисовать его, вызвав его функцию Content, которая использует ранее упомянутые фабричные функции для создания начальных значений ViewModel, State и UI. Поток состояний собирается из ViewModel и передается в функцию Content пользовательского интерфейса. UI оборачивается в Box, чтобы соблюсти ограничения и модификатор вызывающей стороны.

@Composable
internal fun TrioRenderScope.Content(modifier: Modifier = Modifier) {
  key(trioId) {
    val activity = LocalContext.current as ComponentActivity

    val viewModel = remember {
      getOrCreateViewModel(activity)
    }

    val ui = remember { createUI(viewModel) }

    val state = viewModel.stateFlow
                    .collectAsState(viewModel.currentState).value

    Box(propagateMinConstraints = true, modifier = modifier) {
      ui.Content(state = state)
    }
  }
}

Для настройки анимации входа и выхода функция Content также использует приемник TrioRenderScope; он оборачивает реализацию AnimatedVisibilityScope от Compose, которая отображает содержимое. Для координации этого используется вспомогательная функция.

@Composable
fun ShowTrio(trio: Trio, modifier: Modifier) {
  AnimatedVisibility(
    visible = true,
    enter = EnterTransition.None,
    exit = ExitTransition.None
  ) {
    val animationScope = TrioRenderScopeImpl(this)
    trio.Content(modifier, animationScope)
  }
}

На практике фактическая реализация Trio.Content довольно сложна из-за дополнительных инструментов и крайних случаев, которые мы хотим поддерживать — таких как отслеживание жизненного цикла Trio, управление сохраненным состоянием и мокирование ViewModel при показе в скриншот-тестировании или при предварительном просмотре в IDE.

Заключение

В этом вступлении мы обсудили историю Airbnb с Mavericks и Фрагментами, а также то, почему мы создали Trio для перехода к архитектуре на основе Jetpack Compose. Мы представили обзор архитектуры Trio и рассмотрели основные компоненты, такие как класс Trio и класс UI.

В следующих статьях мы продолжим эту серию, подробно рассказав о том, как работает навигация в Trio, и как Props в Trio обеспечивают динамическое взаимодействие между экранами.

Источник

Exit mobile version