
Привет, Хабр!
Я Дмитрий, 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 — добро пожаловать в комментарии.
