Привет, Хабр! 

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