Headless и Custom JSON

Headless подходит, когда Gravity Field выбирает кампанию и доставляет данные, а приложение само строит UI. Вы получаете контент через SDK, не реализуя собственный HTTP-клиент.

Документация относится к 0.24.1. Сначала выполните инициализацию. Настройка кампаний: Кампании в приложении.

Выбор метода

Метод Когда использовать Результат
getContentBySelector() Загрузить размещение по selector ContentResponse
getContentByCampaignId() / getContentByGroup() Загрузить кампанию или группу ContentResponse
getContentBySelectorWithDetails() / getContentByCampaignIdWithDetails() Нужна модель и исходный ответ JSON GravityDataResponse<ContentResponse>
trackViewNoShow() Передать просмотр и получить сработавшую кампанию без показа Nullable GravityDataResponse<ContentResponse>
triggerEventNoShow() Передать действие и получить сработавшую кампанию без показа Nullable GravityDataResponse<ContentResponse>

NoShow принимает pageContext, а событийный метод ещё и events. Для этих методов BuildContext не требуется; ответ возвращается через Future.

getContentBy* не отправляет отдельный явный просмотр экрана; передавайте его согласно сценарию через методы трекинга. Избегайте двух независимых запросов одного placement без необходимости.

Контракт ответа

final details = await GravitySDK.instance.getContentBySelectorWithDetails(
  selector: 'checkout_button',
  pageContext: const PageContext(
    type: ContextType.cart,
    data: [],
    location: 'app://cart',
  ),
);

final ContentResponse model = details.data;
final Map<String, dynamic> rawJson = details.json;

GravityDataResponse<T> содержит типизированную модель в data и исходный ответ сервера в json.

Контент находится по цепочке ContentResponse.data → Campaign.payload → CampaignVariation.contents. Список может быть пустым. У кампании могут быть дополнительные шаги, поэтому для начального UI выбирайте контент с step == null.

CampaignVariation содержит идентификаторы кампании/вариации и decisionId. Для SDK engagement не нужно извлекать эти ID и вручную собирать URL: используйте исходные объекты кампании и контента.

Общий код для примеров

Создайте lib/gravity_content.dart из этого файла. Он используется также в A/B и BDUI. Загрузчик возвращает корневой контент; ошибки запроса обрабатывает вызывающий экран.

import 'package:gravity_sdk/gravity_sdk.dart';

class SelectedContent {
  const SelectedContent({required this.campaign, required this.content});
  final Campaign campaign;
  final CampaignContent content;
}

SelectedContent? selectRoot(ContentResponse response) {
  for (final campaign in response.data) {
    for (final variation in campaign.payload) {
      for (final content in variation.contents) {
        if (content.step == null) {
          return SelectedContent(campaign: campaign, content: content);
        }
      }
    }
  }
  return null;
}

Future<SelectedContent?> loadSelectedContent({
  required String selector,
  required PageContext pageContext,
}) async {
  final response = await GravitySDK.instance.getContentBySelector(
    selector: selector,
    pageContext: pageContext,
  );
  return selectRoot(response);
}

// Один объект на одно фактическое размещение полученного контента.
// Загрузка не считается показом. Показ фиксируем после появления UI.
class ContentExposure {
  ContentExposure(this.selected);
  final SelectedContent selected;
  bool _shown = false;
  bool _visible = false;

  void onVisibility(double fraction) {
    if (fraction > 0 && !_shown) {
      _shown = true;
      GravitySDK.instance.sendContentEngagement(
        ContentImpressionEngagement(selected.content, selected.campaign),
      );
    }
    if (fraction >= 0.5 && !_visible) {
      _visible = true;
      GravitySDK.instance.sendContentEngagement(
        ContentVisibleImpressionEngagement(selected.content, selected.campaign),
      );
    }
  }

  void click() {
    GravitySDK.instance.sendContentEngagement(
      ContentClickEngagement(selected.content, selected.campaign),
    );
  }
}

ContentExposure — вспомогательный класс примера, а не часть SDK. Сохраняйте один экземпляр на одно фактически показанное размещение. Не создавайте его заново при каждом build(). При новом выборе контента создайте новый экземпляр и новый ключ detector.

Произвольные переменные

Ожидаемый фрагмент ответа кампании:

{
  "variables": {
    "checkout_button": {
      "variant": "B",
      "label": "Перейти к оплате"
    }
  }
}

Ключ checkout_button и структура объекта — контракт вашего приложения и кампании; SDK не создаёт этот ключ по имени selector автоматически. Проверьте фактический ответ и настройте данные вариаций в кабинете под тот же контракт.

После загрузки SelectedContent selected:

