# Гайд: Backend-Driven UI (BDUI) с Flutter SDK

Этот гайд представляет собой пошаговое руководство по использованию gravity-sdk-flutter для реализации паттерна Backend-Driven UI (BDUI). BDUI — это мощный подход, который позволяет динамически изменять интерфейс вашего приложения с сервера, без необходимости выпускать новые версии в App Store или Google Play.

Мы рассмотрим практический бизнес-сценарий: динамическое изменение кнопки на карточке товара в зависимости от сегмента пользователя. Это поможет проиллюстрировать весь процесс — от настройки кампании в Gravity Field до реализации во Flutter, включая обработку действий пользователя и отслеживание аналитики.

Ключевые цели этого гайда:

  1. Объяснить концепцию: Понять, что такое BDUI и какие преимущества он дает.
  2. Предоставить практические шаги: Создать пошаговое руководство для реализации динамического компонента.
  3. Продвигать лучшие практики: Сделать акцент на надежной реализации, включая моделирование данных, управление состоянием, обработку ошибок и аналитику.

# Что такое BDUI и зачем его использовать с Gravity Field?

Backend-Driven UI (BDUI) — это архитектурный паттерн, при котором сервер отправляет в мобильное приложение не просто данные, а описание того, как должен выглядеть и вести себя пользовательский интерфейс. Приложение, в свою очередь, "рисует" этот интерфейс на основе полученных инструкций.

Преимущества использования BDUI с Gravity Field:

  • Гибкость: Меняйте UI "на лету". Запускайте акции, меняйте тексты, кнопки и даже целые блоки без релиза новой версии приложения.
  • A/B-тестирование UI: Легко тестируйте разные варианты интерфейса на разных сегментах пользователей и измеряйте, какой из них работает лучше.
  • Персонализация: Показывайте разным пользователям разный интерфейс. Например, новичкам — подсказки, а постоянным клиентам — специальные предложения.
  • Централизованное управление: Вся логика отображения UI находится в одном месте — в интерфейсе Gravity Field, что упрощает управление и снижает количество ошибок.

# Описание сценария: Динамическая кнопка на карточке товара

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

  • Для новых пользователей: Мы хотим, чтобы они добавляли товары в избранное, формируя свой список желаний. Для них основной кнопкой будет "Добавить в избранное".
  • Для вернувшихся пользователей: Мы хотим собрать обратную связь о товарах, которые они, возможно, уже покупали. Для них основной кнопкой будет "Оценить товар".

С помощью BDUI мы реализуем эту логику так, чтобы приложение само запрашивало конфигурацию кнопки у Gravity Field и отображало нужный вариант в зависимости от сегмента, к которому относится текущий пользователь.


# Часть 1: Настройка в Gravity Field

Первый шаг — создать "бэкенд" конфигурацию для нашего UI в интерфейсе Gravity Field. Мы создадим API-кампанию, которая будет возвращать JSON с описанием кнопки.

# 1.1. Создание API-кампании

  1. Перейдите в раздел Campaigns → API Campaigns и нажмите Создать кампанию.
  2. Выберите тип кампании Custom JSON.
  3. Задайте API-селектор. Это уникальный идентификатор, по которому ваше Flutter-приложение будет запрашивать эту конфигурацию. Назовем его product_card_main_button.

# 1.2. Настройка вариаций JSON

Теперь создадим две вариации, по одной для каждого сегмента пользователей.

# Вариация A: Для новых пользователей

  1. Создайте первую вариацию (например, назовите ее "Добавить в избранное").
  2. В поле JSON вставьте следующую конфигурацию:
{
  "type": "FAVORITE_BUTTON",
  "text": "Добавить в избранное",
  "icon": "heart_outline",
  "action": {
    "type": "ADD_TO_FAVORITES",
    "productId": "12345"
  }
}
  • type, text, icon: Описывают внешний вид кнопки.
  • action: Описывает действие, которое должно произойти при нажатии. Мы передаем тип действия (ADD_TO_FAVORITES) и productId, чтобы приложение знало, какой товар добавить в избранное.

# Вариация B: Для вернувшихся пользователей

  1. Создайте вторую вариацию (например, "Оценить товар").
  2. В поле JSON вставьте следующую конфигурацию:
{
  "type": "RATE_BUTTON",
  "text": "Оценить товар",
  "icon": "star_outline",
  "action": {
    "type": "SHOW_RATING_DIALOG",
    "productId": "12345"
  }
}
  • Здесь action.typeSHOW_RATING_DIALOG, что говорит приложению открыть диалог оценки для указанного productId.

