Pull to refresh
1124.84
Яндекс
Как мы делаем Яндекс

Яндекс выпускает DivKit — фреймворк для server-driven UI с открытым кодом

Reading time12 min
Views54K
Привет! Сегодня Яндекс выкладывает в опенсорс DivKit — фреймворк для отрисовки интерфейсов из ответа сервера. Серверная вёрстка поможет ускорить разработку: наладить отправку апдейтов от сервера разным версиям приложения, создать прототип или просто написать интерфейс один раз для нескольких платформ.


Фреймворк включает в себя несколько библиотек: клиентскую часть по отрисовке интерфейсов для Android, iOS и веба, а также DSL для формирования ответа сервера на Kotlin, TypeScript и Python. Исходный код опубликован на Гитхабе под лицензией Apache 2.0.

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

Небольшой экскурс в историю


Мы в команде приложения Яндекс очень давно задавались вопросом, как быстрее докатывать изменения до пользователей. Главная страница приложения состоит из карточек, каждая из которых решает пользовательскую задачу. Например, показывает прогноз погоды, пробки на маршруте или перекрытия дорог в городе. Как предупредить человека, если закрылась станция метро, внезапно пошёл град — или случилось ещё что-то, что требует изменений не только в данных, но и в UI?

Наше первое решение — добавить в ленту карточку на основе WebView для показа кастомизированных сообщений. Но, потратив где-то полгода на разбор странных крешей WebView, мы решили искать другой путь.

Сначала посмотрели на ленту карточек и увидели в них много повторяющихся частей. Везде есть шапка с тремя точками и футер одного стиля. В простом варианте внутри содержится текст или текст плюс картинка.

Так родилась первая версия Div'ов, состоящая из высокоуровневых смысловых блоков. С сервера приходил минимум информации:

{
    "type": "title",
    "title": "Hello World!",
    "title_style": "title_s"
}

На клиенте мы зашили знание о том, какие параметры (размер, начертание, межстрочный и межбуквенный интервал) надо подставить для стиля title_s.

Всё было хорошо примерно год. Запустить новую карточку, поэкспериментировать с её видом и наполнением удавалось за неделю. Поэтому появлялось всё больше карточек, а новые сервисы быстро интегрировались в основную ленту приложения. Довольно просто было добиваться pixel perfect при сравнении с макетами: если все элементы переносились из макетов корректно, они точно соответствовали тому, что в итоге появлялось в продакшене.

Но концепции дизайна, как и разработки, не стоят на месте. Сначала стало не хватать зашитых стилей текста, потом — отступов горизонтального размера. Мы могли потихоньку вносить правки в стиль текущих блоков, но решили сделать всё с нуля и по-другому.

Div 2.0


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

Базовыми элементами у нас стали текст, картинка, линейный контейнер для расположения элементов и сетка.

Для примера — список параметров, которые сейчас можно настроить для текстового элемента
font_size: размер шрифта.

font_family: семейство шрифтов.

line_height: межстрочный интервал (интерлиньяж) диапазона текста. Отсчёт ведётся от базовой линии шрифта.

max_lines: максимальное количество строк, которые не будут обрезаны при выходе за ограничения.
min_hidden_lines: минимальное число обрезанных строк при выходе за ограничения.

auto_ellipsize: ввтоматическая обрезка текста под размер контейнера.

letter_spacing: интервал между символами.

font_weight: начертание.

text_alignment_horizontal: горизонтальное выравнивание текста.

text_alignment_vertical: вертикальное выравнивание текста.

text_color: цвет текста.

focused_text_color: цвет текста при фокусировке на элементе.

text_gradient: градиентный цвет текста.

text: сам текст.

underline: подчёркивание.

strike: зачёркивание.

ranges: диапазон символов, в котором можно установить дополнительные параметры стиля. Определяется обязательными полями start и end.

images: изображения, встроенные в текст.

ellipsis: маркер обрезки текста. Отображается, когда размер текста превышает ограничение по количеству строк.

selectable: выделение и копирование текста.

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

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

Примерно через год нам стало тесно и в текущих фичах тоже, захотелось уметь менять с сервера не только карточки в ленте. Постепенно с натива на дивы переехала главная шапка, у нас внутри она смешно называется Бендер. Профиль, bottom sheet и другие блоки тоже стали «дивными».

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

Пользователи DivKit


Едадил


