BDUI в Яндекс Еде: как мы научились менять интерфейс без обновлений приложения

Представьте, что вы можете полностью изменить интерфейс мобильного приложения, не дожидаясь апдейта в App Store или Google Play. Новые кнопки, секции, экспериментальные UI — всё обновляется мгновенно, прямо с бэкенда. Это не магия и не футуристическая технология будущего, а реальность, которую мы внедрили в Яндекс Еде с помощью Backend-Driven UI.

Этот подход кардинально изменил процесс разработки мобильных интерфейсов, позволив нам быстрее внедрять новые фичи и гибко адаптироваться под потребности пользователей. Меня зовут Никита Шумский, я мобильный разработчик в Яндекс Еде. Сегодня я расскажу вам, как мы используем BDUI в наших мобильных приложениях, какие преимущества этот подход даёт нам в работе и с какими сложностями приходится сталкиваться.

А так ли вообще нужен BDUI?

Может, нативных подходов вполне достаточно? Ведь классическая мобильная разработка проверена временем, даёт полный контроль над UI и обеспечивает нативный пользовательский опыт. WebView, в свою очередь, позволяет обновлять интерфейс без публикации нового билда. Тогда зачем изобретать велосипед? Ответ прост: нативная разработка в больших продуктах слишком медленная, а WebView слишком ограничен. Но, давайте разбираться по порядку.

01.png

1. Дублирование кода и ресурсы на поддержку

Сегодня основная часть мобильной разработки сосредоточена вокруг двух основных платформ — Android и iOS. Даже если бизнес-логика одинакова, UI всё равно приходится делать дважды, учитывая особенности каждого стека. Это приводит к дополнительным расходам и времени, и денег. При этом время от времени появляются новые операционные системы — например, китайская HarmonyOS и российская «Аврора». В будущем их доля может вырасти, и поддерживать приложения может стать ещё сложнее.

А если в продукте много A/B-тестов, маркетинговых акций и динамически изменяемых экранов, обновлять их через классическую разработку становится совсем сложно.

2. Ограничения WebView

Некоторые компании пытаются обойти проблему дублирования кода через WebView, загружая UI как веб-страницу внутри приложения. Это позволяет обновлять интерфейс в реальном времени, но создаёт ряд новых проблем:

  • Производительность хуже, чем у нативного кода. Анимации, плавность скролла, время отклика — всё это страдает.
  • Ограничен доступ к нативным API. Работа с жестами, камерами, push-уведомлениями и другими возможностями устройства становится сложнее.
  • UX ощущается не таким плавным. Даже самые проработанные WebView-интерфейсы ощущаются не так плавно, как нативные.

Мы тоже пытались экспериментировать с WebView в Яндекс Еде. Подробнее об этом вы можете посмотреть в Константина Ларгина, руководителя Android-разработки Яндекс Еды. Но в итоге поняли, что пользователи заказывают меньше, если критически важные экраны рендерились через WebView.

3. Медленные релизы и долгий цикл обновлений

После каждого изменения UI в классической мобильной разработке нужно выпускать новую версию приложения. А это значит, что мы снова и снова повторяем весь цикл:

  1. Кодим и тестируем.
  2. Делаем код-ревью и правим баги.
  3. Отправляем приложение в App Store и Google Play.
  4. Ждём одобрения (от нескольких часов до нескольких дней).
  5. Постепенно раскатываем и мониторим метрики.

Из-за этого даже небольшие правки — типа подвинуть кнопку на пару пикселей вправо или изменить порядок блоков на главном экране — могут доходить до пользователей неделями.

В итоге получается, что классическая нативная разработка — как автомобиль на механике — даёт полный контроль, но требует больше времени и ресурсов. WebView решает проблему лишь отчасти и при этом ещё и ухудшает UX. Мы долго искали подход, который смог бы нам дать лучшее из обоих миров. И, посмотрев на , решили попробовать внедрить у себя BDUI.

Архитектура BDUI в Яндекс Еде