# 1.3. Настройка таргетинга

Теперь свяжем каждую вариацию с нужным сегментом пользователей.

  1. В рамках кампании создайте два сценария (Experiences).
  2. Сценарий для новых пользователей:
    • В настройках таргетинга выберите аудиторию New users.
    • Внутри этого сценария оставьте только вариацию "Добавить в избранное".
  3. Сценарий для вернувшихся пользователей:
    • В настройках таргетинга выберите аудиторию Returning users.
    • Внутри этого сценария оставьте только вариацию "Оценить товар".

После сохранения и публикации кампании наш "бэкенд" готов отдавать нужную конфигурацию UI.


# Часть 2: Реализация во Flutter

Теперь перейдем к Flutter-приложению. Нам нужно получить JSON, распарсить его в строго типизированные модели и на основе этих данных построить виджет.

# 2.1. Получение данных

Для получения конфигурации используем метод GravitySDK.instance.getContentBySelector.

import 'package:gravity_sdk/gravity_sdk.dart';
import 'dart:convert';

// Вспомогательный класс для возврата данных
class ButtonConfigResponse {
  final ButtonConfig config;
  final Campaign campaign;

  ButtonConfigResponse({required this.config, required this.campaign});
}

Future<ButtonConfigResponse?> fetchButtonConfig(String productId) async {
  try {
    final response = await GravitySDK.instance.getContentBySelector(
      selector: 'product_card_main_button',
      pageContext: PageContext(
        type: ContextType.product,
        data: [productId], // Передаем ID текущего товара
        location: 'app://product/$productId',
      ),
    );

    if (response.data.isNotEmpty) {
      final campaign = response.data.first;
      final content = campaign.payload.first.contents.first;
      
      // Для Custom JSON кампаний, контент может быть в поле `variables`
      // или в другом поле в зависимости от конфигурации.
      // Здесь мы предполагаем, что `variables` содержит наш JSON.
      // В реальном SDK может потребоваться другой способ доступа.
      final customData = (content.variables as dynamic).customData;
      
      Map<String, dynamic> configJson;
      if (customData is String) {
        configJson = jsonDecode(customData) as Map<String, dynamic>;
      } else {
        configJson = customData as Map<String, dynamic>;
      }

      final buttonConfig = ButtonConfig.fromJson(configJson);
      return ButtonConfigResponse(config: buttonConfig, campaign: campaign);
    }
    return null;
  } catch (e) {
    // Обработка ошибок (например, нет сети)
    print('Error fetching button config: $e');
    return null;
  }
}

# 2.2. Моделирование данных (Data Models)

Парсинг JSON в строго типизированные Dart-классы — это лучшая практика. Она защищает от ошибок во время выполнения и делает код более читаемым.

// Enum для типов действий для повышения надежности кода
enum ActionType {
  addToFavorites,
  showRatingDialog,
  unknown
}

// Модель для описания действия, которое нужно выполнить
class ButtonAction {
  final ActionType type;
  final String productId;

  ButtonAction({required this.type, required this.productId});

  factory ButtonAction.fromJson(Map<String, dynamic> json) {
    ActionType actionType;
    switch (json['type'] as String?) {
      case 'ADD_TO_FAVORITES':
        actionType = ActionType.addToFavorites;
        break;
      case 'SHOW_RATING_DIALOG':
        actionType = ActionType.showRatingDialog;
        break;
      default:
        actionType = ActionType.unknown;
    }
    
    return ButtonAction(
      type: actionType,
      productId: json['productId'] as String,
    );
  }
}

// Основная модель конфигурации кнопки
class ButtonConfig {
  final String type;
  final String text;
  final String icon;
  final ButtonAction action;

  ButtonConfig({
    required this.type,
    required this.text,
    required this.icon,
    required this.action,
  });

  factory ButtonConfig.fromJson(Map<String, dynamic> json) {
    return ButtonConfig(
      type: json['type'] as String,
      text: json['text'] as String,
      icon: json['icon'] as String,
      action: ButtonAction.fromJson(json['action'] as Map<String, dynamic>),
    );
  }
}

# 2.3. Создание динамического виджета

Используем FutureBuilder для асинхронной загрузки конфигурации и отображения UI.

import 'package:flutter/material.dart';
import 'package:visibility_detector/visibility_detector.dart';
// ... импорты ваших моделей и сервисов

class DynamicProductButton extends StatefulWidget {
  final String productId;

  const DynamicProductButton({Key? key, required this.productId}) : super(key: key);

