Backend-Driven UI или как ускорить поставку функционала без частых релизов
опубликовано: ⏰ 16Вспомнил одну интересную задачу, связанную с пользовательским интерфейсом, которую приходилось делать когда-то давно. Были ситуации, когда требовалась быстрая доставка изменений до пользователя без прохождения полного релизного цикла с доработкой приложений. О том, как это можно реализовать и ускорить для мобильных, десктопных и веб-приложений, далее в статье.
Введение
Приведу пример из прошлого, для транспортных перевозок и приложений для водителей. Не редко в процессе эксплуатации обнаруживаются как критические, так и не очень ошибки в приложении. Например, нет необходимых полей на экране с информацией о заказе, без которой его подтверждение и выполнение невозможно. В обычной ситуации это требует:
- Получение информации об инциденте и постановки задачи на исправление.
- Исправления проблемы силами разработчика.
- Сборка и поставка нового релиза на тестовый стенд.
- Запуск процесса тестирования.
- Публикация новой версии в маркетплейсе с ожиданием подтверждения (если это публично доступное приложение).
- Распространение обновления на устройства водителей.
Всё это может занимать значительное время, которое может повлечь простой в работе, если нет альтернативных путей получения необходимой информации.
Один из подходов решения этой проблемы – реализация Backend-Driven UI (BDUI) или Server-Driven UI (SDUI). Если в классическом подходе сервер отвечает исключительно за данные, а клиент за их отображение, то в модели BDUI сервер берет на себя ответственность за структуру интерфейса, компоновку элементов и даже часть логики взаимодействия.
10 лет назад или больше при разработке приложений (Android/iOS) была идея реализовать BDUI. Но если правильно помню, всё закончилось на этапе PoC, дальше не доделал. А вот для веб-приложений не раз приходилось делать настраиваемые администратором формы для различных частей системы. Всё это зачастую было реализовано через JSON-описание структуры формы, которая затем отображалась на клиенте.
Подходы к разработке интерфейсов
Один из самых старых и известных подходов это Server-Side Rendering (SSR). Думаю, многим приходилось работать с сайтами на PHP, JSP, Perl, CGI-скриптах, где интерфейс формировался исключительно на сервере. Сервер генерировал готовый HTML-код, который отправлялся браузеру. Браузер исполнял роль простого просмотрщика сгенерированного контента. Вся логика отображения была жестко привязана к серверному коду. Это обеспечивало консистентность – что видел пользователь, то и было развёрнуто на сервере. У этого были свои минусы: повышенная нагрузка на сервер (рендеринг), низкая интерактивность (всё действия через обращение к серверу).
Потом, с развитием JavaScript, начали появляться веб-приложения работающие на клиентской стороне – Client-Side Rendering (CSR). Фреймворки вроде Angular, React и Vue позволили перенести логику отображения на сторону клиента. Сервер стал отдавать только данные в формате JSON, а браузер собирал из них интерфейс. Это позволило создать интерактивные веб-приложения – Single Page Applications (SPA), где переходы между экранами происходили мгновенно с подгрузкой только необходимых данных с сервера.
В мобильной разработке аналог подхода SPA был с самого начала для нативных приложений iOS, Android, Windows Mobile/Windows Phone (да и такое было). Приложения выступали в роли “толстых клиентов”, содержащими в себе всю логику UI, анимации, навигацию и бизнес-правила. Сервер только отдавал данные через API. Это дало отличную производительность, сняв нагрузку с серверов и возможность работать оффлайн, но создало проблему, о которой говорил выше – жесткая связка к версии приложения и версии интерфейса.
Одной из попыток решить эту проблему в мобильной разработке было использование различных WebView – встроенных браузеров внутри нативных приложений (совместили CSR и нативное мобильное приложение). Это позволяло обновлять интерфейс мгновенно в казалось бы нативных приложениях. Однако были определённые минусы: интерфейс выглядел и ощущался иначе, чем нативные элементы, производительность была ниже, а доступ к нативным функциям устройства был ограничен или требовал разработки дополнительных адаптеров.
Backend-Driven UI (BDUI) можно рассматривать как развитие идеи серверного рендеринга (SSR), адаптированную для нативных приложений. В отличие от WebView, BDUI не генерирует готовый HTML (это вообще может быть не HTML). Вместо этого сервер отправляет декларативное описание интерфейса (например, в виде JSON), которое клиентское приложение интерпретирует и отображает с помощью нативных компонентов.
Это ключевое отличие. Когда вы используете BDUI, кнопка, которую видит пользователь, – это не веб-элемент <button>, а настоящая UIButton на iOS или android.widget.Button на Android. Это обеспечивает высокую производительность и все возможности нативных компонентов платформы, но при этом сохраняется гибкость веб-подхода. Сервер решает, какие компоненты показать и в каком порядке, а клиент решает, как именно эти компоненты выглядят и ведут себя на конкретной платформе.
Таким образом, BDUI занимает нишу между нативными приложениями и веб-приложениями, объединяя плюсы обоих подходов.
Концепция и архитектура
Примеры далее приведены в контексте мобильных приложений, но всё то же самое применимо для веб-приложений и десктопных приложений.
В классической архитектуре мобильного приложения поток данных выглядит так:
- Пользователь открывает экран.
- Клиент отправляет запрос
GET /api/profile. - Сервер возвращает данные:
{ "name": "Евлампий", "age": 150 }. - Клиент отображает данные в UI-элементах (заранее описанных разработчиком).
В архитектуре BDUI поток данных изменяется следующим образом:
- Пользователь открывает экран (или приложение запрашивает конфигурацию экрана).
- Клиент отправляет запрос
GET /api/screen/profile. - Сервер возвращает дерево компонентов:JSON
{ "type": "Container", "layout": "vertical", "children": [ { "type": "Text", "content": "Профиль", "style": "header" }, { "type": "Text", "content": "Евлампий", "style": "body" }, { "type": "Text", "content": "150 лет", "style": "body" } ] } - Клиентский Renderer получает этот JSON, рекурсивно обходит дерево и для каждого узла создает соответствующий нативный компонент.
На примере ниже приведена упрощённая схема потока данных в BDUI:
База со UI Schemas на стороне клиента не обязательна, но потребуется если приложение должно работать в оффлайне без доступа к серверу (нативные, PWA).
Особенности реализации
Для реализации BDUI можно выделить четыре обязательные части системы:
- Библиотека компонентов.
- Движок рендеринга.
- Схемы интерфейсов.
- Конструктор интерфейсов.
Также важно определить, где проходит граница бизнес-логики:
- Если сервер присылает форму ввода email, должен ли клиент проверять формат email перед отправкой? Клиент должен выполнять базовую валидацию для мгновенной обратной связи (fail fast), но и сервер должен выполнять окончательную валидацию.
- Если нужно рассчитать стоимость доставки, это должно происходить на сервере. Клиент может отображать результат, но не должен его вычислять, чтобы избежать рассинхронизации и манипуляций.
- Клиент должен знать о своих экранах. Сервер может сказать “перейти на экран профиля”, но клиент должен знать, какой нативный контроллер соответствует идентификатору
profile_screen.
Библиотека компонентов
Библиотека компонентов – это основа клиентской части. Клиентское приложение должно содержать заранее реализованный набор виджетов, которые сервер сможет использовать для отрисовки по схеме. Обычно этот набор включает:
- Базовые элементы – текст, изображение, кнопка, различные поля ввода, чекбокс и так далее.
- Контейнеры – вертикальный/горизонтальный список, сетка, форма и так далее.
- Комплексные компоненты – карта, видео-/аудио-плеер, календарь, графики.
Важно понимать, что эта библиотека должна быть параметризуемой. Например, сервер должен иметь возможность менять стили отображения компонентов. Вместо команды “отобрази красную кнопку”, сервер должен отправить { "type": "Button", "style": "danger" }, где стиль danger отображается на клиенте красным цветом. Это позволяет менять представление на клиенте без изменения контракта с сервером.
Движок рендеринга
Движок рендеринга – это ядро клиентской части. Его задача парсить JSON-/XML-схему и превращать ее в граф объектов интерфейса. Рендерер должен быть рекурсивным, так как структура UI является деревом. Он должен уметь:
- Создавать экземпляры классов компонентов с применением свойств, переданных от сервера (текст, цвета, отступы) и учётом ограничений контейнеров, в которых эти компоненты размещаются (layouts).
- Привязывать данные от сервера к элементам интерфейса. При этом это двухсторонняя связь – отображаем и получаем ввод пользователя. Например, если сервер прислал
{"name": "John", "age": 25}, то нужно отобразить это в соответствующих полях и отправить обратно на сервер, то что пользователь ввёл в эти поля. - Уметь обрабатывать события на действия пользователя: нажатия кнопок, скроллинг, жесты, ввод данных.
- Выполнять проверку ввода данных пользователя.
- Корректно обрабатывать ошибки отрисовки схемы интерфейса. Например, если описание компонента некорректное или не поддерживается, то показывать заглушки без полного “падения” приложения.
Схемы интерфейсов
Схемы интерфейсов (UI Schema / DSL) – это по сути язык общения между сервером и клиентом. Чаще всего используется JSON из-за его кажущейся читаемости и широкой поддержки. Но, по моему мнению, JSON плохо подходит для этого из-за своих врождённых ограничений. Для описания типизированных интерфейсов более компактным и предсказуемым всё-таки будет XML. Здесь важно потратить больше времени на проектирование схемы с учётом всех необходимых требований для движка рендеринга.
Схема должна поддерживать:
- Иерархию компонентов, так как описание интерфейса это практически всегда дерево, а не плоский список.
- Задание условий отображения и валидации компонентов. Часто реализуется через логику на клиенте на основе данных контекста.
- Описание действий при взаимодействии пользователя с интерфейсом.
- Возможность привязки компонентов к данным (например,
{ "type": "Text", "value": "@user.name" }).
Для описания клиентской логики в схеме могут использоваться интерпретируемые языки (например, JavaScript – небезопасно в некоторых случаях) или собственный DSL.
Конструктор интерфейсов
Конструктор интерфейсов (UI Builder) – хотя можно собрать схемы вручную (JSON/XML) и хранить в коде, на практике необходим инструмент для визуального создания и управления экранами. Это может быть внутренняя админ-панель, где менеджеры или разработчики описывают экраны из готовых блоков с предварительным просмотром и последующей публикацией. Без такого инструмента преимущество BDUI (скорость изменений) теряется, так как любое изменение схемы в коде бэкенда все равно потребует деплоя серверной части.
Тут важно упомянуть, что не все изменения могут быть выполнены без поставки нового релиза серверной части. Например, изменение в модели данных, в любом случае будет требовать развёртывания новой версии (если конечно модель не динамически формируемая).
Как это реализовать
Решил не делать пример проекта с реализацией BDUI, так как всё сильно зависит от платформы и технологий (Web/JS, iOS/Swift, Android/Kotlin). Везде это реализуется по-разному, даже на уровне схемы. Поэтому не думаю, что будет как-то полезно, а это не быстрая задача.
Схема
Схема описания интерфейса может выглядеть следующим образом:
{
"screen_id": "login_v1",
"version": "1.0.0",
"components": [
{
"id": "header_logo",
"type": "Image",
"properties": {
"url": "https://cdn.example.com/logo.png",
"contentMode": "fit",
"accessibilityLabel": "Логотип компании"
},
"style": {
"marginTop": 40,
"marginBottom": 20,
"alignment": "center"
}
},
{
"id": "email_input",
"type": "TextField",
"properties": {
"placeholder": "Введите email",
"keyboardType": "emailAddress",
"validationRule": "email"
},
"style": {
"marginHorizontal": 16
}
},
{
"id": "password_input",
"type": "TextField",
"properties": {
"placeholder": "Пароль",
"isSecure": true
},
"style": {
"marginHorizontal": 16,
"marginTop": 10
}
},
{
"id": "login_button",
"type": "Button",
"properties": {
"text": "Войти",
"action": {
"type": "submit",
"endpoint": "/api/auth/login",
"method": "POST",
"payload": {
"email": "@email_input.value",
"password": "@password_input.value"
}
}
},
"style": {
"marginHorizontal": 16,
"marginTop": 20,
"theme": "primary"
}
}
]
}Здесь действия на кнопке login_button описываются через поле action. Это действие определяет декларативно без скриптов, что должно произойти при нажатии. Клиент должен уметь интерпретировать это действие, собрать данные из указанных полей (@email_input.value, @password_input.value) и выполнить запрос.
По этой же схеме на клиенте формируется интерфейс с учётом платформы (iOS, Android) с привязкой к данным.
Хранение состояния на клиенте
Один из важных моментов – это управление состоянием или контекстом данных. Когда пользователь вводит текст в поле, это состояние должно быть доступно другим компонентам (например, кнопке отправки, которая может быть неактивна, пока поле пустое). Синхронизацию между компонентами можно организовать двумя способами:
- Через локальное состояние, когда каждый компонент хранит своё значение. Для связи используются события (Event Bus). Например, поле ввода отправляет событие
TextChanged, кнопка подписывается и проверяет валидность. - Через глобальное состояние в виде единого дерева данных, соответствующее дереву UI. Рендерер хранит ссылку на это дерево. Когда поле ввода меняется, оно обновляет значение в дереве данных. Кнопка при отображении читает значение из дерева данных.
Чаще всего приходилось реализовывать второй подход. Он более предсказуем и легче отлаживается, так как интерфейс является чистой функцией от состояния.
Безопасность и валидация
Использование BDUI значительно расширяет поверхность атаки на приложение. Поскольку сервер управляет интерфейсом, злоумышленник может попытаться манипулировать сервером или клиентом, чтобы изменить поведение приложения.
Основное правило – никогда не доверяйте клиенту. То, что сервер прислал кнопку «Перевести 0 рублей», не значит, что клиент должен разрешить перевод. Необходимо учитывать:
- Все расчёты, проверки прав доступа и валидация данных должны дублироваться на сервере. Клиентская валидация – это только для удобства пользователя (UX).
- Если сервер присылает действие для открытия ссылки, клиент должен проверять домен ссылки через белый список. Нельзя разрешать открытие произвольных сайтов, если это не предусмотрено требованиями.
- Если сервер позволяет вставлять пользовательский текст в компоненты (например, комментарии), необходимо гарантировать, что на клиенте этот текст отображается безопасно (экранирование HTML, запрет на выполнение скриптов, если используется WebView внутри компонентов).
Для избежания манипуляций со схемой можно применять следующие меры:
- Обязательно используйте шифрование трафика (HTTPS).
- Обязательная проверка разрешённых сертификатов (SSL Pinning).
- В критических сценариях (например, финансы) сервер может подписывать схему электронной подписью. Клиент проверяет подпись перед отображением. Это гарантирует, что схема не была изменена в пути.
Производительность и оптимизация
Как мне кажется, этот момент спорный, учитывая современные мощности устройств, но стоит упомянуть.
Парсинг больших схем может занимать время на главном потоке (рендеринг UI практически всегда однопоточный), вызывая лаги интерфейса. В качестве решения можно использовать асинхронный парсинг схем и генерацию компонентов пользовательского UI (если это позволяет платформа). Дополнительно можно использовать бинарные форматы описания схем (например, Protobuf).
Со стороны пользовательского интерфейса могут применяться отдельные оптимизации вроде кеширования компонентов или целиком экранов, чтобы не пересоздавать их заново для каждого отображения схемы.
Со стороны сети могут быть задержки, поскольку интерфейс загружается с сервера, пользователь может видеть белый экран дольше обычного. Здесь могут помочь следующие подходы:
- Кешировать схемы экранов на клиенте (на схеме выше это база
UI Schemas). Обновление схем происходит в фоне. - Если пользователь переходит по пути оформления товара (Каталог -> Товар -> Корзина), можно предзагрузить схему экрана
Корзина, пока пользователь смотритТовар. - Показывайте скелетон-заглушку, чтобы пользователь понимал, что контент скоро появится. Даёт ощущение, что приложение работает, а не зависло.
Но всё это не так уж и критично, если схемы не слишком большие.
Версионирование и совместимость
Это, пожалуй, самая сложная организационно-техническая проблема в BDUI. У вас всегда будет парк устройств с разными версиями приложения. Старая версия приложения (1.0) может не знать о новом компоненте VideoPlayer, который появился в версии 2.0.
Как писал выше, движок рендеринга должен корректно обрабатывать ситуации с неизвестными компонентами:
- Если рендерер встречает неизвестный тип, он просто не отображает его или отображает пустое место. Это безопасно, но может сломать верстку (например, исчезнет кнопка отправки).
- Рендерер отображает стандартный блок с текстом “Обновите приложение для просмотра этого контента”.
- Сервер знает версию клиента (например, передается в заголовках). Если клиент старый, сервер может прислать упрощенную версию схемы (без видео, только картинка). Это требует поддержки нескольких версий логики на бэкенде.
Последний пункт требует поддержки нескольких версий схемы на бэкенде и в конструкторе интерфейсов, что может заметно усложнить архитектуру. Но при этом позволяет поддерживать старые клиенты.
Есть более простой подход, который в основном и использовали в прошлом – это форсирование обновления. Если изменения критические и обратно совместимыми быть не могут (например, изменилась модель), сервер должен иметь механизм принудительного обновления. В ответе может прийти флаг обязательного обновления. Клиент просто не сможет продолжать работу без обновления. Это упрощает архитектуру, но может вызвать негатив у пользователей (часто не обновляют до последнего).
Риски и проблемы
Возможно, что-то упускаю, но думаю, что всё можно свести к следующей таблице:
| Риск | Описание |
|---|---|
| Безопасность | - Нельзя доверять клиенту. Вся бизнес-логика и валидация должны быть на сервере. - Риск инъекций через динамический контент. |
| Производительность | - Накладные расходы на парсинг схем и построение интерфейса. - Увеличение объёма передаваемого трафика (текст схемы весит больше, чем просто данные). |
| Работа в оффлайне | - Закэшированные данные и схему не всегда можно показывать. - Через определённый период времени необходимо требовать подключения к серверу. |
| Сложность тестирования | - Сложно тестировать бесконечное количество комбинаций UI. - Кеширование схем на стороне клиента ещё больше усугубляет ситуацию. |
| Ограничение UI | - Набор доступных компонентов ограничен. - Необходимы постоянные доработки (на первых этапах). |
| Сложность реализации | Создание надёжного и полнофункционального фреймворка не совсем тривиальная задача и может потребовать как заметных капитальных затрат, так и операционных на поддержку. |
Инструменты и экосистема
Если честно, никогда не приходилось пользоваться готовыми решениями. Всегда были самописные.
Open-Source решения, которые видел:
- DivKit – это опенсорсный фреймворк от Яндекса для реализации Server-Driven UI (SDUI).
- Duit – это активно развивающаяся экосистема open source библиотек и инструментов, призванная помочь разработчикам легко и эффективно реализовывать подход backend-driven UI в своих приложениях, написанных на Flutter.
- Zup Beagle – похоже уже заброшен.
- React Server Components (RSC) – позволяют генерировать компоненты на сервере и передавать их на клиент. Это концептуально близко к BDUI, но в экосистеме React.
- HTMX – позволяет получать фрагменты HTML от сервера и вставлять их в DOM. Как примен использования можно посмотреть в статье Веб-сервис распознавания изображений на Go с HTMX.
Крупные компании часто пишут свои решения. Это делают по той причине, что готовые фреймворки могут быть избыточными, не покрывать специфические бизнес-кейсы или просто не вписываться во внутренний технический стек.
Стоит ли внедрять BDUI в вашем проекте?
Прежде чем принять решение, необходимо понять, что BDUI действительно оправдано. Обычно начинают с реализации приложений в классическом стиле, а понимание нужно или нет приходит в процессе.
Да, если:
- Требуются частые изменения содержимого представления данных.
- Время релиза в маркетплейсе критично – T2M (нужно меньше 24 часов на изменения).
- Есть ресурсы на поддержку отдельной команды или выделенных инженеров для развития фреймворка (если разрабатывается внутренний фреймворк).
- Необходимо гарантировать одинаковый вид интерфейса на различных платформах (не визуально, а по содержанию).
Нет, если:
- Приложение стабильно и меняется раз в полгода.
- Маленькая команда (2-3 разработчика), которые не потянут поддержку сложной инфраструктуры.
- Приложение требует сложной графики, анимаций или работы с железом (AR, видео-обработка), где нативный код критичен.
- Нет четкого понимания целей и дизайна приложения, компоненты постоянно меняются хаотично.
Заключение
Если свести всё вместе, то к данному подходу приходили эволюционным путём. Когда появлялось понимание, что обычные методы разработки и поставки не подходят или слишком долгие в плане затрачиваемого времени на изменения. Однако эта гибкость не бесплатна. Задача интересная, но требует значительных инженерных усилий, изменения процессов и проработки вопросов безопасности и производительности.