Как вы уже поняли, BDUI — это не просто ещё один инструмент или набор библиотек, а новый подход к разработке мобильных интерфейсов, позволяющий полностью формировать логику UI на бэкенде со всеми мапперами, форматерами и вёрсткой. На первый взгляд звучит здорово. Но давайте рассмотрим подробнее, как это всё работает на примере нашего продукта.

02.png

1. Layout-конфигуратор (CMS)

Прежде чем данные попадут на клиент, их нужно правильно структурировать. За это отвечает Layout-конфигуратор — наша внутренняя CMS-система, в которой конфигурируется UI.

По сути это то место, где мы собираем вместе все компоненты и определяем, как они должны выглядеть на экране. Тут мы можем задавать шаблоны страниц, добавлять виджеты и управлять их настройками, запускать A/B-тесты и менять контент почти в реальном времени.

Например, если нужно добавить новый рекламный баннер на главную страницу, мы просто добавляем новый виджет в Layout-конфигураторе. Никаких изменений в коде приложения, никаких обновлений в сторах — баннер появится у пользователей сразу после сохранения изменений в CMS.

Layout-конфигуратор использует YAML-файлы для настройки UI, в которых указываются:

  • Виджеты и их параметры.
  • Источники данных для каждого элемента.
  • Логика отображения разных версий интерфейса в зависимости от условий.

Кроме того там есть виджеты, которые можно полностью настраивать через графический интерфейс, без необходимости писать код. Эти виджеты могут сами получать данные из указанных источников и передавать их в payload.

Layout-конструктор это сервис, который получает из Layout-конфигуратора список виджетов и далее делает необходимые запросы в различные сервисы, чтобы собирать все необходимые данные. Он же определяет достаточность данных для отрисовки виджета, занимается фолбеками, обработкой ошибок.

2. Мобильный бэкенд

Это главный элемент всей архитектуры BDUI. Его задача — собрать всю нужную информацию, превратить ее в UI-разметку и отправить на клиент.

Наш мобильный бэкенд написан на Kotlin + Spring. Он общается с внешними сервисами через Retrofit и использует DivKit — собственную библиотеку Яндекса для описания UI. Это бесплатная библиотека, которую может использовать любой желающий.

Для обработки запросов и формирования страниц мобильный бэкенд использует «Товарищ SDK» — нашу внутреннюю библиотеку. Она организует обработку данных в виде пайплайна из нескольких этапов:

  1. Drafting — создаёт черновик страницы, определяя, какие секции и элементы экрана должны быть показаны.
  2. Fetching — делает запросы в другие сервисы для получения необходимых данных.
  3. Rendering — компилирует JSON в формате DivKit вёрстки с полной разметкой экрана, который затем передаётся на мобильные устройства.

Как это работает на практике? Представим, что пользователь открывает в мобильном приложении экран списка магазинов. Запрос приходит на мобильный бэкенд. Мобильный бэкенд обращается к Layout-конструктору. Layout-конструктор забирает список виджетов из Layout-конфигуратора и далее делает запросы в несколько сервисов: одному за списком магазинов, другому за пользовательскими рекомендациями, третьему за промоакциями. Полученные данные из Layout-конструктора компонуются на мобильном бекенде в JSON-структуру, где с помощью DivKit прописаны элементы интерфейса: карточки, списки, заголовки, кнопки. Далее этот JSON отправляется обратно в мобильное приложение.

3. DivKit: рендеринг UI на клиенте

DivKit — это ключевая библиотека, отвечающая за рендеринг UI. Она принимает JSON-описание экранов, полученное от мобильного бэкенда, и превращает его в нативные UI-компоненты.

Работает это так: DivKit получает JSON-ответ от мобильного бэкенда, разбирает его и создаёт на его основе иерархию UI-компонентов. В отличие от WebView, тут нет рендеринга HTML — библиотека использует нативные View-компоненты Android и iOS. Это значит, что интерфейс, созданный через BDUI, работает с той же производительностью, что и обычный нативный UI, но при этом его можно менять мгновенно.

DivKit позволяет описывать интерфейсы в декларативном формате. Через JSON можно управлять анимациями, адаптивными параметрами и динамически менять компоненты. Кроме того, в JSON задаётся навигация, запуск камеры или карты, отправка аналитики и другие нативные действия. Это снижает нагрузку на мобильных разработчиков и упрощает работу с UI.

