A/B-тестирование интерфейса через JSON

В этом примере Gravity Field выбирает текст кнопки оформления заказа, а Flutter-приложение рисует её своим виджетом. Вариант назначает сервер; приложение не выбирает A/B локально.

Требуется gravity_sdk 0.24.0 и выполненная инициализация. Общий контракт получения данных и аналитики — в headless-гайде.

Настройка кампании

  1. Создайте кампанию с выдачей Custom JSON/API и selector checkout_button.
  2. Настройте аудиторию и контекст корзины: CART, location app://cart.
  3. Создайте две вариации и распределение трафика для A/B-теста.
  4. Настройте данные так, чтобы в ответе content.variables['checkout_button'] возвращался объект одной из схем ниже.
  5. Настройте tracking показа, видимости и клика; для ручного клика в ответе требуется events с типом click.
  6. Опубликуйте кампанию и проверьте фактический ответ.

Вариация A:

{"variant": "A", "label": "Оформить заказ"}

Вариация B:

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

Это содержимое ключа checkout_button, а не полный ответ SDK. SDK не навязывает формат пользовательских переменных.

Подключение во Flutter

Добавьте visibility_detector: ^0.4.0+2 в pubspec.yaml и выполните flutter pub get. Скопируйте gravity_content.dart и ab_example.dart рядом в lib/.

import 'package:flutter/material.dart';
import 'package:gravity_sdk/gravity_sdk.dart';
import 'package:visibility_detector/visibility_detector.dart';

import 'gravity_content.dart';

class CheckoutVariantButton extends StatefulWidget {
  const CheckoutVariantButton({super.key, required this.onCheckout});
  final VoidCallback onCheckout;

  @override
  State<CheckoutVariantButton> createState() => _CheckoutVariantButtonState();
}

class _CheckoutVariantButtonState extends State<CheckoutVariantButton> {
  ContentExposure? exposure;
  String label = 'Оформить заказ';
  Key visibilityKey = UniqueKey();

  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    try {
      final selected = await loadSelectedContent(
        selector: 'checkout_button',
        pageContext: const PageContext(
          type: ContextType.cart,
          data: [],
          location: 'app://cart',
        ),
      );
      if (!mounted || selected == null) return;
      final config = selected.content.variables.valueOf<Map<String, dynamic>>(
        'checkout_button',
      );
      if (config == null) return;
      final variant = config['variant'];
      final text = config['label'];
      if ((variant != 'A' && variant != 'B') ||
          text is! String ||
          text.trim().isEmpty) {
        return;
      }
      setState(() {
        label = text;
        exposure = ContentExposure(selected);
        visibilityKey = UniqueKey();
      });
    } catch (_) {
      // Кнопка оформления остаётся доступна с исходным текстом.
    }
  }

  @override
  Widget build(BuildContext context) => VisibilityDetector(
    key: visibilityKey,
    onVisibilityChanged: (info) => exposure?.onVisibility(info.visibleFraction),
    child: FilledButton(
      onPressed: () {
        exposure?.click();
        widget.onCheckout();
      },
      child: Text(label),
    ),
  );
}

Подключите к экрану корзины:

import 'ab_example.dart';

// В дереве экрана корзины:
CheckoutVariantButton(
  onCheckout: () {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (_) => const Scaffold(
          body: Center(child: Text('Оформление заказа')),
        ),
      ),
    );
  },
)

В вашем приложении замените демонстрационный маршрут на экран оформления. Отправляйте PurchaseEvent после успешной покупки, а не при нажатии на кнопку. Пример покупки.

Как учитываются показы и клики

До ответа показывается обычная рабочая кнопка. Если пришла поддерживаемая конфигурация, приложение применяет вариант, создаёт ContentExposure и новый ключ detector. Показ фиксируется только после появления выбранной кнопки в UI.

При прокрутке и перестроении один экземпляр ContentExposure не отправляет повторные impression/visible impression. Каждый реальный клик учитывается отдельно. Fallback без принятой конфигурации не считается показом серверного варианта.

onCheckout не блокируется сетевым запросом и работает даже при ошибке или пустом ответе. При смене пользователя пересоздайте виджет с новым Key; не оставляйте выбранный вариант предыдущего аккаунта.

Проверка эксперимента

  • Обе схемы возвращаются корректно, надпись соответствует принятому ответу.
  • Пустой ответ, неверный тип, неизвестный вариант или пустая надпись оставляют исходную кнопку.
  • Уход с экрана до ответа не вызывает обновление размонтированного состояния.
  • Повторное build() не вызывает новую загрузку и не дублирует показ.
  • В отчёте появляются tracking выбранной кампании и бизнес-конверсия после реальной покупки.
  • Между запросами одного пользователя не сбрасываются UID и сессия ради получения другого варианта.

Распределение и атрибуция определяются настройками эксперимента на сервере; SDK не обещает постоянный вариант при произвольной смене идентификации или конфигурации кампании.