Как мы строили BDUI: опыт Яндекс Маркета

  • Алексей Морозов
    Алексей Морозов

    Алексей Морозов

    iOS разработчик в Яндекс

Привет! Меня зовут Алексей Морозов. Я уже несколько лет работаю в инфраструктурной команде Яндекс Маркета. Наша команда делает проекты, улучшающие скорость приложения, его качество и скорость разработки. Но раньше такие проекты решали эти проблемы только на время, а потом приложение снова деградировало. Всё это напоминало древнегреческий миф, в котором Сизиф толкает в гору камень, он скатывается вниз, и всё начинается по новой.

Такое положение дел нас не устраивало, поэтому мы приняли решение изменить подход. И мы построили BDUI (Backend Driven UI). В этой статье расскажу, как мы к этому пришли, что сделали и какой получили результат.

Начнём издалека — посмотрим, какие у нас были проблемы пару лет назад и почему нам знаком этот сизифов труд.

С какими трудностями столкнулась наша команда до BDUI

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

Допустим, наша фича успешно разрабатывается примерно три дня. После этого наступает период ожидания, когда будет запущен релиз. Если разработчик не успел попасть в него, ему приходится ждать следующего, то есть целую неделю. Каждый релиз проходит через регресс, который занимает ещё пару дней. Не исключено, что в релизе найдут баги, на разбор и исправление которых может уйти ещё пара дней. И когда наконец всё завершается, финальный билд релиза отправляется на ревью в сторы. Сам этот процесс тоже занимает несколько дней, однако бывали случаи, когда затягивалось и на пару недель. Также само ревью может закончиться неудачно, поэтому мы получим отказ. Такое «письмо счастья» может откинуть нас в самое начало, а дальше всё снова.

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

Вторая сложность — консистентность приложений на платформах. Нативные экраны могут различаться, например, из‑за отличий в параметрах запросов в backend или из‑за разницы в сроках выкладки фичи. Предлагаем приглядеться к примеру ниже и найти несколько отличий:

Правильный ответ:

Такие отличия, возможно, не очень критичны для обычного пользователя, но это существенно для продукта, потому что сложно строить прогнозы, проводить эксперименты и делать какие‑то выводы, когда приложения ведут себя по‑разному.

И третья проблема — скорость нашего приложения. Если бы мы запустили его и посмотрели в сниффер, увидели бы ~76 запросов. Такое количество запросов было необходимо только для старта приложения и отрисовки главной страницы. Суммарный вес ответов был около 10 мегабайт. Всё из‑за того, что в Маркете много разных бэкендов. Каждый бэкенд выполняет свою задачу, но клиенты не ходят в них напрямую, а используют proxy‑backend. Такой бэкенд пересылает клиенту сырой или минимально обработанный ответ, который получил от другого. И мы, как мобильные разработчики, не могли на него повлиять. Ответы могли содержать много лишних, неиспользуемых данных.

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

Обратив внимание на все эти проблемы, мы выделили следующие требования:

  • отображать экран, сделав всего один запрос
  • готовить данные на бэкенде
  • релизить фичи за один день

И раз мы делали что‑то новое, хотелось влиять на то, что рисует клиент, как именно он это делает и какие действия выполняет. Другими словами, нам хотелось уметь управлять контентом приложения с сервера. Очевидно, что для достижения поставленных целей нам был необходим бэкенд и код на клиенте, который будет уметь работать с ответами этого бэкенда.

Как мы работали над улучшениями

Отрисовываем контент

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

Главная страница приложения Яндекс Маркет

Для начала нам понадобится привычный контейнер, например UIViewController или Activity, в который мы поместим движок Flex. Он и будет отвечать за отрисовку контента. Таким образом, мы можем встроить его в любую часть приложения и получить BDUI.

Чтобы движок отрисовал экран, ему необходимо получить документ. Есть несколько способов передачи документа: например, движок сам может сходить в бэкенд и обработать его. Или мы можем напрямую передать экземпляр документа в движок в нативе. Сам документ — это структура, где одно из основных полей — это «ui». Оно описывает контент, который нам необходимо отобразить. Контент же, в свою очередь, это структура, описывающая пользовательский интерфейс для отображения основной информации, с которой взаимодействует пользователь. Его форма представления может быть любой: вертикальный список элементов, вёрстка во весь экран, веб‑страница, горизонтальный пейджер и так далее. Тип контента, его содержание и возможности взаимодействия с ним определяются источником данных. И у нас уже реализовано несколько видов контента, например статическая вьюшка и секции.

Так как главная страница Маркета — это лента, рассмотрим её на примере секционного контента. Опишем структуру документа и положим внутрь секционный контент. Поле «section» будет содержать в себе все секции, которые мы захотим отрисовать на экране.

"document": {
  "ui": {
    "type": "section",
    "sections": []
  }
}