  @override
  _DynamicProductButtonState createState() => _DynamicProductButtonState();
}

class _DynamicProductButtonState extends State<DynamicProductButton> {
  late Future<ButtonConfigResponse?> _configFuture;

  @override
  void initState() {
    super.initState();
    _configFuture = fetchButtonConfig(widget.productId);
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<ButtonConfigResponse?>(
      future: _configFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          // Состояние загрузки: показываем placeholder (например, Shimmer)
          return const CircularProgressIndicator();
        }

        if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) {
          // Ошибка или нет данных: показываем UI по умолчанию (fallback)
          return _buildFallbackButton();
        }

        // Успех: строим кнопку на основе полученной конфигурации
        final response = snapshot.data!;
        return _buildDynamicUi(response.config, response.campaign);
      },
    );
  }

  Widget _buildDynamicUi(ButtonConfig config, Campaign campaign) {
    final button = _buildButtonFromConfig(config, campaign);

    return VisibilityDetector(
      key: Key(campaign.payload.first.contents.first.contentId),
      onVisibilityChanged: (visibilityInfo) {
        if (visibilityInfo.visibleFraction >= 0.5) {
          GravitySDK.instance.sendContentEngagement(
            ContentVisibleImpressionEngagement(
              campaign.payload.first.contents.first,
              campaign,
            ),
          );
        }
      },
      child: button,
    );
  }

  Widget _buildButtonFromConfig(ButtonConfig config, Campaign campaign) {
    IconData iconData;
    switch (config.icon) {
      case 'heart_outline':
        iconData = Icons.favorite_border;
        break;
      case 'star_outline':
        iconData = Icons.star_border;
        break;
      default:
        iconData = Icons.help_outline;
    }

    return ElevatedButton.icon(
      icon: Icon(iconData),
      label: Text(config.text),
      onPressed: () {
        _handleAction(config.action, campaign);
      },
    );
  }

  Widget _buildFallbackButton() {
    return ElevatedButton.icon(
      icon: const Icon(Icons.shopping_cart_outlined),
      label: const Text('Добавить в корзину'),
      onPressed: () { /* Логика добавления в корзину по умолчанию */ },
    );
  }

  void _handleAction(ButtonAction action, Campaign campaign) {
    final currentAction = ButtonAction(type: action.type, productId: widget.productId);
    ActionHandler.handle(context, currentAction, campaign);
  }
}

# 2.4. Логика обработки действий (Action Handler)

Создадим централизованный обработчик, который будет вызывать нужную бизнес-логику и отправлять событие клика.

class ActionHandler {
  static void handle(BuildContext context, ButtonAction action, Campaign campaign) {
    // Отправляем событие клика в Gravity Field
    GravitySDK.instance.sendContentEngagement(
      ContentClickEngagement(
        campaign.payload.first.contents.first,
        campaign,
      ),
    );

    switch (action.type) {
      case ActionType.addToFavorites:
        print('Добавляем товар ${action.productId} в избранное');
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Товар ${action.productId} добавлен в избранное!')),
        );
        break;
      case ActionType.showRatingDialog:
        print('Показываем диалог оценки для товара ${action.productId}');
        showDialog(
          context: context,
          builder: (_) => AlertDialog(
            title: Text('Оцените товар'),
            content: Text('Как вам товар ${action.productId}?'),
            actions: [
              TextButton(onPressed: () => Navigator.pop(context), child: Text('Закрыть')),
            ],
          ),
        );
        break;
      case ActionType.unknown:
        print('Неизвестное действие');
        break;
    }
  }
}

# Заключение и лучшие практики

Мы рассмотрели полный цикл реализации Backend-Driven UI с помощью Gravity Field и Flutter SDK.

Ключевые выводы и лучшие практики:

  • Моделируйте данные: Всегда парсите JSON в строго типизированные Dart-классы.
  • Предусматривайте Fallback UI: Ваше приложение должно оставаться функциональным, даже если сервер не вернул конфигурацию.
  • Используйте Placeholder'ы: Показывайте индикаторы загрузки (скелетоны, шиммеры), чтобы улучшить пользовательский опыт при медленной сети.
  • Централизуйте обработку действий: Создайте единый ActionHandler для обработки всех действий, приходящих с бэкенда.
  • Не забывайте про аналитику: Отслеживайте показы и клики, чтобы измерять эффективность ваших A/B-тестов и персонализаций.
  • Рассмотрите кэширование: Для некритичных UI-элементов можно реализовать стратегию кэширования конфигурации, чтобы уменьшить задержки при повторном открытии экрана.

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