Разработка
Введение в Trio: часть 3
В третьей, заключительной части нашей серии мы расскажем о том, как Props в Trio позволяют упростить безопасное для типов взаимодействие между ViewModel. Мы также расскажем о текущем внедрении Trio в Airbnb и о том, что будет дальше.
Trio — это фреймворк Airbnb для экранной архитектуры Jetpack Compose в Android. Он построен на базе Mavericks, библиотеки управления состояниями Jetpack от Airbnb с открытым исходным кодом. В этой серии статей мы разбираем принцип работы Trio, чтобы объяснить наши дизайнерские решения, в надежде, что другие команды смогут воспользоваться аспектами нашего подхода.
Мы рекомендуем начать с первой части, посвященной архитектуре Trio, а затем прочитать вторую, рассказывающую о том, как работает навигация в Trio, прежде чем погружаться в эту статью. В третьей, заключительной части нашей серии мы расскажем о том, как Props в Trio позволяют упростить безопасное для типов взаимодействие между ViewModel. Мы также расскажем о текущем внедрении Trio в Airbnb и о том, что будет дальше.
Props в Trio
Чтобы лучше понять Props, давайте рассмотрим пример простого экрана «Входящие сообщения», состоящего из двух Trio, расположенных рядом друг с другом. Слева находится трио List («Список»), показывающее входящие сообщения, а справа — трио Details («Подробности»), показывающее полный текст выбранного сообщения.
Оба трио обернуты родительским экраном, который отвечает за инстанцирование двух дочерних элементов, передачу им данных и их размещение в пользовательском интерфейсе. Как вы помните из второй части, Trio могут храниться в State; родительский State включает в себя как данные сообщений, так и дочерние трио.
data class ParentState( val inboxMessages: List<Message>, val selectedMessage: Message?, val messageListScreen: Trio<ListProps>, val messageDetailScreen: Trio<DetailsProps>, } : MavericksState
Родительский UI решает, как отображать дочерние элементы, к которым он обращается из State. С помощью Compose UI легко применить кастомную логику компоновки: мы показываем экраны рядом, когда устройство находится в ландшафтном режиме, а в портретном — только один экран, в зависимости от того, было ли выбрано сообщение.
@Composable override fun TrioRenderScope.Content(state: ParentState) { if (LocalConfiguration.current.orientation == ORIENTATION_LANDSCAPE) { Row(Modifier.fillMaxSize()) { ShowTrio(state.listScreen, modifier = Modifier.weight(1f)) ShowTrio(state.detailScreen) } } else { if (state.selectedMessage == null) { ShowTrio(state.listScreen) } else { BackHandler { viewModel.clearMessageSelection() } ShowTrio(state.detailScreen) } } }
Оба дочерних экрана должны иметь доступ к последнему состоянию сообщения, чтобы знать, какой контент показывать. Мы можем обеспечить это с помощью Props!
Props (Реквизиты) — это коллекция свойств Kotlin, хранящихся в классе данных и передаваемых Trio его родителем.
В отличие от Arguments, Props могут изменяться со временем, позволяя родителю предоставлять обновленные данные по мере необходимости в течение всего времени существования Trio. Реквизиты могут включать лямбда-выражения, позволяющие экрану обмениваться данными со своим родителем.
Дочерний Trio может быть показан только в родителе, поддерживающем его тип Props. Это обеспечивает корректность навигации и взаимодействия между Trio во время компиляции.
Определение Props
Давайте посмотрим, как используются Props для передачи данных сообщений от родительского трио к трио List и Details. Когда родитель определяет дочерние трио в своем State, он должен указать тип реквизита, который требуется этим дочерним трио. В нашем примере экраны List и Details имеют свои собственные уникальные Props.
Экран List должен знать список всех сообщений и то, выбрано ли одно из них. Он также должен уметь обращаться к родительскому экрану, чтобы сообщить ему о выборе нового сообщения.
data class ListProps( val selectedMessage: Message?, val inboxMessages: List<Message>, val onMessageSelected: (Message) -> Unit, )
Экран Details просто должен знать, какое сообщение отобразить.
data class DetailProps( val selectedMessage: Message? )
Родительская ViewModel хранит дочерние экземпляры в своем State и отвечает за передачу дочерним экземплярам последнего значения Props.
Передача Props
Итак, как родительское Trio передает Props своему дочернему? В своем блоке init оно должно использовать функцию launchChildInitializer
— эта функция использует лямбду для выбора экземпляра Trio из State, указывая, какой Trio является целевым.
class ParentViewModel: TrioViewModel { init { launchChildInitializer({ messageListScreen }) { state -> ListProps( state.selectedMessage, state.inboxMessages, ::showMessageDetails ) } launchChildInitializer({ detailScreen }) { state -> DetailProps(state.selectedMessage) } } fun showMessageDetails(message: Message?) ... }
Второй аргумент лямбды принимает значение State и возвращает новый экземпляр Props для передачи дочернему объекту. Эта функция управляет жизненным циклом дочернего объекта, инициализируя его потоком Props при первом создании и уничтожая его, если он когда-либо будет удален из State родителя.
Лямбда для ребилда Props вызывается заново каждый раз, когда состояние родителя меняется, и любое новое значение Props передается ребенку через его поток.
Общим шаблоном, который мы используем, является включение в Props ссылок на функции, которые указывают на функции родительской ViewModel. Это позволяет дочерней модели обращаться к родительской для обработки событий. В примере выше мы делаем это с помощью функции showMessageDetails. Props также можно использовать для передачи сложных зависимостей, которые формируют граф зависимостей, привязанный к родителю.
Обратите внимание, что мы не можем передавать Props в Trio при его создании, как это делается с Args. Это связано с тем, что Trio должен быть восстановлен после смерти процесса, поэтому класс Trio, а также Args, использованные для его создания, являются Parcelable. Поскольку Props могут содержать лямбды и другие произвольные объекты, которые не могут быть безопасно сериализованы, мы должны использовать описанный выше паттерн для создания потока реквизитов от родителя к ребенку, который может быть восстановлен даже после воссоздания процесса. Навигация и межэкранное взаимодействие были бы намного проще, если бы нам не приходилось заниматься восстановлением процессов!
Использование Props
Чтобы дочерний Trio мог использовать данные Props в своем пользовательском интерфейсе, их сначала нужно скопировать в State.
Чтобы указать, как включать значения Prop в State, дочерние ViewModels переопределяют функцию updateStateFromPropsChange
. Функция вызывается каждый раз, когда значение Props меняется, и новое значение State обновляется в ViewModel. Так дочерние объекты получают актуальные данные от родительского объекта.
class ListViewModel : TrioViewModel<ListProps, ListState> { override fun updateStateFromPropsChange( newProps: ListProps, thisState: ListState ): ListState { return thisState.copy( inboxMessages = newProps.inboxMessages, selectedMessage = newProps.selectedMessage ) } fun onMessageSelected(message: Message) { props.onMessageSelected(message) } }
Для значений, не относящихся к состоянию Props, таких как зависимости или обратные вызовы, ViewModel может получить доступ к последнему значению Props в любое время через свойство props
. Например, мы делаем это в функции onMessageSelected
в примере кода выше. Пользовательский интерфейс List UI будет вызывать эту функцию при выборе сообщения, и это событие будет передано родителю через Props.
При реализации Props возникло много сложностей — например, при обработке краевых случаев жизненного цикла Trio и восстановлении состояния после смерти процесса. Однако внутреннее устройство Trio скрывает большую часть сложностей от конечного пользователя. В целом, наличие согласованной, кодифицированной системы с безопасностью типов для взаимодействия экранов Compose помогло повысить стандартизацию и производительность нашей команды Android-инженеров .
Стандартизация экранного потока с Props
Один из самых распространенных паттернов пользовательского интерфейса в Airbnb — это координация стека экранов. Эти экраны могут иметь общие данные и следовать схожим навигационным шаблонам, таким как выдвижение, задвижение и удаление всех экранов бэкстека в тандеме.
Ранее мы показали, как Trio может управлять списком дочерних элементов в своем State для достижения этой цели, но делать это вручную довольно утомительно. Чтобы помочь, Trio предоставляет стандартную реализацию «экранного потока», который состоит из родительского Trio ScreenFlow
и связанных с ним дочерних Trio экранов. Родительский ScreenFlow
автоматически управляет дочерними транзакциями и отображает верхний дочерний экран в своем пользовательском интерфейсе. Он также передает дочерним экранам кастомный класс Props, предоставляя доступ к общему состоянию и функциям навигации.
Рассмотрим создание приложения Todo, в котором есть экран TodoList, экран TaskScreen и экран EditTaskScreen. Все эти экраны могут совместно использовать один сетевой запрос, который возвращает модель TodoList. В терминах Trio модель данных TodoList может быть реквизитом для этих трех экранов.
Для управления этими экранами мы используем инфраструктуру ScreenFlow, чтобы создать Trio TodoScreenFlow. Его состояние расширяет ScreenFlowState
и переопределяет свойство childScreenTransaction
для хранения транзакций. В этом примере состояние потока было инициализировано для начала с экрана TodoListScreen, поэтому он будет отображаться первым. Объект State потока также выступает в качестве источника истины для других общих состояний, таких как модель данных TodoList.
data class TodoFlowState( @PersistState override val childScreenTransactions: List<ScreenTransaction<TodoFlowProps>> = listOf( ScreenTransaction(Router.TodoListScreen.createFullPaneTrio(NoArgs)) ), // shared state val todoListQuery: TodoList?, ) : ScreenFlowState<TodoFlowState, TodoFlowProps>
Это состояние является приватным для TodoScreenFlow. Однако поток определяет реквизиты для совместного использования модели данных TodoList, обратных вызовов, таких как лямбда reloadList, и NavController
со своими дочерними элементами.
data class TodoFlowProps( val navController: NavController<TodoFlowProps>, val todoListQuery: TodoList?, val reloadList: () -> Unit, )
Свойство NavController
может использоваться дочерними экранами для пуша другого экрана-брата. Базовый класс ScreenFlowViewModel
реализует этот интерфейс NavController
, управляя сложностью интеграции навигационных действий в состояние потока экранов.
interface NavController<PropsT>( fun push(router: TrioRouter<*, in PropsT>) fun pop() )
Наконец, навигация и общее состояние подключаются к потоку Props, когда TodoScreenFlowViewModel
переопределяет createFlowProps
. Эта функция будет вызываться при любом изменении внутреннего состояния TodoScreenFlowViewModel
, то есть любое обновление модели TodoList будет распространяться на дочерние экраны.
class TodoScreenFlowViewModel( initializer: Initializer<NavPopProps, TodoFlowState> ) : ScreenFlowViewModel<NavPopProps, TodoFlowProps, TodoFlowState>(initializer) { override fun createFlowProps( state: TodoFlowState, props: NavPopProps ): TodoFlowProps { return TodoFlowProps( navController = this, state.todoListQuery, ::reloadList, ) } }
Внутри одной из ViewModel дочернего экрана мы видим, что она будет получать общие Props:
class TodoListViewModel( initializer: Initializer<TodoFlowProps, TodoListState> ) : TrioViewModel<TodoFlowProps, TodoListState>(initializer) { override fun updateStateFromPropsChange( newProps: TodoFlowProps, thisState: TodoTaskState ): TodoTaskState { // Incorporate the shared data model into this Trio’s private state passed to its UI: return thisState.copy(todoListQuery = newProps.todoListQuery) } fun navigateToTodoTask(task: TodoTask) { this.props.navController.push(Router.TodoTaskScreen, TodoTaskArgs(task.id)) } }
В navigateToTodoTask
, NavController
, подготовленный родителем потока, используется для безопасной навигации к следующему экрану в потоке (гарантируя, что он получит общие TodoFlowProps). Внутри NavController
обновляет дочерние ScreenFlow childScreenTransactions
, вызывая ScreenFlow для предоставления общих TodoFlowProps новому экрану и рендеринга нового экрана.
Успех Trio в Airbnb
История разработки и запуск
Мы начали разрабатывать Trio в конце 2021 года, а первые экраны Trio появились в середине 2022 года.
По состоянию на март 2024 года у нас в Airbnb есть более 230 экранов Trio с существенным трафиком.
Опрашивая наших разработчиков, мы узнали, что многим из них нравится общий опыт работы с Trio; им нравится иметь ясные и четкие шаблоны, и они счастливы находиться в чистой среде Compose. Как сказал один из разработчиков, «Props стал огромным плюсом, позволив нескольким экранам совместно использовать обратные вызовы, что значительно упростило логику моего кода». Другой сказал: «Trio заставляет отучиться от плохих привычек и перенять лучшие практики, которые работают в Airbnb». В целом, наша команда отмечает более быстрые циклы разработки и более чистый код. «Это делает разработку Android быстрее и приятнее», — так резюмировал один из инженеров.
Инструментарий для разработчиков
Чтобы поддержать наших инженеров, мы инвестировали в инструментарий IDE с собственным плагином Android Studio. Он включает в себя инструмент Trio Generation, который создает все файлы и шаблоны для нового Trio, включая маршрутизацию, модели и тесты.
Инструмент помогает пользователю выбрать, какие аргументы и реквизиты использовать, и помогает с другими настройками, такими как настройка пользовательских потоков. Он также позволяет нам встраивать образовательный опыт, чтобы помочь новичкам освоить Trio.
Один из отзывов, который мы услышали от инженеров, гласил, что изменять типы Args и Props в Trio очень утомительно, поскольку они используются во многих разных файлах.
Мы использовали наш плагин для IDE, чтобы предоставить инструмент для автоматического изменения этих значений, что значительно ускорило этот рабочий процесс.
Наша команда очень сильно полагается на подобные инструменты, и мы убедились, что они очень эффективны для улучшения работы инженеров в Airbnb. Мы использовали Compose Multiplatform для разработки пользовательского интерфейса наших плагинов, что, по нашему мнению, сделало создание мощных инструментов для разработчиков более быстрым и приятным.
Заключение
В целом, более 230 наших производственных экранов реализованы в виде Trio, и органичное внедрение Trio в Airbnb доказало, что многие наши ставки и дизайнерские решения стоили того, чтобы идти на компромиссы.
Одно из изменений, которое мы ожидаем, — это включение общих переходов элементов между экранами, как только фреймворк Compose предоставит API для поддержки этой функциональности. Когда API Compose станут доступны для этого, нам, вероятно, придется соответствующим образом переработать наши навигационные API.
Спасибо, что следите за нашей работой в Airbnb. Наша команда Android Platform работает над множеством сложных и интересных проектов, таких как Trio, и нам не терпится рассказать о них в будущем.
Если подобная работа кажется вам привлекательной, ознакомьтесь с нашими вакансиями — мы набираем сотрудников!