Но что же такое секция и как она будет выглядеть? В данном случае секция — это элемент интерфейса, который сам по себе ничего не делает, а просто принимает готовые данные от бэкенда. Рассмотрим, как можно описать секцию при помощи Flex. Для начала необходимо описать саму модельку секции, которая будет парситься из ответа бэкенда. Чтобы движок смог правильно определить и отрисовать нашу секцию, необходимо задать поле «type».

{
  "type": "SomeSection",
  "id": "12345",
  "data": "someData"
}

Также мы можем положить какой‑то другой контент, который нам необходим. Рассмотрим на примере секции баннеров.

Для отрисовки такого баннера нам достаточно всего лишь получить URL картинки и текст для бейджика.

{
  "type": "BannersSection",
  "content": [
    {
      "id": "4321",
      "imageUrl": "https://ya.ru/banner.png",
      "adLabelEnabled": true,
      "adLabelText": "Реклама"
    },
    {...}
  ]
}

Теперь добавим следующую секцию с быстрыми ссылками и получим вот такой документ:

{
  "ui": {
    "type": "section",
    "sections": [
      {
        "type": "BannersCarouselSection",
        "content": [
          {...}
        ]
      },
      {
        "type": "HotlinksSection",
        "content": [
          {...}
        ]
      }
    ]
  }
}

Чтобы движок смог отрисовать секции на экране, ему необходимо передать нативные реализации вьюшек. После этого движок сможет обработать секции по type и подобрать правильные реализации.

Вот какие секции у нас получились

Встраиваем DivKit

Возможно, на этом этапе возникнет вопрос: «А что же тут такого BDUI‑ного? Ведь мы просто научились готовить данные и рисовать их в нативных вьюшках». И, пожалуй, это было бы правдой, однако мы интегрировали себе DivKit. DivKit — это фреймворк для отрисовки интерфейсов из ответа сервера. Подробнее можно узнать в отличной статье Ольги Ким. Это позволило нам заменить нативные реализации вьюшек на намного более гибкое решение. И мы получили возможность изменять не просто контент в приложении, а модифицировать целую вёрстку сразу на двух платформах без нативных релизов. Для этого мы во Flex реализовали готовый компонент — DivKit‑cекцию. В неё мы передаём div‑верстку, которую собираем на бэкенде. Пример такой вёрстки и возможность её попробовать — в Playground DivKit.

{
  "type": "DivkitSection",
  "content": [
    {
      "divData": {
        ...
      }
    }
  ]
}

Осталось отрисовать секции с товарами, и мы сделаем это на DivKit.

{
  "type": "DivKitSection",
  "content": [
    {
      "type": "divkit",
      "snippet": {
        "id": "031799851768",
        "divData": {
          "log_id": "FeedBoxDivkitProduct",
          "states": [...]
        }
      }
    }
  ]
}

В итоге у нас получилось реализовать главную страницу Маркета на новом фреймворке:

Главная страница Маркета на Flex

Добавляем динамику

Однако что делать, если хочется добавить интерактивности? Введём понятие экшена. Экшен — это действие, которое описывает и сообщает движку, что ему нужно сделать. В самой простой реализации это идентификатор, который описывает его тип.

{
  "type": "SomeAction"
}

Для каждого действия существует обработчик, который отвечает за выполнение этого действия. Действия могут быть привязаны к свайпам, тапам и любой другой активности пользователя, а также к не связанным с пользователем событиям (например, к срабатыванию таймера). Привязанные к событиям действия передаются на обработку приложению в момент возникновения события. Сами действия передаются в условную шину событий, где находят свой обработчик и вызывают его.

Вернёмся к секциям на главной Маркета и попробуем применить экшены. Допустим, стоит задача отправить аналитику на показ баннера. Чтобы её выполнить, расширим интерфейс и добавим объект, который назовём «actions». Так как нам необходимо вызвать экшен в момент показа баннера, то назовём его onShow и поместим в объект actions. При отправке действия onShow вызовется обработчик экшена, который мы указали. Он, в свою очередь, отправит данные аналитики, которые мы ему передали.

{
  "type": "BannersSection",
  "content": [...],
  "actions": {
    "onShow": {
      "type": "SendAnaylticsAction",
      "name": "BANNER_VISIBLE",
      "params": {
        "widgetId": "54321",
        "name": "BannersSection",
        "is_login": false
      }
    }
  }
}

Сшиваем страницы

Но как сделать что‑то более сложное, например бесконечную ленту? То есть перед нами стоит задача реализовать пагинацию. И мы сделаем это следующим образом: добавим специальный сниппет-шиммер, доскроллив до которого, мы вызываем загрузку второй страницы. Такой сниппет должен:

  • принимать и отрисовывать DivKit‑верстку;
  • принимать и обрабатывать экшн на показ контента (можем воспользоваться onShow или visibility из DivKit).
Бесконечная лента товаров

Чтобы корректно загрузить вторую страницу, необходим токен (номер страницы), с которым Flex пойдёт в бэкенд. Когда вторая страница загрузилась, необходимо склеить вторую часть документа с текущей версией. Пусть за склейку отвечает действие MergeSectionAction, куда мы и передадим токен следующей страницы.