Роль Flex SDK в процессе рендеринга

«Flex SDK» — это библиотека, работающая в связке с DivKit и отвечающая за подготовку данных перед их рендерингом. Её основная задача — разбор JSON-ответов мобильного бэкенда и их трансформация в удобный формат для работы с DivKit.

С помощью «Flex SDK» мы обеспечиваем гибкость при работе с DivKit и можем вносить тонкие настройки в поведение компонентов, избегая излишней нагрузки на мобильный бэкенд. Благодаря такой связке нам удаётся добиться плавного и предсказуемого отображения UI даже при сложных сценариях.

Рендер UI на мобильном бэкенде шаг за шагом

На уровне архитектуры мобильного бекенда весь процесс рендеринга можно представить следующим образом:

1. Запрос от мобильного клиента

Мобильное приложение формирует запрос к мобильному бэкенду. Этот запрос может быть инициирован различными событиями: открытие экрана, действие пользователя, получение push-уведомления и т. д.

«Flex SDK» упаковывает запрос в определённый формат, который будет понятен «Товарищ SDK» на стороне бэкенда. Этот формат, по сути — стандартизированное API между клиентской и серверной частями BDUI.

Запрос содержит информацию, необходимую для идентификации нужного экрана и его состояния:

  • Тип экрана (например, «главный экран», «экран ресторана», «корзина»).
  • Идентификатор пользователя (если требуется).
  • Параметры экрана (например, ID ресторана, поисковый запрос).
  • Состояние экрана (например, прокрутка, введённый текст в поле поиска).
  • Хедеры с информацией о версии приложения и платформе.

Пример body запроса экрана коллекций:

{
 "request": {
   "view": {
     "slug": "shops_department",
     "type": "collection"
   }
 },
 "payload": {
   "state": {
     ...
   }
 }
}

2. Мобильный бэкенд: входная точка

Мобильный бэкенд получает запрос и обрабатывает его с помощью «Товарищ SDK». Как уже упоминалось, «Товарищ SDK» организует обработку в виде pipeline, состоящего из интерцепторов.

Контроллеры экранов генерируем с помощью OpenAPI. Они имплементируют интерфейс ApiDelegate, а также:

  • Обрабатывают запросы, пришедшие от Flex SDK.
  • Извлекают из запроса всю необходимую информацию, такую как тип экрана, параметры, состояние и т. д.
  • Запускают запрос на формирование документа в pipeline.
@Component
open class CollectionApiService(
    private val pipelineExecutor: PipelineExecutor,
) : CollectionApiDelegate {

    override fun eatsV1BduiMobileV1CollectionPost(
        collectionRequest: CollectionRequest
    ): ResponseEntity<Document> {
        val document = pipelineExecutor.execute(collectionRequest)
        return ResponseEntity(document, HttpStatus.OK)
    }
}

3. Мобильный бэкенд: Drafting

Первым в pipeline срабатывает Draftsman, так называемый DraftingInterceptor. Он:

  • Создаёт «черновик» документа, который в итоге будет содержать полное описание UI. Этот черновик — ещё не JSON, а внутренняя структура данных «Товарищ SDK».
  • Указывает, какие секции будут в документе.
  • Описывает события ЖЦ контента и документа.
  • Задаёт элементы отображения скафолда экрана (toolbar, navigation bar, background color).
  • Настраивает отображение экрана и анимации.
  • Передаёт черновик дальше по pipeline.
@Service
      @BindTo(CollectionRequest::class)
class CollectionDraftsmen: TypedDraftsman<CollectionRequest, DraftingContext>() {

