Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Как Airbnb обеспечивает быстрое развертывание функций в браузерах, на iOS и Android с помощью серверной UI-системы Ghost Platform
Введение. Серверный UI
Прежде чем разбираться с реализацией серверного UI (SDUI) от Airbnb, важно понять, что это вообще такое и какие преимущества оно дает относительно традиционного клиентского UI.
Обычно данные обрабатываются серверной частью, а за работу интерфейса отвечает конкретный клиент (веб-браузер, приложения для iOS, Android). В качестве примера возьмем страницу Airbnb со списком предложений. Чтобы отобразить этот список пользователю, клиент может запросить данные у бэкенда, а затем преобразовать их в UI.
И здесь есть несколько трудностей. Во-первых, на каждом клиенте нужно реализовать специфичную для списка логику, обеспечивающую преобразование и отрисовку данных. Если по ходу развития проекта вносить изменения в то, как список отображается, эта логика быстро усложняется и становится негибкой.
Во-вторых, функциональность всех клиентов должна быть единообразной. Логика экрана со списком быстро усложняется, и при этом у каждого клиента есть свои тонкости и особенности реализации для обработки состояния, отображения UI и т. д. Поэтому клиенты по реализации начинают быстро расходиться друг с другом.
Наконец, у мобильных приложений возникают сложности с версиями: при добавлении новых функций на страницу со списком нужно выпускать новую версию приложения, и пока пользователи не обновятся, у нас мало возможностей определить, насколько удобны эти функции и насколько хорошо их приняли.
Применение SDUI
А что, если клиент даже не будет знать, что он отображает список предложений? Можно ли передавать клиенту напрямую UI, а не данные для построения интерфейса? Именно это и происходит в случае SDUI: мы передаем UI и данные вместе, а клиент отображает всё это независимо от того, что конкретно там внутри.
Реализация SDUI в Airbnb позволяет бэкенду контролировать одновременно и отображаемые данные, и способ их представления — причем для всех клиентов сразу. В браузере, приложениях для iOS и Android всё контролируется единственным ответом бэкенда: и макет экрана, и расположение разделов, и отображаемых в них данные, и даже действия, выполняемые при взаимодействии пользователей с разделами.
SDUI в Airbnb: Ghost Platform
Ghost Platform (GP) — это унифицированная гибкая серверная UI-система, которая позволяет быстро проводить итерации разработки и надежным образом запускать новые функции в браузерных клиентах, приложениях iOS и Android. Она называется Ghost, потому что наша основная цель — реализация функций «гость» (guest) и «хозяин» (host), представляющих две стороны приложений Airbnb.
GP предоставляет фреймворки для браузеров, iOS и Android на нативных языках каждого клиента (Typescript, Swift и Kotlin соответственно), которые позволяют разрабатывать управляемые сервером функции с минимальными усилиями.
Главная особенность GP заключается в том, что функции могут использовать общую библиотеку основных разделов, макетов и действий (многие — с обратной совместимостью), что позволяет разработчикам быстрее реализовывать и перемещать сложную бизнес-логику на бэкенд.
Стандартизированная схема
Основа Ghost Platform — это стандартизированная модель данных, на базе которой клиенты отрисовывают UI. Чтобы это было осуществимо, GP использует общий уровень данных между бэкенд-сервисами и унифицированную сетку сервисов данных Viaduct.
Ключевым решением, которое помогло сделать серверную систему UI масштабируемой, стало использование единой совместно используемой схемы GraphQL для браузерного клиента, приложений iOS и Android — иными словами, для обработки ответов и создания строго типизированных моделей данных на всех платформах мы используем одну схему.
Мы продумали, как обобщить совместно используемые аспекты различных функций и как можно единообразно учитывать особенности каждой страницы. В результате получилась универсальная схема, позволяющая отображать все функции Airbnb. В ее рамках можно применять концепцию повторного использования разделов, динамические макеты, подстраницы, действия и многое другое. Соответствующие GP-фреймворки в клиентских приложениях используют эту универсальную схему для стандартизации отрисовки UI.
Ответ GP
Первый фундаментальный аспект GP — это структура ответа в целом. Для описания UI в ответе GP используются две основные концепции: разделы и экраны.
Разделы. Самый примитивный стандартный блок в GP — это раздел. Раздел описывает данные связанной группы компонентов UI, содержащие конкретную информацию для отображения — переведенную, локализованную и отформатированную. Клиенты берут данные раздела и преобразуют их непосредственно в интерфейс.
Экраны. В ответе GP может содержаться произвольное количество экранов. Экран описывает собственный макет, а также расположение разделов из массива разделов — «места размещения». Также экран определяет иные метаданные, например, способ отрисовки разделов (всплывающие, модальные, полноэкранные) и данные журнала.
interface GPResponse {
sections: [SectionContainer]
screens: [ScreenContainer]
# ... Other metadata, logging data or feature-specific logic
}
{"mode":"full","isActive":false}
Рисунок 2. Пример схемы GraphQL для ответа GP
Бэкенд функции на базе GP реализует этот GPResponse
(рисунок 2) и заполняет экраны и разделы в зависимости от варианта использования. Клиентские GP-фреймворки в браузере, на iOS и Android предоставляют разработчику стандартный подход, позволяющий получить реализацию GPResponse
и преобразовать ее в UI с минимальными усилиями.
Разделы
Раздел — это самый базовый стандартный блок в GP. Ключевая особенность разделов GP — их полная независимость от других разделов и экрана, на котором они отображаются.
Отделяя разделы от окружающего контекста, мы можем повторно использовать и видоизменять их, не беспокоясь о тесной привязке бизнес-логики к какой-либо конкретной функции.
Схема разделов
В схеме GraphQL разделы GP представляют собой совокупность всех возможных типов разделов. Каждый тип раздела определяет поля, предоставляемые для отрисовки. Разделы передаются через реализацию GPResponse
с определенными метаданными и предоставляются посредством оболочки SectionContainer
, которая содержит сведения о состоянии раздела, данных журнала и фактической модели данных раздела.
# Example sections
type HeroSection {
# Image urls
images: [String]!
}
type TitleSection {
title: String!,
titleStyle: TextStyle!
# Optional subtitle
subtitle: String
subtitleStyle: TextStyle
# Action to be taken when tapping the optional subtitle
onSubtitleClickAction: IAction
}
enum SectionComponentType {
HERO,
TITLE,
PLUS_TITLE,
# ... There's alot of these :)
}
union Section = HeroSection
| TitleSection
| # ... More section data models
# The wrapper that wraps each section. Responsible for metadata, logging data and SectionComponentType
type SectionContainer {
id: String!
# The key that determines how to render the section data model
sectionComponentType: SectionComponentType
# The data for this specific section
section: Section
# ... Metadata, logging data & more
}
{"mode":"full","isActive":false}
Рисунок 3. Фрагмент схемы GraphQL для раздела
Следует упомянуть еще один важный элемент — SectionComponentType
, который управляет отрисовкой модели данных раздела и позволяет при необходимости изображать одну модель различными способами.
Например, два типа SectionComponentType
— TITLE
и PLUS_TITLE
— могут использовать одну модель данных TitleSection
, но в реализации PLUS_TITLE
для отображения раздела TitleSection
будет использоваться логотип и стиль заголовка для Airbnb Plus. Такой подход обеспечивает универсальность функций на базе GP и возможность множественного использования схемы и данных.
Компоненты раздела
Данные раздела преобразуются в UI посредством так называемых «компонентов раздела». Каждый компонент отвечает за преобразование модели данных и SectionComponentType
в элементы UI. GP для каждой платформы предоставляет абстрактные компоненты разделов на нативных языках (Typescript, Swift, Kotlin), поэтому разработчики могут расширять их и создавать новые разделы.
Компонент раздела сопоставляет модель данных раздела с одной уникальной отрисовкой и поэтому относится только к одному SectionComponentType
. Как говорилось выше, разделы отрисовываются без учета контекста с экрана, на котором они находятся, и размещенных рядом разделов, поэтому в компонентах раздела нет бизнес-логики для конкретных функций.
Возьмем пример для платформы Android (потому что я на ней разрабатываю — а еще потому, что Kotlin классный ). Создадим раздел заголовка, используем фрагмент кода, приведенный ниже (рисунок 5). В реализациях для браузера и iOS построение компонентов раздела делается похожим образом (на Typescript и Swift соответственно).
// This annotation builds a Map<SectionComponentType, SectionComponent> that GP uses to render sections
@SectionComponentType(SectionComponentType.TITLE)
class TitleSectionComponent : SectionComponent<TitleSection>() {
// Developers override this method and build UI from TitleSection corresponding to TITLE
override fun buildSectionUI(section: TitleSection) {
// Text() Turns our title into a styled TextView
Text(
text = section.title,
style = section.titleStyle
)
// Optionally build a subtitle if present in the TitleSection data model
if (!section.subtitle.isNullOrEmpty() {
Text(
text = section.subtitle,
style = section.subtitleStyle
)
}
}
}
{"mode":"full","isActive":false}
Рисунок 5. Пример компонента раздела (Kotlin)
GP предоставляет множество «основных» компонентов раздела (например, TitleSectionComponent
в примере на рисунке 5 выше), для которых можно изменять конфигурацию, стиль и которые обратно совместимы на бэкенде, что позволяет адаптировать их к любому варианту использования функции. При этом для разработки новых функций на GP можно при необходимости добавлять новые компоненты раздела.
Экраны
Экраны — это еще один стандартный блок GP. В отличие от разделов, они в основном обрабатываются клиентскими GP-фреймворками и используются более свободно. Экраны GP отвечают за макет расположения и организацию разделов.
Схема экранов
Экраны передаются как тип ScreenContainer
и могут запускаться в модальном режиме (всплывающее окно), на нижнем слое или в полноэкранном режиме — в зависимости от значений, указанных в поле screenProperties
.
Экраны позволяют динамически настраивать макет экрана и, соответственно, размещать разделы с помощью типа LayoutsPerFormFactor
, который определяет макет для компактных и широких контрольных точек с помощью интерфейса ILayout
(об этом — ниже). Затем GP-фреймворк на клиентах с помощью данных о пиксельной плотности, ориентации и других данных определяет, какие ILayout
из LayoutsPerFormFactor
отрисовывать.
type ScreenContainer {
id: String
# Properties such as how to launch this screen (popup, sheet, etc.)
screenProperties: ScreenProperties
layout: LayoutsPerFormFactor
}
# Specifies the ILayout type depending on rotation, client screen density, etc.
type LayoutsPerFormFactor {
# Compact is usually used for portrait breakpoints (i.e. mobile phones)
compact: ILayout
# Wide is usually used for landscape breakpoints (i.e. web browsers, tablets)
wide: ILayout
}
{"mode":"full","isActive":false}
Рисунок 7. Пример схемы экранов GP
Макеты ILayout
ILayout
позволяет менять макет экрана в зависимости от ответа. В схеме ILayout
это интерфейс, в котором каждый ILayout
представляет собой реализацию с указанием различных мест размещения. Места размещения включают в себя один или несколько типов SectionDetail
, которые указывают на разделы во внешнем массиве sections ответа. Здесь мы указываем на модели данных раздела, а не включаем их в код, что сокращает размер ответа за счет повторного использования разделов в различных конфигурациях макета (LayoutsPerFormFactor
на рисунке 7).
interface ILayout {}
type SectionDetail {
# References a SectionContainer in the GPResponse.sections array
sectionId: String
# Styling data
topPadding: Int
bottomPadding: Int
# ... Other styling data (margins, borders, etc)
}
# A placement meat to display a single GP section
type SingleSectionPlacement {
sectionDetail: SectionDetail!
}
# A placement meat to display multiple GP sections in the order they appear in the sectionDetails array
type MultipleSectionsPlacement {
sectionDetails: [SectionDetail]!
}
# A layout implementation defines the placements that sections are inserted into.
type SingleColumnLayout implements ILayout {
nav: SingleSectionPlacement
main: MultipleSectionsPlacement
floatingFooter: SingleSectionPlacement
}
{"mode":"full","isActive":false}
Рисунок 9. Пример схемы GP для ILayout
Клиентские GP-фреймворки наполняют макеты ILayout, поскольку типы ILayout более гибкие, чем разделы. В GP-фреймворке каждого клиента есть уникальный отрисовщик для каждого ILayout. Отрисовщик берет данные SectionDetail из каждого места размещения, находит соответствующий компонент раздела для отрисовки этого раздела, формирует с его помощью UI раздела, а затем помещает собранный UI в макет.
Действия
И еще одна концепция GP — это инфраструктура обработки действий и событий. Одним из наиболее важных аспектов GP является то, что из сетевого ответа мы можем определять не только разделы и макет экрана, но и действия, выполняемые, когда пользователи взаимодействуют с UI на экране, например, нажимают кнопку или пролистывают карточку. В схеме это делается с помощью интерфейса IAction
.
interface IAction {}
# A simple action that will navigate the user to the screen matching the screenId when invoked
type NavigateToScreen implements IAction {
screenId: String
}
# A sample TitleSection using an IAction type to handle the click of the subtitle
type TitleSection {
...
subtitle: String
# Action to be taken when tapping the subtitle
onSubtitleClickAction: IAction
}
{"mode":"full","isActive":false}
Рисунок 10. Пример схемы IAction в GP
Ранее (см. рисунок 6) мы видели, что компонент раздела на каждом клиенте преобразует TitleSection
в UI. Взглянем на тот же пример TitleSectionComponent
для Android с динамичным IAction
, которое запускается по нажатию на текст подзаголовка.
@SectionComponentType(SectionComponentType.TITLE)
class TitleSectionComponent : SectionComponent<TitleSection>() {
override fun buildSectionUI(section: TitleSection) {
// Build title UI elements
if (!section.subtitle.isNullOrEmpty() {
Text(
...
onClick = {
GPActionHandler.handleIAction(section.onSubtitleClickAction)
}
)
}
}
}
{"mode":"full","isActive":false}
Рисунок 11. Пример компонента раздела с действием IAction, которое запускается при нажатии на подзаголовок
Когда пользователь нажимает на подзаголовок в этом разделе, запускается действие IAction
, переданное для поля onSubtitleClickAction
в TitleSection
. GP отвечает за направление этого действия к определенному для этой функции обработчику событий, который исполнит вызванное IAction
.
Есть стандартный набор обычных действий, которые GP выполняет универсально (например, переход к экрану или прокрутка к разделу). Функции могут добавлять собственные типы IAction
и использовать их для обработки своих уникальных действий. Обработчик событий для конкретной функции ограничен этой функцией, поэтому он может содержать всю необходимую для нее бизнес-логику, что дает свободу использовать настраиваемые действия и бизнес-логику при возникновении конкретных вариантов использования.
Собираем всё вместе
Мы рассмотрели несколько концепций — теперь, чтобы связать всё воедино, взглянем на ответ GP и разберемся, как он отрисовывается.
{
"screens": [
{
"id": "ROOT",
"screenProperties": {},
"layout": {
"wide": {},
"compact": {
"type": "SingleColumnLayout",
"main": {
"type": "MultipleSectionsPlacement",
"sectionDetails": [
{
"sectionId": "hero_section"
},
{
"sectionId": "title_section"
}
]
},
"nav": {
"type": "SingleSectionPlacement",
"sectionDetail": {
"sectionId": "toolbar_section"
}
},
"footer": {
"type": "SingleSectionPlacement",
"sectionDetail": {
"sectionId": "book_bar_footer"
}
}
}
}
},
],
"sections": [
{
"id": "toolbar_section",
"sectionComponentType": "TOOLBAR",
"section": {
"type": "ToolbarSection",
"nav_button": {
"onClickAction": {
"type": "NavigateBack",
"screenId": "previous_screen_id"
}
}
}
},
{
"id": "hero_section",
"sectionComponentType": "HERO",
"section": {
"type": "HeroSection",
"images": [
"api.airbnb.com/...",
],
}
},
{
"id": "title_section",
"sectionComponentType": "TITLE",
"section": {
"type": "TitleSection",
"title": "Seamist Beach Cottage, Private Beach & Ocean Views",
"titleStyle": {}
}
},
{
"id": "book_bar_footer",
"sectionComponentType": "BOOK_BAR_FOOTER",
"section": {
"type": "ButtonSection",
"title": "$450/night",
"button": {
"text": "Check Availability",
"onClickAction": {
"type": "NavigateToScreen",
"screenId": "next_screen_id"
}
},
}
},
]
}
{"mode":"full","isActive":false}
Рисунок 12. Пример действительного ответа GP в формате JSON
Создание компонентов раздела
Функции, использующие GP, должны получить ответ, реализующий GPResponse
(см. рисунок 2 выше). Получив GPResponse
, инфраструктура GP анализирует этот ответ и формирует разделы.
Напомню, что каждый раздел в массиве sections
включает в себя тип SectionComponentType
и модель данных раздела. Разработчик добавляет компоненты section, используя SectionComponentType
как ключ к отрисовке модели данных раздела.
GP находит все компоненты раздела и передает им соответствующую модель данных. Каждый компонент раздела создает компоненты UI для раздела, после чего GP вставляет их в нужное место размещения в макете (см. ниже).
Обработка действий
Элементы пользовательского интерфейса для каждого компонента раздела настроены — теперь нужно обработать взаимодействие пользователей с этими разделами. Например, если пользователь нажимает кнопку, нужно обрабатывать действие, выполняемое при нажатии.
Напомню, что GP направляет действия к соответствующим обработчикам. В примере ответа выше (рисунок 12) есть два раздела, которые могут запускать действия: toolbar_section
и book_bar_footer
. Компонент раздела для создания этих разделов должен всего лишь принять действие IAction
указать, когда его запускать (в обоих случаях — при нажатии кнопки).
Сделать это можно с помощью обработчиков нажатий на каждом клиенте, которые будут использовать инфраструктуру GP для направления события по нажатию.
button(
onClickListener = {
GPActionHandler.handleIAction(section.button.onClickAction)
}
)
Подготовка экрана и макета
Чтобы сформировать для пользователя интерактивный экран, GP ищет в массиве экранов элемент с идентификатором ROOT
(экран по умолчанию для GP). Затем GP ищет подходящий тип ILayout
в зависимости от контрольной точки и других данных, относящихся к конкретному устройству пользователя. Для простоты используем макет из поля compact
— SingleColumnLayout.
Затем GP ищет отрисовщик макета для SingleColumnLayout
, где он наполняет макет верхним контейнером (место размещения nav
), прокручиваемым списком (main
) и плавающей нижней панелью (footer
).
Этот отрисовщик макета берет модели для мест размещения, содержащие объекты SectionDetail
. В каждом SectionDetail
есть сведения о стиле, а также sectionId
наполняемого раздела. GP проходит по объектам SectionDetail
и с помощью созданных ранее компонентов раздела наполняет разделы, формируя соответствующие места размещения.
Развитие GP
GP существует всего около года, но уже сейчас большинство самых часто используемых функций Airbnb (поиск, страницы со списками, бронирование) построены на базе этой системы. Несмотря на такое широкое применение, GP всё еще на начальных этапах развития — впереди многое предстоит сделать.
Мы планируем расширить возможность компоновки UI за счет «вложенных разделов», повысить удобство поиска имеющихся элементов в инструментах для дизайна, таких как Figma, а также реализовать WYSIWYG-редактирования разделов и мест размещения, что позволит изменять функции без написания кода.
Если вам интересен серверный UI или создание масштабируемых UI-систем — работа для вас найдется. Смело подавайте резюме на вакансии в нашей команде разработчиков.
Конференция Re-engineering Travel
Серверный UI — это сложная тема. На создание надежной схемы, клиентских фреймворков и документации для разработчиков ушло огромное количество время, но только так можно обеспечить успешное применение GP.
Если вам интересен более высокоуровневый обзор SDUI и GP — можете послушать мое недавнее выступление в рамках проводимого компанией Airbnb мероприятия Re-engineering Travel, где я представлял систему GP. Там я даю общий обзор серверного пользовательского интерфейса и GP (если хотите покороче — слушайте с 31-й минуты).
Благодарности
Хочу поблагодарить за неутомимую работу над созданием и поддержкой GP следующих людей: Abhi Vohra, Wensheng Mao, Jean-Nicolas Vollmer, Pranay Airan, Stephen Herring, Jasper Liu, Kevin Weber, Rodolphe Courtier, Daniel Garcia-Carrillo, Fidel Sosa, Roshan Goli, Cal Stephens, Chen Wu, Nick Miller, Yanlin Chen, Susan Dang, Amity Wang, а также многих, кто остался за кулисами.
О переводчике
Перевод статьи выполнен в Alconost.
Alconost занимается локализацией игр, приложений и сайтов на 70 языков. Переводчики-носители языка, лингвистическое тестирование, облачная платформа с API, непрерывная локализация, менеджеры проектов 24/7, любые форматы строковых ресурсов.
Мы также делаем рекламные и обучающие видеоролики — для сайтов, продающие, имиджевые, рекламные, обучающие, тизеры, эксплейнеры, трейлеры для Google Play и App Store.