{
  "snippet": {
    "divData": {..},
    "actions": {
      "onShow": {
        "type": "MergeSectionAction",
        "sectionId": "12345",
        "params": {
          "nextPageToken": 2
        }
      }
    }
  }
}

Вставляем экран в экран

Однако над бесконечной лентой есть поле для ввода, которое ведёт пользователя на экран поиска. Здесь стоит задача научиться рисовать контент поверх секций и других вьюшек. И тут вводится понятие scaffold. Так как мы обернули движок в контейнер (UIViewController или Activity), у нас есть возможность делегировать ему создание и отрисовку scaffold. Таким образом, scaffold — это экран, который помещается внутрь движка, и, когда приходит контент, мы просто заворачиваем его внутрь scaffold.

Scaffold с полем поиска товаров

А как это выглядит для Flex с точки зрения ответа бэкенда? Расширим ответ и добавим новое поле, где передадим какой‑то тип. В этот момент движок считает его и попробует достать. После этого создаст экран, поместит его в себя и подгрузит контент, который опять положит в себя. Как с секциями или экшенами, мы можем обогатить scaffold дополнительными данными. Например, изменять иконку в navigation bar.

"document": {
  "ui": {...},
  "scaffold": {
    "type": "SomeScaffold",
    "title": "Мой заголовок",
    "rightButtonIcon": "https://icons.com/..."
  }
}

И эту нативную часть мы тоже заменили на DivKit, реализовав scaffold, у которого есть две вьюшки — снизу и сверху. В каждую из этих вьюшек прилетает своя вёрстка. Всё это можно наверстать на бэкенде и отобразить поверх секций.

"document": {
  "ui": {...},
  "scaffold": {
    "type": "DivKitScaffold",
    "topView": {
      "divData": {...}
    },
    "bottomView": { },
    "backgroundColor": "#FFFFFF"
  }
}

Оттачиваем навигацию

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

Чтобы открыть новый экран, необходимо сделать новый экземпляр движка и загрузить контент нового экрана. Поскольку нам необходимо, чтобы движок выполнил какое‑то действие, начнём с экшена. Так как нужно открыть следующий экран, то назовём его ForwardAction. Но новый экран ничего не знает о том, что ему требуется отобразить, поэтому ему мы передадим query, который заранее подготовили на бэкенде. Движок сам сходит в бэкенд, получит и отрисует данные.

{
  "type": "ForwardAction",
  "query": {
    "path": "/api/screen/product?id=12345"
  }
}

А откуда берётся скелетон, который отобразился во время загрузки контента? Для этого у нас есть библиотека, которая называется Skeletor. С помощью неё можно быстро собрать необходимый UI. Это решение достаточно хорошо кастомизируется и позволяет управлять цветами, расположением элементов и другими параметрами. За счёт лаконичного синтаксиса получаем очень лёгкий ответ. Подробнее можно посмотреть в замечательном докладе Михаила Бесхитрова.

(padded 50 100 (bone 100% 200 20))
Пример простого скелетона

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

"document": {
  "ui": {
    "type": "skeleton",
    "skeleton": "(theme #F8F7F5 0.0p (col (theme #F8F7F5 4.0p (col (padded 12.0p 16.0p (row 100.0% sb (bone 24.0p 24.0p) ... (bone 100.0% 34.0p 4.0p))))))))"
  }
}
Пример скелетона главной страницы Маркета

Что мы получили в итоге

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

Сама фича по‑прежнему разрабатывается примерно три дня, однако ожидание релиза занимает максимум день.

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

По скорости тоже всё отлично, теперь на экран нам требуется всего один запрос весом около 500 килобайт. Это всё ещё много, но в несколько раз компактнее, чем было раньше. Бо́льшую часть ответа занимает аналитика, но мы уже переезжаем на новую систему, которая облегчит ответ в десятки раз.

Так мы получили технологию, которая позволяет нам перевести приложение целиком на BDUI. Регулярно сталкиваясь с новыми кейсами, мы дополняем и развиваем её. Также мы успешно развиваем инфраструктуру вокруг нашего решения. В проекте применяются различные виды тестов (Unit, UI, E2E). А основным преимуществом является возможность писать тесты в одном экземпляре сразу под две платформы. Такой подход позволяет сохранять консистентность, ускорять разработку и иметь единый источник правды.

Нашим BDUI заинтересовались и другие команды Яндекса, которые уже примеряют подход на себе. И вот, например, заказывая такси в Go, можно увидеть ленту Маркета. Это стало возможно после вынесения фреймворка в отдельный, самостоятельный SDK. И теперь на нём можно сделать практически любое приложение.

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

Михаил Шкутков

28/6/2024

Александр Миронычев

4/10/2023

Айдар Шайфутдинов

29/7/2024

Александр Фишер

30/5/2024

Антон Полухин

16/5/2024

Егор Федяев

15/4/2024

Антон Полухин

28/3/2024

Валерий Ильин

5/7/2024

Артём Фомин

14/4/2022