
Привет, Хабр!
Я Дмитрий, iOS-разработчик из команды Салют — мы делаем устройства и программное обеспечение для Умного дома Сбер. У нас много собственных устройств и ещё больше устройств брендов-партнёров, которые поддерживает платформа. Релизный круговорот фичей и интеграций заставляет думать: как оптимизировать процесс доставки новых функций пользователям?
В статье расскажу про опыт разработки, внедрения и поддержки нашей собственной backend-driven UI парадигмы (BDUI) — подхода, в котором сервер управляет не только данными, но и вёрсткой интерфейсов.
Проблема: бесконечный релизный круговорот
Обычно путь обновления выглядит так: согласование макетов → разработка → ревью → сборка → ревью в сторе → релиз. Пользователь получает новую функциональность только после того, как обновит приложение. А что, если его уже удалено из стора? Или юзер просто отключил автоматическое обновление и ленится обновлять вручную?
Например, один из экранов приложения Салют — блок датчиков, где собраны их показания. Красным подсвечиваются критические значения. Максимально удобная фича, которую без обновления пользователи не видят.

BDUI позволяет обойти часть релизных процессов: пользователь не обновляясь получает новую фичу на экран своего устройства. Даже если приложение удалили из стора.
Каждый второй разработчик, которому мы рассказывали о проекте, задавал один и тот же вопрос.
— Почему не DivKit? Зачем изобретать колесо, если есть готовое решение?
Ответ — безопасность данных и независимость от сторонних команд. У умного дома могут быть самые разные задачи. Используя чужое решение, мы бы вынуждены были отказываться от задумок, которые в нём нельзя поддержать. Кроме того, в любой момент может измениться лицензионная политика. Для энтерпрайз-проектов это большой риск.
Salute User Interface
В итоге мы сделали SUI — Salute User Interface. Аналитика и разработка проекта заняли примерно полгода. Чтобы составить аналитику, собрали мини-команду: iOS-разработчик, Android-разработчик, бэкендер, и аналитик. Встречались каждый рабочий день, искали в основном, как подружить фронты. Оказалось, что декларативные UI-фреймворки обеих платформ имеют схожий набор сущностей:
Контейнеры — управляют расположением дочерних элементов.
Элементы — атомарные UI-компоненты (текст, изображение, кнопка).
Модификаторы — изменяют внешний вид и поведение элементов.