Каждый продуктовый сценарий в мобильном приложении Едадил выделен в отдельное мини-веб-приложение. У такого подхода есть свои преимущества, но те экраны, где важно сохранить нативную скорость загрузки и плавность взаимодействия, реализованы без использования веб-технологий. Один из примеров — главный экран, который из-за этого не удавалось развивать так же быстро, как остальные разделы.

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

Так зародился сервис Mosaic. Он позволял генерировать UI для нативных экранов, и его основой (инструментом для Backend Driven UI) стал DivKit. Для удобства админов главной страницы и верстальщиков блоков также создали WYSIWYG-редактор. Mosaic уже второй год живёт в продакшене, продолжает развиваться, обрастать продуктовой и технической функциональностью.



Маркет


Команда Маркета рассматривала несколько вариантов:

  • Написать движок для кросплатформенной разработки — но это было бы слишком дорого и нецелесообразно.
  • Flutter/React Native. Дорого в обучении и интеграции в текущий проект, непроизводительно, плюс ограничена возможность конфигурирования.
  • KMM — не решал проблему с вёрсткой, не позволял динамически менять бизнес-логику.

Самой подходящей по критериям технологией оказался DivKit. В качестве proof of concept решили накидать самую сложную карточку заказа в онлайн-просмотрщике и посмотреть, сколько это займёт времени с нуля, без сторонней помощи, если пользоваться лишь документацией. В итоге человек, который раньше не сталкивался с DivKit, подготовил макет чуть больше чем за час.

В плане интеграции всё прошло гладко. Достаточно было:

  • Подключить библиотеку, опираясь на заранее предоставленный Sample App и документацию.
  • Написать обвязки для уже имеющихся функций, чтобы затем вызывать их напрямую из DivKit.
  • И начать присылать серверную вёрстку и на iOS, и на Android.



Яндекс ТВ


У Яндекс ТВ особая ситуация — прошивки для телевизоров обновляются гораздо реже, чем происходят обычные апдейты приложений, и цена ошибки слишком высока. Server Driven UI позволяет проверять гипотезы с желаемой частотой, выкатывать новые фичи, а прошивки выпускать редко. Сейчас один из блоков на главной странице ТВ создан с помощью DivKit.



К делу!


Давайте напишем небольшое приложение под Android и iOS. Это будет очень простой просмотрщик ленты Хабра.

Для DivKit сначала необходимо создать конфигурацию, инициализировать библиотеку. В Android это можно сделать так:

fun createDivConfiguration(): DivConfiguration {
    return DivConfiguration.Builder(DefaultDivImageLoader(imageManager))
        .actionHandler(DemoDivActionHandler())
        .supportHyphenation(true)
        .typefaceProvider(YandexSansTypefaceProvider(this))
        .visualErrorsEnabled(true)
        .build()
}

Обязательно нужно реализовать загрузчик картинок. Я возьму стандартную реализацию, которая поставляется вместе с фреймворком. Вы можете использовать привычные вам библиотеки: Glide, Picasso и так далее.

.supportHyphenation(true) — включаю перенос по слогам. Это нужно прописывать отдельно, потому что поддержка переноса по слогам может вызывать просадки в производительности. Поэтому включаем явно.

.typefaceProvider(YandexSansTypefaceProvider(this)) — подключаемый шрифт. Yandex Sans идёт отдельным пакетом, не в основном модуле.

.visualErrorsEnabled(true) — включаю отображение ошибок прямо в моей вьюхе. Тем самым можно упростить дебаг: если у меня будут ошибки в div-вёрстке, появится каунтер с ними в левом верхнем углу. Это ускорит разработку. В продакшен-версии отображение ошибок, конечно, следует отключить.

Код для iOS будет выглядеть так:

components = DivKitComponents(
  updateCardAction: nil,
  urlOpener: urlOpener
)

DivKitComponents — «фасад» для работы с DivKit.

updateCardAction — вызывается, когда DivKit хочет обновить вьюху.

urlOpener — внешний обработчик URL-адресов.

Тут можно подробнее остановиться на обработке ошибок в дивах. При невалидной вёрстке неправильные блоки просто выкидываются, убираются из отрисовки. Какие случаи считаются невалидными? Например, если нет поля "text", обязательного для текстового дива, то он не сможет построиться. В контейнере обязательно должен быть хотя бы один дочерний элемент в "items". Эти и другие правила описаны в нашей json-schema.

Распарсим JSON. Пока возьмём зашитые данные — DivKit'у не важно, откуда приходит вёрстка.

Android:

val divJson = assetReader.read(DIVJSON_PATH)
val templateJson = divJson.optJSONObject("templates")
val cardJson = divJson.getJSONObject("card")

iOS:

private func loadCard() throws -> DeserializationResult<DivData>? {
  let url = Bundle.main.url(forResource: "div_json", withExtension: "json")!
  let data = try Data(contentsOf: url)
  let divJson = try DivJson(JSONData: data)
  return divJson.cards.first.flatMap {
    DivData.resolve(
      card: $0,
      templates: divJson.templates
    )
  }
}

DivJson — структура, в которую десериализуется ответ нашего сервера.

DivData.resolve() — создаёт модель карточки по данным из ответа.

Что такое divJson.optJSONObject("templates")? Вёрстка делится на данные и шаблоны, необходимые для упрощения понимания и уменьшения объёма. У шаблона обязательно должен быть тип дива, или он должен наследоваться от такого шаблона.

Если потребуется подставлять данные в какие-то поля, то их необходимо пометить через $ в названии поля. Например: "$text": "card_title", где "card_title" — название поля шаблона.

Вот как будут выглядеть шаблоны для карточки Хабра
"templates": {
    "habr_card": {
        "type": "container",
        "background": [
            {
                "type": "solid",
                "color": "#FFF"
            }
        ],
        "paddings": {
            "left": 16,
            "top": 16,
            "right": 16,
            "bottom": 16
        },
        "margins": {
            "bottom": 8
        },
        "items": [
            {
                "type": "container",
                "orientation": "horizontal",
                "items": [
                    {
                        "type": "user_avatar",
                        "$image_url": "avatar"
                    },
                    {
                        "type": "user_name",
                        "$text": "username"
                    },
                    {
                        "type": "time",
                        "$text": "time"
                    }
                ],
                "margins": {
                    "bottom": 8
                }
            },
            {
                "type": "title",
                "$text": "title"
            },
            {
                "type": "container",
                "orientation": "horizontal",
                "items": [
                    {
                        "type": "footer_icon",
                        "image_url": "https://yastatic.net/s3/home/yandex-app/div_demo/diamond.png"
                    },
                    {
                        "type": "footer_text",
                        "text_color": "#7AA600",
                        "$text": "votes"
                    },
                    {
                        "type": "footer_space"
                    },
                    {
                        "type": "footer_icon",
                        "image_url": "https://yastatic.net/s3/home/yandex-app/div_demo/eye.png"
                    },
                    {
                        "type": "footer_text",
                        "$text": "views"
                    },
                    {
                        "type": "footer_space"
                    },
                    {
                        "type": "footer_icon",
                        "image_url": "https://yastatic.net/s3/home/yandex-app/div_demo/flag.png"
                    },
                    {
                        "type": "footer_text",
                        "$text": "favorites"
                    },
                    {
                        "type": "footer_space"
                    },
                    {
                        "type": "footer_icon",
                        "image_url": "https://yastatic.net/s3/home/yandex-app/div_demo/bubble.png"
                    },
                    {
                        "type": "footer_text",
                        "$text": "comments"
                    }
                ]
            }
        ],
        "orientation": "vertical"
    },
    "user_name": {
        "type": "text",
        "font_size": 13,
        "text_color": "#414b50",
        "font_weight": "medium",
        "width": {
            "type": "wrap_content"
        },
        "alignment_vertical": "center",
        "margins": {
            "right": 5
        }
    },
    "user_avatar": {
        "type": "image",
        "width": {
            "type": "fixed",
            "value": 24
        },
        "height": {
            "type": "fixed",
            "value": 24
        },
        "border": {
            "corner_radius": 3
        },
        "margins": {
            "right": 5
        }
    },
    "time": {
        "type": "text",
        "font_size": 13,
        "text_color": "#777",
        "width": {
            "type": "wrap_content"
        },
        "alignment_vertical": "center",
        "margins": {
            "right": 5
        }
    },
    "title": {
        "type": "text",
        "font_size": 20,
        "text_color": "#333",
        "line_height": 23,
        "font_weight": "medium",
        "margins": {
            "bottom": 20
        }
    },
    "footer_icon": {
        "type": "image",
        "scale_type": "fit",
        "width": {
            "type": "fixed",
            "value": 24
        },
        "height": {
            "type": "fixed",
            "value": 24
        },
        "border": {
            "corner_radius": 3
        },
        "paddings": {
            "top": 4,
            "bottom": 4,
            "left": 4,
            "right": 4
        }
    },
    "footer_text": {
        "type": "text",
        "font_size": 13,
        "text_color": "#BDCDD6",
        "font_weight": "medium",
        "width": {
            "type": "wrap_content"
        },
        "margins": {
            "left": 4
        },
        "alignment_vertical": "center"
    },
    "footer_space": {
        "type": "separator",
        "width": {
            "type": "match_parent",
            "weight": 1
        },
        "delimiter_style": {
            "color": "#0000"
        }
    }
}

