Headless и Custom JSON
Headless подходит, когда Gravity Field выбирает кампанию и доставляет данные, а приложение само строит UI. Вы получаете контент через SDK, не реализуя собственный HTTP-клиент.
Документация относится к 0.24.1. Сначала выполните инициализацию. Настройка кампаний: Кампании в приложении.
Выбор метода
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 после получения контента; приложение фиксирует показ и взаимодействия отдельно.
Отправка идёт через 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-кнопка товара.