    override suspend fun provideDraft(
        context: DraftingContext,
        request: DocumentRequest<CollectionRequest>
    ): DocumentDraft {
        return DocumentDraft(
            content = SectionContentDraft(
                sections = listOf(
			    SearchBarSectionDraft(),
                    RteUpsellSectionDraft(legacyId = request.legacyId),
                    ...
                ),
                settings = SectionContent.Settings(
                    refreshable = true,
                    scrollable = true,
                    enableSnippetsAnimations = false,
                ),
                lifecycle = CollectionLifecycleDraft(...),
            ),
            lifecycle = CollectionDocumentLifecycleDraft(...),
            scaffold = CollectionScaffoldDraft(...),
            transitionSettings = TransitionSettings(
                onShow = TransitionSettings.Options(...),
            )
        )
    }
}

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

class SearchBarSectionDraft() : SectionDraft

data class RteUpsellSectionDraft(
   val legacyId: Long
) : SectionDraft

4. Мобильный бэкенд: Fetching

Для работы с черновиками секций используется SectionPlanner. Он получает черновики секций, подготавливает для них Query запросы в вышестоящие сервисы и планирует очерёдность их исполнения. Выполняет преобразование сырых данных от сервисов в простые модельки RenderData, содержащие минимально необходимые структуры данных для их отображения. В андроид такие называют ViewObject, а в iOS — ViewModel.

@Service
@BindTo(RteUpsellSectionDraft::class)
class RteUpsellSectionPlanner : SectionPlanner<RteUpsellSectionDraft, PlanningContext>() {
    override fun plan(
        draft: RteUpsellSectionDraft,
        context: PlanningContext,
    ): Plan<RteUpsellWidgetRenderData> {
        return Plan
            .lets(RteUpsellQuery(draft.legacyId))
            .done { response ->
                RteUpsellWidgetRendererData(
                    imageUrl = response.imageUrl,
                    ...
                )
            }
    }
}

Для запроса данных для секций используются классы, имплементирующие интерфейс Query.

data class RteUpsellQuery(
    val legacyId: Long?,
) : Query<RteUpsellResponse>

Для работы с Query используется специальный интерсептор Fetcher, это так называемый FetchingInterceptor. Он выполняет запросы к другим сервисам (продуктовым микросервисам или Layout-конструктору) для получения этих данных.

@Service
@BindTo(RteUpsellQuery::class)
class RteUpsellFetcher(
    private val client: EatsUpsellApiClient,
) : Fetcher<RteUpsellQuery, RteUpsellResponse>() {
    override suspend fun fetchQuery(query: RteUpsellQuery): () -> Response<RteUpsellResponse> {
        val request = client
            .eatsV1UpsellV1RteMenuRecommendationsPost(requestBody)
            .build()
        return { request.enqueue() }
    }
}

Если нужно, на этом этапе могут применяться эксперименты и конфиги. Например, для разных групп пользователей могут использоваться разные наборы данных или разные настройки отображения.

5. Мобильный бэкенд: Rendering

Для описания визуальных моделей секций используется интерфейс RenderData.

data class RteUpsellWidgetRenderData(
    val imageUrl: String,
) : RenderData

Для их рендеринга используется DivkitSectionRenderer. Он:

  • Получает RenderData, дополненный данными, от SectionPlanner.
  • Для каждого элемента UI (виджета) вызывает соответствующий Renderer.
  • Renderer использует DivKit DSL для формирования описания UI. Он «собирает» UI из примитивов DivKit (контейнеры, текст, изображения, кнопки и т. д.), задавая им параметры (размеры, цвета, отступы, шрифты) на основе полученных данных.
  • Результатом работы Renderer’ов является Snippet — фрагмент описания UI в формате DivKit.
  • RenderingInterceptor объединяет все Snippet’ы в единый документ — JSON, который представляет собой полное описание UI экрана.
  • Этот JSON-документ и есть конечный результат работы мобильного бэкенда.
@Service
@BindTo(RteUpsellWidgetRenderData::class)
class RteUpsellWidgetRenderer : DivkitSectionRenderer<RteUpsellWidgetRenderData, RenderingContext>() {

    override fun render(
        sectionId: String,
        data: RteUpsellWidgetRenderData,
        builder: Builder,
        context: EatsRenderingContext
    ) {
        builder.snippets += Snippet(
            divan = divan {
                data(
                    states = singleRoot(
                        div = image(
                            width = fixedSize(150),
                            height = wrapContentSize(),
                            imageUrl = url(data.imageUrl)
                        )
                    )
                )
            }
        )
    }
}