final content = selected.content;
final Map<String, dynamic> variables = content.rawVariables;
final Object? value = content.variables['checkout_button'];
final Map<String, dynamic>? config =
    content.variables.valueOf<Map<String, dynamic>>('checkout_button');

rawVariables содержит весь объект, включая типизированные поля. Он доступен и у обычного getContentBySelector(): использовать WithDetails только ради него не нужно.

valueOf<T>() проверяет тип, но не преобразует значение и не проверяет схему. JSON-массив — List<dynamic>; число удобно читать как num. Целое число не проходит проверку double. Отсутствующий ключ или неверный тип возвращает null.

Карта неизменяема на верхнем уровне. Не изменяйте вложенные объекты ответа: они могут использоваться и в details.json. Для собственной изменяемой модели сделайте копию. Данные content.custom?.json, если их возвращает ваша кампания, — отдельная строка JSON; не приписывайте их объекту GravityDataResponse.

Просмотр и событие без показа

final details = await GravitySDK.instance.trackViewNoShow(
  pageContext: const PageContext(
    type: ContextType.homepage,
    data: [],
    location: 'app://homepage',
  ),
);

final selected = details == null ? null : selectRoot(details.data);
final details = await GravitySDK.instance.triggerEventNoShow(
  events: [
    AddToCartEvent(
      value: 1990,
      productId: 'sku-123',
      quantity: 1,
      currency: 'RUB',
    ),
  ],
  pageContext: const PageContext(
    type: ContextType.product,
    data: ['sku-123'],
    location: 'app://product/sku-123',
  ),
);

null означает, что контент не получен: нет подходящей кампании, загрузка отключена, ответ устарел, событие осталось в очереди или SDK обработал ошибку. Это не доказательство отсутствия кампании в проекте.

Для наблюдения за подробными ответами доступен глобальный callback:

await GravitySDK.instance.initialize(
  apiKey: 'YOUR_API_KEY',
  section: 'YOUR_SECTION_ID',
  gravityContentCallback: (GravityDataResponse<ContentResponse> response) {
    print(response.data.data.length);
  },
);

Он задаётся при инициализации, а не в каждом NoShow. Успешный подробный запрос одновременно возвращает ответ и вызывает callback. В примерах UI обрабатывается через возвращённое значение, чтобы не показать его дважды.

Аналитика собственного UI

Загрузка контента не означает, что пользователь его увидел. SDK вызывает contentLoaded после получения контента; приложение фиксирует показ и взаимодействия отдельно.

Событие Момент
ContentImpressionEngagement(content, campaign) Собственный UI действительно появился
ContentVisibleImpressionEngagement(content, campaign) Не менее 50% размещения видно; один раз на показ
ContentClickEngagement(content, campaign) Пользователь нажал на собственный контент
ContentCloseEngagement(content, campaign) Пользователь закрыл собственное сообщение
ProductVisibleImpressionEngagement(slot, content, campaign) Товар видим в полностью собственном списке
ProductClickEngagement(slot, content, campaign) Пользователь нажал на товар

Отправка идёт через sendContentEngagement() или sendProductEngagement() с исходными объектами ответа.

SDK использует content.variables.onImpression/onVisibleImpression/onClose и соответствующие content.events. Для ContentClickEngagement требуется событие с типом click. Product engagement использует события исходного Slot. Если нужного tracking в ответе нет, вызов не отправит событие; наличие вызова в коде само по себе не подтверждает аналитику.

Не вызывайте engagement из build() или сразу после сетевой загрузки. В примерах VisibilityDetector сообщает о видимости, а флаги предотвращают повторные показы при прокрутке. Встроенный UI SDK дополнительно этим кодом оборачивать не нужно.

Ошибки и жизненный цикл

  • Для пустого ответа или неподдерживаемой схемы оставляйте рабочий fallback приложения.
  • Для getContentBy* перехватывайте исключение; NoShow обычно возвращает null после обработки ошибки.
  • После запроса проверяйте mounted, а при изменении товара/пользователя — ещё и актуальность запроса.
  • Не начинайте загрузку на каждом build(). После смены пользователя пересоздайте персонализированный экран или сбросьте его состояние.
  • Headless не включает постоянный кеш персонализированного UI и не гарантирует его обновление без нового запроса.

Защита автопоказа при смене пользователя и экрана в 0.24.1 относится к встроенному UI SDK. Она не отбрасывает за приложение ответы getContentBy*, NoShow или глобального callback: сохраните проверку актуальности и пересоздание собственного UI после смены аккаунта.

Готовые экраны: A/B-кнопка, BDUI-кнопка товара.