У большинства сущностей схожие аргументы инициализации. Это дало нам единый язык для описания экранов.
В итоге стек SUI выглядит так: Go (бэкенд) → YAML (контент) → JSON (протокол) → SwiftUI / Jetpack Compose.
Задача бэкенда — сформировать JSON по определённым правилам и подставить данные в контент, написанный менеджером. Бэкенд-разработчик добавил YAML-слой. При запросе экрана YAML автоматически конвертируется в JSON. На фронтах — декларативный подход. Для iOS это SwiftUI, для Android — Jetpack Compose.
JSON-протокол: как выглядит экран изнутри?
Ответ сервера для одного экрана — это дерево компонентов. Вот упрощённый пример блока датчиков:
{
"type": "vstack",
"spacing": 12,
"padding": { "horizontal": 16, "vertical": 12 },
"children": [
{
"type": "text",
"value": "Датчики",
"style": "headline",
"color": "$colorPrimary"
},
{
"type": "hstack",
"spacing": 8,
"children": [
{
"type": "sensor_card",
"title": "Температура",
"value": "21°C",
"status": "normal",
"icon": "thermometer"
},
{
"type": "sensor_card",
"title": "CO₂",
"value": "1200 ppm",
"status": "critical",
"icon": "wind"
}
]
}
]
}Поле status: "critical" — сервер сам говорит клиенту, что значение нужно подсветить красным. Логика отображения переехала с фронта на бэк.
YAML на стороне контент-менеджера
Контент-менеджер работает с YAML — он проще для чтения и записи, чем JSON:
screen:
type: vstack
spacing: 12
padding:
horizontal: 16
vertical: 12
children:
- type: text
value: "Датчики"
style: headline
color: $colorPrimary
- type: hstack
spacing: 8
children:
- type: sensor_card
title: "Температура"
value: "21°C"
status: normal
icon: thermometer
- type: sensor_card
title: CO₂
value: "1200 ppm"
status: critical
icon: windПри запросе экрана бэкенд конвертирует YAML в JSON и подставляет актуальные данные с устройств.
Дизайн-система: токены из бэкенда
Строки вида $colorPrimary — это токены дизайн-системы. Вся дизайн-система приложения хранится на бэкенде. При запросе экрана клиент получает маппинг токенов в реальные значения, и они подставляются автоматически:
{
"tokens": {
"colorPrimary": "#1A1A2E",
"colorCritical": "#E94560",
"colorNormal": "#16213E"
}
}Контент-менеджер использует имена токенов и стили — ему не нужно знать конкретные hex-значения. При смене темы достаточно обновить маппинг на сервере.
Реализация на iOS: рендер-движок на SwiftUI
На клиенте живёт рендер-движок, который принимает JSON и строит SwiftUI-дерево. Основная точка входа — рекурсивный рендерер:
struct SUIRenderer: View {
let component: SUIComponent
var body: some View {
switch component.type {
case .vstack:
VStack(spacing: component.spacing ?? 0) {
ForEach(component.children ?? [], id: \.id) { child in
SUIRenderer(component: child)
}
}
.padding(component.padding?.edgeInsets ?? .zero)
case .hstack:
HStack(spacing: component.spacing ?? 0) {
ForEach(component.children ?? [], id: \.id) { child in
SUIRenderer(component: child)
}
}
case .text:
SUIText(component: component)
case .sensorCard:
SUIResolvedView(identifier: "sensor_card", component: component)
default:
EmptyView()
}
}
}Нативные кастомные компоненты (вроде sensor_card) регистрируются через реестр. Это позволяет расширять систему без изменения ядра рендерера:
final class SUIRegistry {
static let shared = SUIRegistry()
private var builders: [String: (SUIComponent) -> AnyView] = [:]
func register<V: View>(identifier: String, builder: @escaping (SUIComponent) -> V) {
builders[identifier] = { AnyView(builder($0)) }
}
func build(identifier: String, component: SUIComponent) -> AnyView {
builders[identifier]?(component) ?? AnyView(EmptyView())
}
}
// Регистрация при старте приложения:
SUIRegistry.shared.register(identifier: "sensor_card") { component in
SensorCardView(
title: component.string(for: "title") ?? "",
value: component.string(for: "value") ?? "",
status: SensorStatus(rawValue: component.string(for: "status") ?? "normal") ?? .normal,
icon: component.string(for: "icon") ?? ""
)
}Модификаторы: переносим SwiftUI-модификаторы в JSON
Декларативная природа SwiftUI позволяет элегантно реализовать модификаторы. Каждый модификатор применяется как обёртка:
extension View {
func applyModifiers(_ modifiers: [SUIModifier]?) -> some View {
guard let modifiers else { return AnyView(self) }
return AnyView(
modifiers.reduce(AnyView(self)) { view, modifier in
switch modifier.type {
case .background:
return AnyView(view.background(Color(hex: modifier.value ?? "")))
case .cornerRadius:
return AnyView(view.cornerRadius(CGFloat(modifier.doubleValue ?? 0)))
case .shadow:
return AnyView(view.shadow(radius: CGFloat(modifier.doubleValue ?? 0)))
case .opacity:
return AnyView(view.opacity(modifier.doubleValue ?? 1))
}
}
)
}
}В JSON это выглядит так:
{
"type": "sensor_card",
"title": "CO₂",
"value": "1200 ppm",
"status": "critical",
"modifiers": [
{ "type": "background", "value": "$colorSurface" },
{ "type": "cornerRadius", "value": "12" },
{ "type": "shadow", "value": "4" }
]
}Всё о шаблонах: переиспользование вёрстки на уровне JSON
Одна из самых мощных возможностей SUI — система шаблонов. Без неё контент-менеджер вынужден копировать одинаковые блоки в каждом месте экрана. Шаблоны решают эту проблему так же, как компоненты в SwiftUI: объявляешь один раз, используешь везде — с разными данными.
JSON верхнего уровня делится на две части:
{
"templates": { ... },
"content": { ... }
}templates — словарь переиспользуемых блоков. content — корневой элемент экрана, который ссылается на шаблоны через "type": "template".
Шаблон описывает структуру компонента, оставляя «слоты» для данных. Слот объявляется через template_bindings — словарь, где ключ — поле компонента, значение — имя переменной, в которую будут подставлены данные:
"templates": {
"red_text": {
"type": "text",
"color": "#FFFFFF",
"text_alignment": "center",
"modifiers": [
{ "type": "background", "fill": { "type": "solid", "value": "#1fcecb" } }
],
"template_bindings": {
"content": "text"
}
}
}Здесь content — поле, которое будет заполнено. "text" — имя переменной, которую передаст вызывающая сторона.
В content шаблон вызывается через "type": "template". Данные передаются в template_data:
{
"type": "template",
"template_id": "red_text",
"template_data": {
"text": "Элементарный шаблон"
}
}Рендерер найдёт red_text в словаре шаблонов, подставит "Элементарный шаблон" в поле content текстового элемента и построит итоговый View.
Тоже шаблон, но сложный
Шаблоны умеют не только принимать скалярные значения — в слот можно передавать целый список дочерних компонентов. Именно так устроен шаблон-обёртка feature, который использовался на тестовом экране для группировки всех демо-блоков:
Код под спойлером
"feature": {
"type": "column",
"modifiers": [
{ "type": "padding", "top": 12, "bottom": 12, "start": 12, "end": 12 },
{
"type": "border",
"width": 1,
"corner_radius": 20,
"color": {
"type": "linear_gradient",
"colors": [
{ "color": "#ff0000", "location": 0.0 },
{ "color": "#00ff00", "location": 0.5 },
{ "color": "#ffffff", "location": 1.0 }
],
"angle": 30
}
}
],
"items": [
{
"type": "text",
"font_size": 22,
"template_bindings": { "content": "feature_name" }
},
{
"type": "column",
"alignment": "h_center",
"template_bindings": { "items": "content" }
},
{
"type": "spacer",
"modifiers": [{ "type": "height", "value": 12 }]
}
]
}Два template_bindings — для скаляра (feature_name → поле content у текста) и для массива (content → поле items у вложенного column). При вызове передаём оба:
Ещё больше кода под спойлером
{
"type": "template",
"template_id": "feature",
"template_data": {
"feature_name": "Диплинки",
"content": [
{
"type": "template",
"template_id": "button",
"template_data": { "title": "Rendering (sheet)" },
"modifiers": [
{ "type": "action_on_click", "values": ["/rendering?mode=sheet"] }
]
},
{
"type": "template",
"template_id": "button",
"template_data": { "title": "Navigation (пейринг устройств)" },
"modifiers": [
{ "type": "action_on_click", "values": ["/navigation?pageId=addDevice"] }
]
}
]
}
}Шаблоны вкладываются друг в друга — feature содержит несколько button. Система поддерживает три дополнительных паттерна вложенности:
шаблон-псевдоним — просто ссылается на другой шаблон, наследуя его биндинги.
"nested_red_text": {
"type": "template",
"template_id": "red_text"
}Вызов: { "template_id": "nested_red_text", "template_data": { "text": "..." } } — биндинг text проходит сквозь два уровня.
Шаблон с предзаполненными данными — значение зашито в шаблоне, вызывающей стороне ничего передавать не нужно:
"nested_red_filled_text": {
"type": "template",
"template_id": "red_text",
"template_data": {
"text": "Это вложенный шаблон"
}
}Используется как { "type": "template", "template_id": "nested_red_filled_text" } — без template_data.
Шаблон с переименованием ключа — позволяет переименовать биндинг, чтобы вызывающий код использовал другое имя:
"nested_red_moved_binding_text": {
"type": "template",
"template_id": "red_text",
"template_bindings": {
"text": "new_text"
}
}Внутри red_text слот называется text, но снаружи вызывающий код передаёт ключ new_text.
YAML для контент-менеджера
Контент-менеджер работает с объявлением шаблона в YAML. Выглядит оно так:
Много кода
templates:
button:
type: column
items:
- type: row
modifiers:
- type: align
value: center_center
- type: border
width: 2
corner_radius: 20
color:
type: linear_gradient
colors:
- color: "#ff0000"
location: 0.0
- color: "#00ff00"
location: 0.5
angle: 30
- type: height
value: 60
- type: width
value: 380
items:
- type: text
template_bindings:
content: title
- type: spacer
modifiers:
- type: height
value: 10
content:
type: lazy_column
items:
- type: template
template_id: feature
template_data:
feature_name: "Диплинки"
content:
- type: template
template_id: button
template_data:
title: "Открыть настройки"
modifiers:
- type: action_on_click
values:
- /navigation?pageId=settingsНа клиенте шаблоны разворачиваются в конкретные компоненты до начала рендеринга. Резолвер — это рекурсивная функция, которая проходит по дереву компонентов:
Вновь много кода
final class SUITemplateResolver {
private let templates: [String: SUIComponent]
init(templates: [String: SUIComponent]) {
self.templates = templates
}
func resolve(_ component: SUIComponent) -> SUIComponent {
guard component.type == .template,
let templateId = component.templateId,
let templateDefinition = templates[templateId]
else {
// Не шаблон — просто рекурсивно обходим дочерние элементы
return component.replacingChildren(component.items?.map { resolve($0) })
}
// Шаблон может ссылаться на другой шаблон — резолвим рекурсивно
let resolvedDefinition = resolve(templateDefinition)
// Применяем template_data и template_bindings
let merged = apply(
data: component.templateData,
bindings: component.templateBindings,
to: resolvedDefinition
)
// Дополнительные модификаторы вызывающей стороны добавляем поверх
return merged.appendingModifiers(component.modifiers)
}
private func apply(
data: [String: SUIValue]?,
bindings: [String: String]?,
to component: SUIComponent
) -> SUIComponent {
var result = component
// template_bindings на уровне определения: { "content": "feature_name" }
// заменяем ключ биндинга на значение из data вызывающей стороны
if let ownBindings = component.templateBindings {
for (fieldKey, dataKey) in ownBindings {
// Если биндинг переименован вызывающей стороной — используем новое имя
let resolvedKey = bindings?[dataKey] ?? dataKey
if let value = data?[resolvedKey] {
result = result.setting(field: fieldKey, to: value)
}
}
}
// Рекурсивно применяем к дочерним элементам
result = result.replacingChildren(result.items?.map {
apply(data: data, bindings: bindings, to: $0)
})
return result
}
}Резолвер запускается один раз при получении ответа сервера, до передачи данных в SUIRenderer:
struct SUIScreenView: View {
let response: SUIScreenResponse
private var resolvedContent: SUIComponent {
let resolver = SUITemplateResolver(templates: response.templates ?? [:])
return resolver.resolve(response.content)
}
var body: some View {
SUIRenderer(component: resolvedContent)
}
}После резолвинга SUIRenderer работает с уже «плоским» деревом компонентов — без каких-либо ссылок на шаблоны. Это упрощает рендер и делает отладку предсказуемой.
Результаты и профит
После интеграции BDUI в проект мы получили:
Доставку новой функциональности без обновления приложения. Фичи с навигацией тоже работают через BDUI. Тестовая публикация приложения с BDUI в в AppStore показала, что всё работает.
Экономию времени разработки. BDUI позволил сократить время интеграции новых экранов, а заодно и силы команды: большая часть интерфейсов нового контента создаётся контент-менеджером, а не разработчиком.
Масштабируемость. Решение легко переносится на другие проекты. Дизайн-система хранится в одном месте — на бэкенде. Контент-менеджер может использовать имена токенов, стили в JSON подставляются автоматически.
Свободу для фронтенд-разработчиков. Разработчики занимаются нативными компонентами и ядром рендерера, не верстая каждый экран вручную.
Далее в наших планах — расширить библиотеку компонентов, поддержать анимации через JSON и унифицировать протокол между платформами на уровне схемы (JSON Schema). Если вы тоже сталкивались с проблемой бесконечных релизных циклов или в целом интересуетесь BDUI — добро пожаловать в комментарии.



