6. Ответ мобильного бэкенда

Мобильный бэкенд отправляет JSON-документ мобильному клиенту. Ответ также «упакован» в формат, понятный «Flex SDK».

{
  "ui": {
    "sections": [
…
],
    "settings": {
      "refreshable": true,
      "scrollable": true,
      "pagination": {
        "triggerFactor": 0.3,
        "minPagingThreshold": 100.0
      },
      "enableSnippetsAnimations": false
    },
    "actions": {
      "onShow": {...},
    "type": "section"
  },
  "scaffold": {
    "topView": {...},
    "bottomView": {...},
    "backgroundColor": "#00000000",
    "type": "DivkitScaffold"
  },
  "actions": {
    "onAwake": {...},
    "onFinish": {...}
  },
  "transitionSettings": {
    "onShow": {
      "duration": 0.25,
      "transition": "crossDissolve"
    }
  }
}

7. Мобильный клиент: «Flex SDK»

  • «Flex SDK» на стороне клиента получает ответ от бэкенда.
  • Он распаковывает ответ, извлекая из него JSON-документ с описанием UI.
  • «Flex SDK» раскладывает элементы DivKit по нужным местам на экране в соответствии с иерархией, заданной в JSON.
  • Запускает процесс рендеринга DivKit.

8. Мобильный клиент: DivKit

  • DivKit (клиентская часть) получает JSON-описание UI.
  • Он преобразует это описание в нативные элементы Android/iOS и выводит их на экран.
  • DivKit также обрабатывает события пользовательского ввода, такие как нажатия, свайпы, ввод текста и, если это необходимо, отправляет их обратно на бэкенд через цепочку «Flex SDK» — «Товарищ SDK».

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

Сложности при реализации BDUI

Несмотря на все преимущества BDUI, его внедрение в Еду не обошлось без сложностей. Вот с какими проблемами мы столкнулись:

  1. Ограниченные возможности сложных анимаций и UI-логики. BDUI отлично справляется с рендерингом интерфейсов, но когда речь заходит о сложных анимациях, нестандартных переходах между экранами и управлении визуальными состояниями, возникают трудности. Гибкость нативной разработки здесь выше, а в BDUI сложные визуальные эффекты требуют значительных усилий.
  2. Сложность в освоении для разработчиков. BDUI кардинально меняет привычный процесс работы. Android-разработчики быстрее адаптируются, но для iOS-специалистов переход даётся сложнее — им приходится изучать новый язык программирования и работать в непривычной среде разработки. Это замедляет внедрение BDUI и требует дополнительного времени на обучение.
  3. Рост сложности архитектуры приложения. BDUI требует множества библиотек и в какой-то момент приложение превращается в «слоёный пирог» из технологий. Не всегда удаётся просто и гибко настроить поведение UI или провести ручную отладку.
  4. Баги и различия между платформами. Несмотря на то, что ключевые библиотеки BDUI разрабатываются внутри Яндекса, периодически в них обнаруживаются баги или несоответствия в поведении между iOS и Android. Иногда на поиск бага, отправку запроса на исправление, ожидание фикса и обновление версии библиотеки может уйти больше времени, чем на разработку самой фичи.

Эти сложности не отменяют преимуществ BDUI, но заставляют нас постоянно искать способы улучшения инструментов, с которыми мы работаем.

Подводя итоги

С BDUI интерфейсы в Яндекс Еде разрабатываются быстрее, а изменения доходят до пользователей без обновлений приложения. Опытные разработчики сразу пишут код для iOS и Android, что ускоряет процесс. Правда, новичкам сначала сложнее разобраться с таким подходом, но со временем работа становится удобнее.

Мы используем единые технологии, библиотеки и API, чтобы сервисы Яндекса — Такси, Маркет, Еда — могли легко обмениваться целыми экранами. В будущем, когда BDUI охватит бóльшие части приложений, любые сервисы смогут показывать экраны друг друга без доработок. Именно к этому мы стремимся.

Другие публикации