Подставляем данные в шаблон:

"card": {
    "log_id": "div2_sample_card",
    "states": [
        {
            "state_id": 0,
            "div": {
                "type": "container",
                "background": [
                    {
                        "type": "solid",
                        "color": "#F0F0F0"
                    }
                ],
                "items": [
                    {
                        "type": "habr_card",
                        "avatar": "https://habrastorage.org/r/w32/getpro/habr/avatars/133/48f/f00/13348ff00887755177016af1d4ea450e.png",
                        "username": "Barseadr",
                        "time": "сегодня в 11:48",
                        "title": "Встречи формата 1-on-1: не противостояние, а слаженное взаимодействие",
                        "votes": "+4",
                        "comments": "10",
                        "views": "200",
                        "favorites": "2"
                    }
                ]
            }
        }
    ]
}

Здесь видно, что данные занимают очень малый объём. Шаблоны помогают значительно сократить вёрстку, выделив все повторяющиеся фрагменты. Вот как будет выглядеть сама карточка:



Добавляем фабрику по созданию вьюшек. Android:

internal class DivViewFactory(
    private val context: Div2Context,
    private val templatesJson: JSONObject? = null
) {

    private val environment = DivParsingEnvironment(ParsingErrorLogger.ASSERT).apply {
        if (templatesJson != null) parseTemplates(templatesJson)
    }

    fun createView(cardJson: JSONObject): Div2View {
        val divData = DivData(environment, cardJson)
        return Div2View(context).apply {
            setData(divData, DivDataTag(divData.logId))
        }
    }
}

DivParsingEnvironment — библиотека распаршенных шаблонов, они будут переиспользоваться при создании карточек. Методом createView мы создаём view из шаблонов и данных. В конструкторе фабрики упоминается DivContext — он аналогичен контексту в Android и используется для тех же целей. В нём можно расшарить общие между вьюхами зависимости.

Как создаётся контекст:

val divContext = Div2Context(baseContext = this, configuration = createDivConfiguration())

Добавляем данные во view: setData(divData, DivDataTag(divData.logId). DivData — это DTO-модель карточки, которая строится из шаблонов и данных.

iOS:

func setCard(_ card: DivData) throws {
  let context = components.makeContext(
    cardId: DivCardID(rawValue: card.logId),
    cachedImageHolders: []
  )
  let block = try card.makeBlock(context: context)
  let view = block.reuse(
    state?.view,
    observer: nil,
    overscrollDelegate: nil,
    renderingDelegate: nil,
    superview: self
  )
  state = State(block: block, view: view)
}

components.makeContext() — создаём контекст. Для этого нужно передать уникальный в рамках контекста идентификатор карточки и хранилище картинок, чтобы они не моргали при переиспользовании.

block.reuse() — создаст новую вьюху или переиспользует существующую.

Код приложения можно посмотреть на Гитхабе, там оно немного доработано: сделана не одна карточка, а лента на DivKit, и есть пример интеграции для iOS.

Кстати, поддержка переменных и функций позволяет писать с помощью DivKit и более сложные блоки интерфейса. Например, можно сделать виджет для настройки цвета:



Что дальше?


Всё больше команд в Яндексе переводят свои приложения на DivKit, и мы дорабатываем технологию под их требования. В ближайших планах:

  • Новый layout с «констрейтами»
  • Flutter
  • Вспомогательные компоненты для работы с DivKit

DivKit как технология Server Driven UI удобна тем, что не требует многого на старте. Вы можете перевести на дивы один экран или даже его часть — карточку, кнопку или другой элемент. В самом начале сервер не обязателен: DivKit легко использовать как облегчающий работу фреймворк, все данные зашить на клиенте, а когда появится механизм ответа сервера — начать получать апдейты.

Для экспериментов есть песочница. К ней подключён веб-движок DivKit, также можно скачать из Google Play и подключить к песочнице демоприложение (в App Store оно появится в сентябре). Данные будут обновляться вживую: песочница соединяется с демоприложением по веб-сокетам. Ответы на многие вопросы есть на сайте, если чего-то не нашли — спрашивайте в комментариях или в телеграм-чате.
Tags:
Hubs:
Total votes 102: ↑95 and ↓7+110
Comments49

Articles

Information

Website
www.ya.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия