# Гайд: A/B-тестирование с Flutter SDK

Это руководство представляет собой пошаговую инструкцию по реализации A/B-тестирования в Flutter-приложении, где варианты интерфейса управляются удаленно с помощью JSON, получаемого от Gravity Field SDK. Такой подход, известный как Server-Driven UI, обеспечивает максимальную гибкость и позволяет запускать тесты без необходимости выпускать новые версии приложения.

Цель руководства: Провести вас через весь процесс: от настройки кампании в интерфейсе Gravity Field до реализации логики в приложении и отслеживания результатов.

Что мы будем делать: В качестве примера мы создадим A/B-тест для экрана оплаты, где будем проверять различные комбинации кнопок оплаты.

  • Вариант А (Контрольный): Стандартная кнопка "Оплатить картой".
  • Вариант Б (Тестовый): Две кнопки: "Оплатить картой" и "Оплатить через СБП".

# Шаг 1: Настройка кампании в Gravity Field

Первый шаг — создать кампанию, которая будет возвращать JSON с описанием нашего UI.

  1. Перейдите в раздел Campaigns → API Campaigns и нажмите Создать кампанию.
  2. Выберите тип кампании Custom JSON. Этот тип позволяет вернуть данные в произвольном JSON-формате.
  3. Задайте API-селектор (например, payment_screen_options). Этот селектор будет использоваться в коде приложения для получения кампании.
  4. Создайте две вариации: "Вариант А" и "Вариант Б".
  5. В каждой вариации определите JSON-объект, который описывает контент.

Вариант А (Контрольный):

{
  "title": "Выберите способ оплаты",
  "buttons": [
    {
      "id": "card",
      "text": "Оплатить картой",
      "style": "primary"
    }
  ]
}

Вариант Б (Тестовый):

{
  "title": "Выберите способ оплаты",
  "buttons": [
    {
      "id": "card",
      "text": "Оплатить картой",
      "style": "secondary"
    },
    {
      "id": "sbp",
      "text": "Оплатить через СБП",
      "style": "primary"
    }
  ]
}
  1. Настройте распределение трафика (например, 50/50) и сохраните кампанию.

💡 Важно: API-селектор — это ключ, по которому ваше приложение будет запрашивать эту конкретную кампанию. Убедитесь, что он уникален и понятен.


# Шаг 2: Получение варианта теста в приложении

Теперь в Flutter-приложении нам нужно запросить данные кампании с помощью SDK. Для этого используется метод GravitySDK.instance.getContentBySelector(...).

Этот метод асинхронный и возвращает Future<ContentResponse>. Ответ может быть успешным, содержать ошибку или быть пустым, если для пользователя нет активной кампании.

import 'package:gravity_sdk/gravity_sdk.dart';

Future<Campaign?> fetchPaymentOptions() async {
  try {
    final response = await GravitySDK.instance.getContentBySelector(
      selector: 'payment_screen_options', // Наш API-селектор
      pageContext: PageContext(
        type: ContextType.cart,
        data: [],
        location: 'app://payment',
      ),
    );

    if (response.data.isNotEmpty) {
      // Кампания найдена, возвращаем ее
      return response.data.first;
    } else {
      // Для пользователя нет активной кампании
      return null;
    }
  } catch (e) {
    // Обработка ошибок (например, нет сети)
    print('Error fetching payment options: $e');
    return null;
  }
}

Из ответа нам важны два элемента:

  1. decisionId: Уникальный идентификатор решения, который понадобится для отслеживания взаимодействий.
  2. payload: Сам JSON-объект, который мы определили в Gravity Field.

# Шаг 3: Рендеринг UI на основе JSON

После получения данных мы можем динамически строить интерфейс. Лучше всего для этого подходит FutureBuilder.

# 3.1. Создание моделей данных (Data Models)

Чтобы работать с JSON было удобно и безопасно, создадим Dart-классы для наших данных.

// Модель для кнопки
class PaymentButtonModel {
  final String id;
  final String text;
  final String style;

  PaymentButtonModel({required this.id, required this.text, required this.style});

  factory PaymentButtonModel.fromJson(Map<String, dynamic> json) {
    return PaymentButtonModel(
      id: json['id'] as String,
      text: json['text'] as String,
      style: json['style'] as String,
    );
  }
}

> 💡 Примечание: Поле для доступа к вашему JSON-объекту называется `.custom`. Это стандартное имя поля в API-ответе Gravity Field для кампаний типа Custom JSON.

// Модель для всего экрана
class PaymentScreenModel {
  final String title;
  final List<PaymentButtonModel> buttons;

  PaymentScreenModel({required this.title, required this.buttons});

  factory PaymentScreenModel.fromJson(Map<String, dynamic> json) {
    var buttonList = json['buttons'] as List;
    List<PaymentButtonModel> buttons =
        buttonList.map((i) => PaymentButtonModel.fromJson(i)).toList();

    return PaymentScreenModel(
      title: json['title'] as String,
      buttons: buttons,
    );
  }
}

# 3.2. Создание виджета с FutureBuilder

Теперь создадим виджет, который будет запрашивать данные и отображать либо состояние загрузки, либо UI на основе полученного JSON, либо UI по умолчанию (fallback).

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

class PaymentScreen extends StatefulWidget {
  @override
  _PaymentScreenState createState() => _PaymentScreenState();
}

class _PaymentScreenState extends State<PaymentScreen> {
  late Future<Campaign?> _campaignFuture;

  @override
  void initState() {
    super.initState();
    _campaignFuture = fetchPaymentOptions();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Оплата')),
      body: FutureBuilder<Campaign?>(
        future: _campaignFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) {
            // Fallback UI: если кампания не пришла или произошла ошибка
            return _buildDefaultUi();
          }

          // Кампания успешно загружена, строим UI на основе JSON
          final campaign = snapshot.data!;
          final customData = campaign.variations.first.payload.data.custom;
          final Map<String, dynamic> contentJson =
              customData is String ? jsonDecode(customData) : customData as Map<String, dynamic>;
          final model = PaymentScreenModel.fromJson(contentJson);
          return _buildDynamicUi(model, campaign);
        },
      ),
    );
  }

  // UI по умолчанию
  Widget _buildDefaultUi() {
    return Center(
      child: ElevatedButton(
        onPressed: () { /* ... */ },
        child: Text('Оплатить картой'),
      ),
    );
  }

  // Динамический UI
  Widget _buildDynamicUi(PaymentScreenModel model, Campaign campaign) {
    // Отправляем событие показа (impression)
    // Лучше использовать VisibilityDetector для более точного отслеживания
    final impressionEngagement = ContentImpressionEngagement(
      decisionId: campaign.variations.first.decisionId,
      content: campaign.variations.first.payload,
      campaign: campaign,
    );
    GravitySDK.instance.sendContentEngagement(impressionEngagement);

    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(
            model.title,
            style: Theme.of(context).textTheme.headlineSmall,
            textAlign: TextAlign.center,
          ),
          SizedBox(height: 24),
          ...model.buttons.map((buttonModel) {
            return Padding(
              padding: const EdgeInsets.symmetric(vertical: 4.0),
              child: ElevatedButton(
                onPressed: () => _onPaymentButtonPressed(buttonModel, campaign),
                style: buttonModel.style == 'primary' ? null : ElevatedButton.styleFrom(backgroundColor: Colors.grey),
                child: Text(buttonModel.text),
              ),
            );
          }).toList(),
        ],
      ),
    );
  }

  void _onPaymentButtonPressed(PaymentButtonModel button, Campaign campaign) {
    print('Button "${button.text}" pressed!');

    // Отправляем событие клика в Gravity Field
    final engagement = ContentClickEngagement(
      decisionId: campaign.variations.first.decisionId,
      content: campaign.variations.first.payload,
      campaign: campaign,
    );
    GravitySDK.instance.sendContentEngagement(engagement);

    // Здесь ваша логика обработки платежа...
  }
}

# Шаг 4: Отслеживание взаимодействия (Engagement)

Это критически важный шаг. Чтобы A/B-тест собрал статистику, мы должны сообщить Gravity Field, когда пользователь взаимодействует с нашим кастомным UI. Для этого используется метод GravitySDK.instance.sendContentEngagement(...).

Дополним наш метод _onPaymentButtonPressed:

void _onPaymentButtonPressed(PaymentButtonModel button, Campaign campaign) {
  print('Button "${button.text}" pressed!');

  // Отправляем событие клика в Gravity Field
  final engagement = ContentClickEngagement(
    decisionId: campaign.variations.first.decisionId,
    content: campaign.variations.first.payload,
    campaign: campaign,
  );
  GravitySDK.instance.sendContentEngagement(engagement);

  // Здесь ваша логика обработки платежа...
}

Теперь при каждом нажатии на кнопку SDK будет отправлять событие, которое будет учтено в отчете по A/B-тесту.


# Шаг 5: Анализ результатов

После того как кампания запущена и собрала достаточно данных, вы можете проанализировать результаты в интерфейсе Gravity Field.

Перейдите в отчет по вашей кампании и изучите метрики для каждой вариации, чтобы определить, какой из вариантов UI оказался более эффективным.

Подробнее об анализе отчетов по кампаниям.


# FAQ

Q: Что делать, если SDK не вернул JSON?

A: Всегда предусматривайте "запасной" (fallback) UI. Ваш FutureBuilder должен обрабатывать null или ошибочные состояния и показывать интерфейс по умолчанию. Это гарантирует, что пользовательский опыт не пострадает, если кампания неактивна или нет сети.

Q: Как обрабатывать состояние загрузки?

A: FutureBuilder отлично справляется с этим. Пока snapshot.connectionState равен ConnectionState.waiting, показывайте индикатор загрузки, например, CircularProgressIndicator.

Q: Можно ли кешировать ответ от SDK?

A: Не рекомендуется. SDK управляет логикой показа (например, частотой), и кеширование может нарушить ее. Каждый раз запрашивайте данные заново при входе на экран.

Q: В response.data.custom приходит строка вместо Map. Что делать?

A: Иногда JSON может приходить в виде строки. В этом случае его нужно распарсить вручную с помощью jsonDecode(). В полном примере кода показано, как обработать оба случая.