В Airbnb мы разработали Android-фреймворк для экранной архитектуры Jetpack Compose, который мы назвали Trio. Trio построен на нашей библиотеке с открытым исходным кодом Mavericks, которая используется для поддержания навигации и состояния приложения внутри ViewModel.
Компания Airbnb начала разработку Trio более двух лет назад и использует его в продакшене уже более полутора лет. На нем построена значительная часть работающих экранов в Android-приложении Airbnb, и он позволил нашим инженерам создавать 100% функций в Compose UI.
В этой серии статей мы рассмотрим, как Mavericks можно использовать в современных приложениях на базе Compose. Мы обсудим проблемы архитектуры на базе Compose и то, как Trio пытался их решить. В частности, мы рассмотрим такие концепции, как:
- безопасная для типов навигация между функциональными модулями
- хранение состояния навигации в ViewModel
- коммуникация между экранами на основе 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.
- Область действия — Совместное использование ViewModel несколькими Фрагментами основано на неявной инъекции ViewModel. Таким образом, неясно, какой фрагмент отвечает за первоначальное создание модели Активити ViewModel или за предоставление ей начальных аргументов.
- Коммуникация — сложно обмениваться данными между Фрагментами напрямую и с обеспечением безопасности типов. Опять же, поскольку ViewModel инжектируются, трудно обеспечить их прямое взаимодействие, и мы не можем контролировать порядок их создания.
- Навигация — Навигация осуществляется через менеджер фрагментов и должна происходить во фрагменте. Однако изменение состояния происходит в ViewModel. Это приводит к проблемам синхронизации между состояниями ViewModel и навигации. Сложно скоординировать сценарии типа «если — то», например, вызов навигации только после обновления значения состояния во ViewModel.
- Тестируемость — сложно изолировать пользовательский интерфейс для тестирования, поскольку он обернут во Фрагмент. Скриншот-тесты подвержены расслоению, а для мокирования состояния ViewModel требуется много непрямых действий, поскольку ViewModel внедряются во Фрагмент с помощью делегатов свойств.
- Реактивность — 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 обеспечивают динамическое взаимодействие между экранами.