#
SDK Gravity Field (Android)
Gravity Field SDK - это клиентская библиотека для интеграции Android-приложений с платформой персонализации и A/B-тестирования Gravity Field.
SDK работает как "тонкий клиент": логика выбора кампаний и контента остается на стороне Gravity Field, а приложение:
- передает контекст текущего экрана;
- отправляет события пользователя;
- получает контент в виде встроенных UI-форматов или JSON;
- отображает inline- и in-app-кампании;
- отправляет события взаимодействия для аналитики.
Эта документация предназначена для мобильных разработчиков и инженеров, которые интегрируют Android SDK в приложение.
Репозиторий Gravity Field SDK для Android
#
Добавление библиотеки в проект
Скопируйте модуль
gravity_sdkиз репозитория SDK в корень Android-проекта.Подключите модуль в
settings.gradle.kts:
include(":gravity_sdk")
- Добавьте зависимость в
build.gradle.ktsвашегоapp-модуля:
dependencies {
implementation(project(":gravity_sdk"))
}
#
Быстрый старт
Этот раздел показывает минимальную интеграцию: инициализация SDK, идентификация пользователя, отправка контекста, событий и отображение inline-кампаний.
#
Шаг 1: Инициализация SDK
Инициализируйте SDK один раз при старте приложения. Обычно это делается в классе Application.
import android.app.Application
import android.content.Intent
import android.net.Uri
import ai.gravityfield.gravity_sdk.GravitySDK
import ai.gravityfield.gravity_sdk.models.FollowDeeplinkEvent
import ai.gravityfield.gravity_sdk.models.FollowUrlEvent
import ai.gravityfield.gravity_sdk.models.RequestPushEvent
import ai.gravityfield.gravity_sdk.models.TrackingEvent
import ai.gravityfield.gravity_sdk.models.UISettings
import ai.gravityfield.gravity_sdk.utils.LogLevel
class App : Application() {
override fun onCreate() {
super.onCreate()
GravitySDK.initialize(
context = this,
apiKey = "YOUR_API_KEY",
section = "YOUR_SECTION_ID",
gravityEventCallback = ::handleGravityEvent,
uiSettings = UISettings(fontResId = R.font.your_custom_font),
logLevel = LogLevel.DEBUG,
)
}
private fun handleGravityEvent(event: TrackingEvent) {
when (event) {
is FollowUrlEvent -> {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.url))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
is FollowDeeplinkEvent -> {
// Реализуйте навигацию по deeplink
}
is RequestPushEvent -> {
// При необходимости добавьте аналитику или дополнительную логику
}
else -> Unit
}
}
}
#
Почему важен gravityEventCallback?
SDK не выполняет навигацию по URL и deeplink самостоятельно. Когда пользователь нажимает на кнопку, ссылку или действие внутри кампании, SDK передает событие в gravityEventCallback, а приложение должно выполнить нужное действие.
Без gravityEventCallback интерактивные элементы кампаний не смогут корректно открыть URL и deeplink. Для RequestPushEvent SDK сам открывает системный экран настроек уведомлений и дополнительно отправляет callback.
Подробнее см. раздел
#
Шаг 2: Идентификация пользователя
После авторизации пользователя отправьте LoginEvent, чтобы связать анонимную активность с авторизованным профилем.
fun onUserLoggedIn(context: Context, rawPhoneNumber: String) {
val normalizedPhone = normalizePhone(rawPhoneNumber)
val hashedPhone = sha256Hex(normalizedPhone)
val loginEvent = LoginEvent(
cuid = hashedPhone,
cuidType = "phone_hash",
)
GravitySDK.instance.triggerEvent(
events = listOf(loginEvent),
pageContext = PageContext(
type = ContextType.OTHER,
data = emptyList(),
location = "app://login",
),
activityContext = context,
)
}
Отправка LoginEvent после успешного входа - рекомендуемый сценарий идентификации для Android SDK.
#
Шаг 3: Отслеживание просмотров экранов
Используйте trackView(...), чтобы передать SDK факт просмотра экрана. Этот вызов нужен и для трекинга пользовательского поведения, и для сценариев, в которых показ кампании зависит от контекста страницы.
fun trackHomepageView(context: Context) {
GravitySDK.instance.trackView(
pageContext = PageContext(
type = ContextType.HOMEPAGE,
data = emptyList(),
location = "app://homepage",
),
activityContext = context,
)
}
Важно: если
PageContextсодержит значения из товарного фида, передавайте их без изменений относительно фида. Это относится к SKU, иерархии категорий,lngи другим feed-derived значениям. Регистр является частью значения: не приводите его к upper/lower case.
Если для этого контекста настроена in-app-кампания, SDK может загрузить и показать ее автоматически.
#
Почему trackView(...) принимает Context?
Android SDK использует activityContext для показа in-app-контента. Если SDK не сможет определить Activity из переданного контекста, in-app-кампания не будет показана.
Для экранов Activity и Fragment рекомендуется передавать контекст текущего экрана, а не applicationContext.
#
Шаг 4: Отслеживание событий
Используйте triggerEvent(...), чтобы передать в SDK действие пользователя. Этот вызов нужен и для трекинга событий в аналитике, и для сценариев, в которых событие может активировать кампанию.
fun trackAddToCart(context: Context, productId: String) {
val event = AddToCartEvent(
value = 99.99,
productId = productId,
quantity = 1,
currency = "RUB",
)
GravitySDK.instance.triggerEvent(
events = listOf(event),
pageContext = PageContext(
type = ContextType.PRODUCT,
data = listOf(productId),
location = "app://product/$productId",
),
activityContext = context,
)
}
Важно: если в событии или
PageContextпередаются значения, которые должны матчиться с товарным фидом, используйте их в точности как в фиде. Это относится кproductId,cart[*].productId, SKU вdata, категориям,lngи другим feed-derived значениям. Регистр должен совпадать с фидом.
#
Шаг 5: Отображение inline-кампаний
Для отображения inline-кампаний используйте GravityInlineView, GravityInlineCompose или GravityInlineListView.
Пример для Jetpack Compose:
GravityInlineCompose(
modifier = Modifier.height(250.dp),
selector = "homepage-recommendations",
pageContext = PageContext(
type = ContextType.HOMEPAGE,
data = emptyList(),
location = "app://homepage",
),
loader = { CircularProgressIndicator() },
)
Пример для XML:
<ai.gravityfield.gravity_sdk.ui.GravityInlineView
android:id="@+id/recommendationsView"
android:layout_width="match_parent"
android:layout_height="250dp"
app:selector="homepage-recommendations" />
val inlineView = findViewById<GravityInlineView>(R.id.recommendationsView)
inlineView.init(
PageContext(
type = ContextType.HOMEPAGE,
data = emptyList(),
location = "app://homepage",
)
)
#
Инициализация и конфигурация
#
initialize()
Основной метод настройки SDK. Вызывается один раз при старте приложения.
fun initialize(
context: Context,
apiKey: String,
section: String,
gravityEventCallback: GravityEventCallback,
productViewBuilder: ProductViewBuilder? = null,
productFilter: ProductFilter? = null,
uiSettings: UISettings? = null,
logLevel: LogLevel = LogLevel.NONE,
)
Если GravitySDK.instance вызывается до initialize(...), SDK выбросит исключение GravitySDK has not been initialized.
#
Логирование SDK
Android SDK поддерживает параметр logLevel в initialize(...). Он управляет только внутренними логами SDK и не влияет на gravityEventCallback, отправку событий или логи приложения.
Логи пишутся через Android Log с тегом GravitySDK.
LogLevel.DEBUGвключает HTTP debug-логи SDK и сообщения об ошибках.LogLevel.INFOдоступен в публичном API, но в текущей реализации SDK отдельныхinfo-логов нет, поэтому практический эффект сейчас такой же, как уERROR.LogLevel.ERRORоставляет только ошибки SDK.LogLevel.NONEполностью отключает внутренний вывод SDK.
Для отладки удобно явно включать LogLevel.DEBUG, а в production оставлять значение по умолчанию LogLevel.NONE.
#
setOptions()
Метод задает глобальные настройки, которые применяются ко всем последующим вызовам trackView(...), triggerEvent(...) и getContentBySelector(...).
fun setOptions(
options: Options?,
contentSettings: ContentSettings?,
proxyUrl: String?,
)
Пример:
GravitySDK.instance.setOptions(
options = Options(
isReturnUserInfo = true,
isReturnAnalyticsMetadata = true,
),
contentSettings = ContentSettings(
skusOnly = false,
fields = listOf("name", "price", "imageUrl"),
),
proxyUrl = "https://my-proxy.example.com",
)
#
Что настраивается через Options
data class Options(
val isReturnCounter: Boolean = false,
val isReturnUserInfo: Boolean = false,
val isReturnAnalyticsMetadata: Boolean = false,
val isImplicitPageview: Boolean = false,
val isImplicitImpression: Boolean = true,
)
#
Что настраивается через ContentSettings
data class ContentSettings(
val skusOnly: Boolean = false,
val fields: List<String>? = null,
)
Практически это означает следующее:
- если
skusOnly = true, приложение получает облегченный ответ и само решает, как догружать данные о товаре; - если
fieldsзадан, SDK запрашивает только указанные товарные поля; - если
setOptions(...)не вызывается, используются стандартные значенияOptions()иContentSettings().
proxyUrl позволяет отправлять запросы SDK через ваш прокси. Если значение не задано, используется стандартный endpoint SDK.
#
Идентификация пользователя
Android SDK поддерживает два режима идентификации: автоматический и управляемый приложением.
#
Стандарт CUID на основе телефона (phone_hash)
Рекомендуемый идентификатор пользователя для LoginEvent и SignUpEvent - SHA-256 хеш нормализованного мобильного телефона.
Правила формирования:
- перед хешированием номер очищается от всех символов, кроме цифр;
- для РФ и КЗ номер приводится к формату
7XXXXXXXXXX; - для других стран используется международный формат без
+и разделителей; - хеш считается по строке в UTF-8;
- результат передается в lowercase hex;
- в
cuidTypeпередается строка"phone_hash".
Один и тот же идентификатор рекомендуется использовать во всех каналах: web, server-side API, offline import и mobile SDK.
#
Автоматическая идентификация
Это поведение SDK по умолчанию. При первом запросе сервер возвращает uid и ses, а SDK сохраняет их и переиспользует в следующих запросах.
Это означает:
- неавторизованный пользователь идентифицируется SDK автоматически;
uidиsesне нужно передавать вручную, если вы используете SDK-managed режим;- после логина рекомендуется отправить
LoginEvent, чтобы связать анонимный и авторизованный профили.
#
Ручная идентификация
Если приложение уже управляет идентификатором пользователя и сессии, можно задать их явно.
#
setUser()
fun setUser(userId: String, sessionId: String)
Пример:
GravitySDK.instance.setUser(
userId = "user-from-my-system-42",
sessionId = "session-from-my-system-xyz",
)
После этого SDK будет использовать ваш userId и sessionId во всех последующих запросах.
#
PageContext
PageContext описывает, где находится пользователь в приложении и в каком бизнес-контексте выполняется запрос.
Этот объект используется во всех основных интеграционных вызовах:
для передачи просмотров экранов;trackView(...) для передачи пользовательских действий;triggerEvent(...) для ручного получения контента.getContentBySelector(...)
Корректное заполнение PageContext влияет и на аналитику, и на подбор контента, и на активацию кампаний.
#
Структура PageContext
data class PageContext(
val type: ContextType,
val data: List<String>,
val location: String,
val lng: String? = null,
val pageNumber: Int? = null,
val referrer: String? = null,
val utm: Map<String, String>? = null,
val attributes: Map<String, String> = emptyMap(),
)
Бизнес-логика заполнения PageContext соответствует Page context.
Рекомендуемая схема:
HOMEPAGE:data = emptyList().PRODUCT: вdataпередается SKU товара ровно как в товарном фиде, без нормализации и с тем же регистром.CART: вdataпередаются SKU всех товаров, находящихся в корзине, ровно как в товарном фиде, без нормализации и с тем же регистром.CATEGORY: вdataпередается полная иерархия категорий от самой широкой до самой узкой ровно как в товарном фиде, без нормализации и с тем же регистром.SEARCH: вdataпередается поисковый запрос одной строкой. Для пустого поиска передается пустой список.OTHER: используется только для экранов, которые не подходят под остальные типы, например для статических страниц или категорий, не включенных в товарный фид.
Для любых значений в PageContext, которые должны матчиться с товарным фидом, действует единое правило: передавайте их идентично фиду. Нельзя менять написание, переименовывать значения или приводить их к upper/lower case.
lng используется в первую очередь для мультирегиональности. Это позволяет отдавать пользователю корректные региональные данные по товарам: цены, доступность и остатки.
Например, если пользователь находится в Новосибирске, значение lng может использоваться для того, чтобы в рекомендациях участвовали только товары, реально доступные для Новосибирска.
Важно:
- значения
lngв контексте и в товарном фиде должны совпадать полностью, включая регистр; lng, как и любые другие feed-derived значения, нужно передавать без преобразования к upper/lower case;lngнужно передавать только если проект использует региональные варианты данных в фиде.
utm и attributes технически не обязательны. Передавайте их, если эти поля используются в правилах таргетинга или аналитике вашего проекта.
Дополнительно:
- SDK автоматически дополняет
attributesслужебными значениямиapp_version,sdk_versionиapp_platform; - блок
deviceформируется самим SDK, вручную его заполнять не нужно.
См. также:
trackView(...)triggerEvent(...)getContentBySelector(...)
#
Отслеживание просмотров экранов
#
trackView(...)
Метод отправляет событие просмотра экрана. В Android SDK этот вызов асинхронный, но сам метод не является suspend.
fun trackView(
pageContext: PageContext,
activityContext: Context,
)
Для trackView(...) требуется корректно заполненный PageContext
Пример:
GravitySDK.instance.trackView(
pageContext = PageContext(
type = ContextType.PRODUCT,
data = listOf("product-sku-123"),
location = "app://product/123",
),
activityContext = this,
)
trackView(...) выполняет две задачи:
- передает в Gravity Field информацию о просмотре экрана;
- может инициировать загрузку и показ кампании, если для этого контекста настроен соответствующий сценарий.
Если для переданного контекста настроена in-app-кампания, SDK может сразу после запроса загрузить и показать ее.
#
Трекинг событий
События описывают действия пользователя: покупку, добавление в корзину, авторизацию и другие сценарии, которые должны попадать в аналитику или запускать кампании.
Бизнес-логика заполнения событий соответствует Настройке передачи событий, а Android SDK предоставляет типы и методы для их отправки.
Для e-commerce сценариев события PurchaseEvent и AddToCartEvent обязательны. Дополнительно рекомендуется внедрять остальные релевантные преднастроенные события, которые поддерживаются вашим сценарием интеграции.
Для всех полей событий, которые должны матчиться с товарным фидом, действует то же правило, что и для PageContext: передавайте значения идентично фиду. Это относится к productId, cart[*].productId и другим feed-derived значениям. Регистр является частью значения.
#
triggerEvent(...)
В Android SDK этот вызов асинхронный, но сам метод не является suspend.
fun triggerEvent(
events: List<TriggerEvent>,
pageContext: PageContext,
activityContext: Context,
)
Для triggerEvent(...) требуется корректно заполненный PageContext
triggerEvent(...) выполняет две задачи:
- передает в Gravity Field пользовательские действия для аналитики, сегментации и построения профиля;
- может инициировать загрузку и показ кампании, если событие используется как триггер в настройках платформы.
Пример:
GravitySDK.instance.triggerEvent(
events = listOf(
AddToCartEvent(
value = 1500.0,
productId = "sku-abc-1",
quantity = 1,
currency = "RUB",
)
),
pageContext = PageContext(
type = ContextType.PRODUCT,
data = listOf("sku-abc-1"),
location = "app://product/sku-abc-1",
),
activityContext = this,
)
#
Покупка (PurchaseEvent)
Отправляется после успешного завершения заказа.
val purchaseEvent = PurchaseEvent(
uniqueTransactionId = "ORDER-12345",
value = 2550.75,
currency = "RUB",
cart = listOf(
CartItem(productId = "sku-123", quantity = 1, itemPrice = 100.50),
CartItem(productId = "sku-456", quantity = 2, itemPrice = 1225.125),
),
)
Правила заполнения:
uniqueTransactionIdдолжен быть уникальным для каждой покупки.value- полная сумма заказа.currencyопциональна, но обязательна для мультивалютных проектов.cartсодержит фактический состав заказа.- каждый
cart[*].productIdдолжен совпадать со SKU в товарном фиде полностью, включая регистр. - товары в
cartрекомендуется передавать в порядке добавления: от самых старых к самым новым. - каждый
CartItem.itemPrice- стоимость одной единицы товара после применения скидок.
#
Добавление в корзину (AddToCartEvent)
Отправляйте событие в момент фактического добавления товара в корзину.
val addToCartEvent = AddToCartEvent(
value = 1500.0,
productId = "sku-abc-1",
quantity = 1,
currency = "RUB",
)
Правила заполнения:
value- сумма, добавляемая в корзину этим действием. Если добавляется несколько единиц одного товара, передаетсяquantity * itemPrice.quantity- количество единиц, добавленных именно этим действием, а не итоговое количество товара в корзине.productIdдолжен совпадать со SKU в товарном фиде полностью, включая регистр.currencyопциональна, но обязательна для мультивалютных проектов.cart, если передается, должен содержать актуальное состояние корзины, включая только что добавленный товар. Всеcart[*].productIdдолжны совпадать со SKU в товарном фиде полностью, включая регистр. Товары рекомендуется передавать в порядке добавления: от самых старых к самым новым.
#
Удаление из корзины (RemoveFromCartEvent) и синхронизация корзины (SyncCartEvent)
RemoveFromCartEventотправляйте в момент удаления товара из корзины или уменьшения количества.valueвRemoveFromCartEvent- сумма удаляемых единиц товара.quantityвRemoveFromCartEvent- количество единиц, удаленных этим действием.productIdвRemoveFromCartEventдолжен совпадать со SKU в товарном фиде полностью, включая регистр.SyncCartEventиспользуйте, когда нужно передать актуальное состояние корзины целиком: например, при пакетном изменении корзины, очистке корзины, объединении корзин после логина или серверном обновлении состава корзины.- в Android SDK
SyncCartEventтакже требуетvalue, поэтому передавайте общую стоимость актуального состава корзины. cartвSyncCartEventдолжен содержать полное текущее состояние корзины. Всеcart[*].productIdдолжны совпадать со SKU в товарном фиде полностью, включая регистр.- товары в
cartдляRemoveFromCartEventиSyncCartEventрекомендуется передавать в порядке добавления: от самых старых к самым новым. currencyдляRemoveFromCartEventиSyncCartEventопциональна, но обязательна для мультивалютных проектов.
#
Вход в систему (LoginEvent)
val loginEvent = LoginEvent(
cuid = sha256Hex(normalizePhone(rawPhoneNumber)),
cuidType = "phone_hash",
)
Поле hashedEmail опционально. Если email не является обязательным в вашем процессе регистрации или логина, его можно не передавать.
Для LoginEvent рекомендуется использовать единый идентификатор phone_hash. Если проект использует другой cuid, он должен быть согласован и одинаково использоваться во всех каналах интеграции.
#
Добавление в избранное (AddToWishlistEvent)
val addToWishlistEvent = AddToWishlistEvent(
value = 1500.0,
productId = "sku-abc-1",
)
productId должен совпадать со SKU в товарном фиде полностью, включая регистр.
#
Кастомное событие (CustomEvent)
val customEvent = CustomEvent(
type = "survey-completed-v1",
name = "Survey completed",
customProps = mapOf(
"surveyId" to "summer-2025-feedback",
"rating" to "5",
),
)
#
Передача статуса Push-уведомлений
Если таргетинг кампаний зависит от статуса push-разрешения, передавайте его в SDK.
#
setNotificationPermissionStatus()
fun setNotificationPermissionStatus(status: NotificationPermissionStatus)
Допустимые значения:
NotificationPermissionStatus.GRANTEDNotificationPermissionStatus.DENIEDNotificationPermissionStatus.UNKNOWN
Пример:
import androidx.core.app.NotificationManagerCompat
val areNotificationsEnabled =
NotificationManagerCompat.from(context).areNotificationsEnabled()
val status = if (areNotificationsEnabled) {
NotificationPermissionStatus.GRANTED
} else {
NotificationPermissionStatus.DENIED
}
GravitySDK.instance.setNotificationPermissionStatus(status)
SDK добавляет это значение в device.permission последующих запросов. Для базовой интеграции этот шаг не обязателен, но он необходим, если push-статус участвует в таргетинге.
#
Работа с контентом
Android SDK поддерживает три основных режима работы с контентом:
- in-app-кампании, которые SDK показывает самостоятельно;
- inline-кампании, которые встраиваются в экран приложения;
- JSON-кампании для полностью ручного рендеринга.
#
In-App кампании
SDK автоматически отображает in-app-кампании, если они приходят в ответ на trackView(...) или triggerEvent(...).
Приложение отвечает за:
- корректный
PageContext; - корректный
activityContext; - обработку действий пользователя через
gravityEventCallback.
#
GravityInlineView
Используйте GravityInlineView, если экран построен на XML.
<ai.gravityfield.gravity_sdk.ui.GravityInlineView
android:id="@+id/recommendationsView"
android:layout_width="match_parent"
android:layout_height="250dp"
app:selector="homepage-recs"
app:color="#FFF1F1F1"
app:cornerRadius="20dp" />
После инфлейта обязательно передайте PageContext:
val inlineView = findViewById<GravityInlineView>(R.id.recommendationsView)
inlineView.init(
PageContext(
type = ContextType.HOMEPAGE,
data = emptyList(),
location = "app://homepage",
)
)
Поддерживаемые XML-атрибуты:
#
GravityInlineCompose
Используйте GravityInlineCompose, если экран написан на Jetpack Compose.
GravityInlineCompose(
modifier = Modifier
.fillMaxWidth()
.height(250.dp),
selector = "homepage-recs",
pageContext = PageContext(
type = ContextType.HOMEPAGE,
data = emptyList(),
location = "app://homepage",
),
loader = { CircularProgressIndicator() },
)
loader - это composable для состояния загрузки. Если placeholder не нужен, можно передать loader = null.
#
GravityInlineListView
GravityInlineListView используется для отображения нескольких inline-элементов из одной группы.
<ai.gravityfield.gravity_sdk.ui.GravityInlineListView
android:id="@+id/inlineListView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:groupSelector="homepage-group" />
val inlineListView = findViewById<GravityInlineListView>(R.id.inlineListView)
inlineListView.init(
PageContext(
type = ContextType.HOMEPAGE,
data = emptyList(),
location = "app://homepage",
)
)
Этот компонент полезен для сценариев, где несколько блоков должны быть загружены одной группой кампаний.
#
ProductViewBuilder и кастомный рендеринг товаров
Если нужно полностью контролировать отображение карточек товара, реализуйте ProductViewBuilder или LegacyProductViewBuilder.
Важно:
- SDK автоматически отслеживает показ товара;
- клик по товару в кастомной карточке нужно отправлять вручную через
sendProductEngagement(...).
Пример для Jetpack Compose:
class MyComposeProductViewBuilder : ProductViewBuilder {
@Composable
override fun Build(slot: Slot, content: CampaignContent, campaign: Campaign) {
Box(
modifier = Modifier.clickable {
GravitySDK.instance.sendProductEngagement(
ProductClickEngagement(slot, content, campaign)
)
// Откройте экран товара
}
) {
Text(text = slot.item["name"] as? String ?: "Unknown Product")
}
}
}
#
JSON-кампании
Используйте JSON-режим, если приложение должно само интерпретировать ответ и строить UI без встроенных компонентов SDK.
#
getContentBySelector(...)
suspend fun getContentBySelector(
selector: String,
pageContext: PageContext,
): ContentResponse
Для getContentBySelector(...) требуется корректно заполненный PageContext
Пример:
lifecycleScope.launch {
val response = GravitySDK.instance.getContentBySelector(
selector = "homepage-banner-json",
pageContext = PageContext(
type = ContextType.HOMEPAGE,
data = emptyList(),
location = "app://home",
)
)
if (response.data.isNotEmpty()) {
val campaign = response.data.first()
val variation = campaign.payload.firstOrNull()
val content = variation?.contents?.firstOrNull()
// Отрисуйте content в своем UI
}
}
В Android SDK это публичный метод для manual rendering. Внутренние методы getContentByCampaignId(...) и getContentByGroupSelector(...) используются самим SDK и не являются частью публичного интеграционного API.
#
Какие параметры нужны для получения контента
Для вызова getContentBySelector(...) обязательны:
selector; ;pageContext.type ;pageContext.data .pageContext.location
Опциональны:
;lngpageNumber;referrer;utm;attributes.
skusOnly и fields не передаются в метод напрямую. Они задаются один раз через setOptions(...) в ContentSettings и затем влияют на все следующие вызовы choose.
#
Как устроен ответ ContentResponse
Упрощенно структура ответа выглядит так:
ContentResponse
-> data: List<Campaign>
-> payload: List<CampaignVariation>
-> contents: List<CampaignContent>
-> products?.slots: List<Slot>
Это важно для manual rendering и engagement:
campaignберите изresponse.data;contentберите изcampaign.payload[*].contents[*];slotберите изcontent.products?.slots[*].
decisionId присутствует в CampaignVariation, но публичные методы sendContentEngagement(...) и sendProductEngagement(...) не требуют передавать его отдельным параметром.
#
Пример: server-driven recommendation widget
Этот сценарий полезен, когда приложению нужен полный контроль над native UI, но структура recommendation widget, заголовок, подборка товаров и layout по-прежнему должны приходить с backend. На Android это альтернатива GravityInlineView и GravityInlineCompose, а не их замена на уровне SDK API.
Разделение ответственности в этом flow:
- клиент вызывает
getContentBySelector(...)с нужнымиselectorиPageContext; - backend возвращает готовую конфигурацию виджета;
- клиент не выбирает сам заголовок, не перестраивает порядок товаров и не решает локально
rowилиgrid; - клиент интерпретирует ответ SDK, строит native UI, обрабатывает
onClickи вручную отправляет engagement.
#
Что именно управляется с сервера
- заголовок и дополнительные текстовые блоки приходят как
Element(type = text)внутриcontent.variables.elements; - изображения, кнопки, spacer и другие UI-элементы тоже приходят в
elements; - подборка товаров, стратегия, fallback и ранжирование приходят в
content.products; - тип товарного блока задается через
products-containerиstyle.productContainerType; - внешний контейнер виджета приходит в
content.variables.frameUI?.container; - визуальная кастомизация приходит через
frameUI.container.styleиelements[*].style.
#
Как читать ответ SDK
products.strategyId, products.name и products.fallback показывают, какая рекомендательная стратегия была выбрана backend и был ли использован fallback.
#
Что клиент не должен делать
- не хардкодить заголовок, если он уже приходит в
elements; - не перестраивать порядок товаров, если backend уже вернул готовое ранжирование;
- не игнорировать
productContainerType, если backend переключил layout сrowнаgridили обратно; - не передавать вручную
decisionIdили engagement URL: используйтеcampaign,contentиslotиз ответа SDK.
#
Полный пример на Jetpack Compose
Ниже пример server-driven виджета. Он:
- загружает
CampaignContentчерезgetContentBySelector(...); - рендерит
elementsв том порядке, который вернул backend; - берет товары из
content.products?.slots; - выбирает
rowилиgridпоproducts-container.style.productContainerType; - вручную отправляет
ContentImpressionEngagement,ContentVisibleImpressionEngagement,ProductVisibleImpressionEngagementиProductClickEngagement.
Для краткости RemoteImage ниже — это placeholder. Подмените его на ваш image loader, например Coil или Glide.
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import ai.gravityfield.gravity_sdk.GravitySDK
import ai.gravityfield.gravity_sdk.models.Action
import ai.gravityfield.gravity_sdk.models.CampaignContent
import ai.gravityfield.gravity_sdk.models.ContentImpressionEngagement
import ai.gravityfield.gravity_sdk.models.ContentVisibleImpressionEngagement
import ai.gravityfield.gravity_sdk.models.Element
import ai.gravityfield.gravity_sdk.models.ElementType
import ai.gravityfield.gravity_sdk.models.GravityContentAlignment
import ai.gravityfield.gravity_sdk.models.GravityMargin
import ai.gravityfield.gravity_sdk.models.GravityPadding
import ai.gravityfield.gravity_sdk.models.OnClickModel
import ai.gravityfield.gravity_sdk.models.PageContext
import ai.gravityfield.gravity_sdk.models.ProductClickEngagement
import ai.gravityfield.gravity_sdk.models.ProductContainerType
import ai.gravityfield.gravity_sdk.models.ProductVisibleImpressionEngagement
import ai.gravityfield.gravity_sdk.models.Slot
import ai.gravityfield.gravity_sdk.models.Style
import ai.gravityfield.gravity_sdk.network.Campaign
import kotlinx.coroutines.delay
@Composable
fun ServerDrivenRecommendationWidget(
selector: String,
pageContext: PageContext,
onOpenUrl: (String) -> Unit,
onOpenDeeplink: (String) -> Unit,
onOpenProduct: (Map<String, Any?>) -> Unit,
) {
var campaign by remember { mutableStateOf<Campaign?>(null) }
var content by remember { mutableStateOf<CampaignContent?>(null) }
var isLoading by remember { mutableStateOf(true) }
var hasSentContentImpression by remember { mutableStateOf(false) }
var hasSentContentVisibleImpression by remember { mutableStateOf(false) }
LaunchedEffect(selector, pageContext) {
isLoading = true
val response = runCatching {
GravitySDK.instance.getContentBySelector(
selector = selector,
pageContext = pageContext,
)
}.getOrNull()
campaign = response?.data?.firstOrNull()
content = campaign?.payload?.firstOrNull()?.contents?.firstOrNull()
hasSentContentImpression = false
hasSentContentVisibleImpression = false
isLoading = false
}
if (isLoading) {
CircularProgressIndicator()
return
}
val resolvedCampaign = campaign ?: return
val resolvedContent = content ?: return
val containerStyle = resolvedContent.variables.frameUI?.container?.style ?: Style.empty
val containerAlignment = containerStyle.contentAlignment?.toHorizontalAlignment()
?: Alignment.Start
LaunchedEffect(resolvedContent.contentId) {
if (!hasSentContentImpression) {
hasSentContentImpression = true
GravitySDK.instance.sendContentEngagement(
ContentImpressionEngagement(resolvedContent, resolvedCampaign)
)
}
}
VisibleImpressionBox(
modifier = Modifier.fillMaxWidth(),
onVisible = {
if (!hasSentContentVisibleImpression) {
hasSentContentVisibleImpression = true
GravitySDK.instance.sendContentEngagement(
ContentVisibleImpressionEngagement(resolvedContent, resolvedCampaign)
)
}
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.applyGravitySize(containerStyle)
.clip(RoundedCornerShape((containerStyle.cornerRadius ?: 0.0).dp))
.background(
color = containerStyle.backgroundColor ?: Color.Transparent,
shape = RoundedCornerShape((containerStyle.cornerRadius ?: 0.0).dp)
)
.padding(containerStyle.padding.toPaddingValues())
.padding(containerStyle.margin.toPaddingValues()),
horizontalAlignment = containerAlignment,
) {
resolvedContent.variables.elements.orEmpty().forEach { element ->
RenderElement(
element = element,
content = resolvedContent,
campaign = resolvedCampaign,
onOpenUrl = onOpenUrl,
onOpenDeeplink = onOpenDeeplink,
onOpenProduct = onOpenProduct,
)
}
}
}
}
@Composable
private fun RenderElement(
element: Element,
content: CampaignContent,
campaign: Campaign,
onOpenUrl: (String) -> Unit,
onOpenDeeplink: (String) -> Unit,
onOpenProduct: (Map<String, Any?>) -> Unit,
) {
val context = LocalContext.current
val style = element.style
when (element.type) {
ElementType.TEXT -> {
Text(
text = element.text.orEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(style.margin.toPaddingValues())
.then(
if (element.onClick != null) {
Modifier.clickable {
handleOnClick(
context = context,
onClick = element.onClick,
onOpenUrl = onOpenUrl,
onOpenDeeplink = onOpenDeeplink,
)
}
} else {
Modifier
}
),
color = style.textColor ?: Color.Unspecified,
fontSize = style.fontSize ?: TextUnit.Unspecified,
fontWeight = style.fontWeight ?: FontWeight.Normal,
textAlign = style.contentAlignment.toTextAlign(),
)
}
ElementType.IMAGE -> {
val src = element.src ?: return
RemoteImage(
url = src,
modifier = Modifier
.fillMaxWidth()
.height((style.size?.height ?: 120.0).dp)
.padding(style.margin.toPaddingValues())
.then(
if (element.onClick != null) {
Modifier.clickable {
handleOnClick(
context = context,
onClick = element.onClick,
onOpenUrl = onOpenUrl,
onOpenDeeplink = onOpenDeeplink,
)
}
} else {
Modifier
}
)
)
}
ElementType.BUTTON -> {
Button(
onClick = {
handleOnClick(
context = context,
onClick = element.onClick,
onOpenUrl = onOpenUrl,
onOpenDeeplink = onOpenDeeplink,
)
},
modifier = Modifier
.fillMaxWidth()
.padding(style.margin.toPaddingValues()),
colors = ButtonDefaults.buttonColors(
containerColor = style.backgroundColor ?: Color(0xFF111827),
contentColor = style.textColor ?: Color.White,
)
) {
Text(
text = element.text.orEmpty(),
fontSize = style.fontSize ?: TextUnit.Unspecified,
fontWeight = style.fontWeight ?: FontWeight.Normal,
)
}
}
ElementType.SPACER -> {
if (style.weight != null) {
Spacer(modifier = Modifier.weight(style.weight))
} else {
Spacer(modifier = Modifier.height((style.size?.height ?: 12.0).dp))
}
}
ElementType.PRODUCTS_CONTAINER -> {
val slots = content.products?.slots.orEmpty()
when (style.productContainerType ?: ProductContainerType.ROW) {
ProductContainerType.ROW -> {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(style.margin.toPaddingValues()),
contentPadding = style.padding.toPaddingValues(),
horizontalArrangement = Arrangement.spacedBy((style.rowSpacing ?: 12).dp),
) {
itemsIndexed(
items = slots,
key = { itemIndex, slot -> productKey(slot, itemIndex) }
) { itemIndex, slot ->
ProductCard(
slot = slot,
content = content,
campaign = campaign,
onOpenProduct = onOpenProduct,
)
}
}
}
ProductContainerType.GRID -> {
LazyVerticalGrid(
modifier = Modifier
.fillMaxWidth()
.padding(style.margin.toPaddingValues())
.then(
if (style.size?.height != null) {
Modifier.height(style.size.height.dp)
} else {
Modifier
}
),
columns = GridCells.Fixed(max(style.gridColumns ?: 3, 1)),
contentPadding = style.padding.toPaddingValues(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
userScrollEnabled = false,
) {
itemsIndexed(
items = slots,
key = { itemIndex, slot -> productKey(slot, itemIndex) }
) { itemIndex, slot ->
ProductCard(
slot = slot,
content = content,
campaign = campaign,
onOpenProduct = onOpenProduct,
)
}
}
}
}
}
else -> Unit
}
}
@Composable
private fun ProductCard(
slot: Slot,
content: CampaignContent,
campaign: Campaign,
onOpenProduct: (Map<String, Any?>) -> Unit,
) {
VisibleImpressionBox(
modifier = Modifier.width(180.dp),
onVisible = {
GravitySDK.instance.sendProductEngagement(
ProductVisibleImpressionEngagement(slot, content, campaign)
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.White, RoundedCornerShape(16.dp))
.clickable {
GravitySDK.instance.sendProductEngagement(
ProductClickEngagement(slot, content, campaign)
)
onOpenProduct(slot.item)
}
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
stringValue("imageUrl", slot.item)?.let { imageUrl ->
RemoteImage(
url = imageUrl,
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
)
}
Text(
text = stringValue("name", slot.item) ?: "Unknown product",
style = TextStyle(fontWeight = FontWeight.SemiBold),
)
stringValue("price", slot.item)?.let { price ->
Text(text = price, color = Color(0xFF667085))
}
}
}
}
@Composable
private fun RemoteImage(
url: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.background(Color(0xFFF2F4F7), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = "Load with your image loader:\n$url",
textAlign = TextAlign.Center,
color = Color(0xFF475467),
)
}
}
@Composable
private fun VisibleImpressionBox(
modifier: Modifier = Modifier,
onVisible: () -> Unit,
content: @Composable () -> Unit,
) {
val view = LocalView.current
var isVisible by remember { mutableStateOf(false) }
var hasFired by remember { mutableStateOf(false) }
LaunchedEffect(isVisible, hasFired) {
if (!isVisible || hasFired) return@LaunchedEffect
delay(1000)
if (isVisible && !hasFired) {
hasFired = true
onVisible()
}
}
Box(
modifier = modifier.onGloballyPositioned { coordinates ->
val windowRect = Rect(0f, 0f, view.width.toFloat(), view.height.toFloat())
val widgetRect = coordinates.boundsInWindow()
val widgetSize = coordinates.size
val intersection = widgetRect.intersect(windowRect)
val visibleArea = intersection.width * intersection.height
val fullArea = widgetSize.width * widgetSize.height
isVisible = fullArea > 0 && visibleArea >= fullArea * 0.5f
}
) {
content()
}
}
private fun handleOnClick(
context: Context,
onClick: OnClickModel?,
onOpenUrl: (String) -> Unit,
onOpenDeeplink: (String) -> Unit,
) {
onClick ?: return
when (onClick.action) {
Action.FOLLOW_URL -> onClick.url?.let(onOpenUrl)
Action.FOLLOW_DEEPLINK -> onClick.deeplink?.let(onOpenDeeplink)
Action.COPY -> {
val text = onClick.copyData ?: return
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("", text))
}
else -> Unit
}
}
private fun productKey(slot: Slot, index: Int): String {
return slot.slotId
?: stringValue("sku", slot.item)
?: stringValue("id", slot.item)
?: "slot-$index"
}
private fun stringValue(key: String, item: Map<String, Any?>): String? {
val value = item[key] ?: return null
return when (value) {
is String -> value
is Number -> value.toString()
else -> null
}
}
private fun GravityPadding?.toPaddingValues(): PaddingValues {
return PaddingValues(
start = ((this?.left ?: 0.0)).dp,
top = ((this?.top ?: 0.0)).dp,
end = ((this?.right ?: 0.0)).dp,
bottom = ((this?.bottom ?: 0.0)).dp,
)
}
private fun GravityMargin?.toPaddingValues(): PaddingValues {
return PaddingValues(
start = ((this?.left ?: 0.0)).dp,
top = ((this?.top ?: 0.0)).dp,
end = ((this?.right ?: 0.0)).dp,
bottom = ((this?.bottom ?: 0.0)).dp,
)
}
private fun Modifier.applyGravitySize(style: Style): Modifier {
var result = this
if (style.size?.width != null) {
result = result.width(style.size.width.dp)
}
if (style.size?.height != null) {
result = result.height(style.size.height.dp)
}
return result
}
private fun GravityContentAlignment?.toTextAlign(): TextAlign {
return when (this) {
GravityContentAlignment.CENTER -> TextAlign.Center
GravityContentAlignment.END -> TextAlign.End
else -> TextAlign.Start
}
}
Если backend передает frameUI.container.onClick, его можно повесить на wrapper виджета тем же способом, что и element.onClick в примере выше.
#
Engagement для manual widget
При использовании публичного getContentBySelector(...) SDK только получает контент. Engagement для такого manual widget приложение отправляет само:
ContentImpressionEngagement— один раз, когда виджет впервые отрисован;ContentVisibleImpressionEngagement— один раз, когда минимум 50% виджета находится в видимой области не менее 1 секунды;ProductVisibleImpressionEngagement— один раз на карточку товара по тому же правилу;ProductClickEngagement— на тап по карточке товара до навигации;- публичного
ContentClickEngagementв текущем Android SDK нет.
Для manual rendering это означает, что campaign, content и slot нужно сохранить из ответа SDK и использовать повторно при отправке engagement.
#
Дополнительная кастомизация из backend
Нативный widget можно стилизовать по полям, которые уже приходят в моделях SDK:
- container:
backgroundColor,cornerRadius,padding,margin,size; - text:
fontSize,fontWeight,textColor,contentAlignment; - image:
src,fit,size; - layout:
gridColumns,productContainerType; - элементы:
button,image,text,spacer,products-container.
#
Кастомизация UI
#
Настройка шрифта
Встроенные UI-компоненты SDK можно стилизовать через UISettings.
val uiSettings = UISettings(fontResId = R.font.my_custom_font)
GravitySDK.initialize(
context = this,
apiKey = "YOUR_API_KEY",
section = "YOUR_SECTION_ID",
gravityEventCallback = ::handleGravityEvent,
uiSettings = uiSettings,
)
После этого встроенные текстовые элементы SDK будут использовать указанный шрифт.
#
Трекинг взаимодействий (engagement)
Engagement-события позволяют фиксировать показы и клики по контенту и товарам.
#
Какие события SDK отправляет автоматически
Для встроенных UI-компонентов SDK сам отправляет базовые события показа.
Для кастомного UI вручную нужно отправлять только те взаимодействия, которые SDK не может определить сам.
#
sendContentEngagement()
fun sendContentEngagement(engagement: ContentEngagement)
Поддерживаемые типы:
ContentImpressionEngagement(content, campaign)ContentVisibleImpressionEngagement(content, campaign)ContentCloseEngagement(content, campaign)
Пример:
GravitySDK.instance.sendContentEngagement(
ContentVisibleImpressionEngagement(content, campaign)
)
#
sendProductEngagement()
fun sendProductEngagement(engagement: ProductEngagement)
Поддерживаемые типы:
ProductClickEngagement(slot, content, campaign)ProductVisibleImpressionEngagement(slot, content, campaign)
Пример:
GravitySDK.instance.sendProductEngagement(
ProductClickEngagement(slot, content, campaign)
)
#
Когда нужно отправлять engagement вручную
- при полностью ручном JSON-рендеринге;
- при кастомном рендеринге карточек товара через
ProductViewBuilder; - в других сценариях, где вы сами контролируете жизненный цикл видимости и клика.
#
Какие объекты передавать в engagement
Для ручной отправки используйте объекты из ответа SDK, а не отдельные ID:
- в
ProductClickEngagement(...)иProductVisibleImpressionEngagement(...)передаютсяslot,content,campaign; - в
ContentVisibleImpressionEngagement(...)и других content engagement передаютсяcontent,campaign.
На практике это означает:
slotберется изcontent.products?.slots;contentберется изcampaign.payload[*].contents[*];campaignберется изContentResponse.data.
#
Откуда брать slot, content и campaign
Чаще всего эти объекты берутся из ответа getContentBySelector(...).
val response = GravitySDK.instance.getContentBySelector(
selector = "homepage-banner-json",
pageContext = pageContext,
)
val campaign = response.data.firstOrNull() ?: return
val variation = campaign.payload.firstOrNull() ?: return
val content = variation.contents.firstOrNull() ?: return
val slot = content.products?.slots?.firstOrNull() ?: return
После этого можно отправлять engagement:
GravitySDK.instance.sendContentEngagement(
ContentVisibleImpressionEngagement(content, campaign)
)
GravitySDK.instance.sendProductEngagement(
ProductClickEngagement(slot, content, campaign)
)
Если вы используете кастомный ProductViewBuilder, SDK сам передает вам slot, content и campaign в метод Build(...) или createView(...), и их можно использовать напрямую без дополнительного поиска в ответе.
#
Когда считается visible impression
Во встроенных UI-компонентах Android SDK фиксирует visible impression, когда элемент находится в видимой области не менее чем на 50% и сохраняет видимость не менее 1 секунды.
#
Справочник по событиям (TriggerEvent)
Для LoginEvent и SignUpEvent рекомендуется передавать cuid и cuidType, даже если на уровне класса эти поля не обязательны.
#
Обработка обратных вызовов (Callbacks)
Подпишитесь на события SDK через gravityEventCallback в initialize(...).
#
Справочник по событиям TrackingEvent
Пример обработки:
private fun handleGravityEvent(event: TrackingEvent) {
when (event) {
is FollowUrlEvent -> {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.url))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
is FollowDeeplinkEvent -> {
// Выполните навигацию по deeplink
}
is RequestPushEvent -> {
// При необходимости обработайте событие в аналитике
}
else -> Unit
}
}
#
FAQ и Troubleshooting
Почему GravityInlineView не отображается?
Проверьте два условия: в XML задан app:selector, а после инфлейта вызван init(pageContext). Без init(...) SDK не получит контекст для загрузки кампании.
Почему GravityInlineCompose не показывает контент?
Проверьте selector, pageContext и то, что composable действительно получает место на экране. Для отладки удобно временно передать loader, чтобы различать состояние загрузки и отсутствие кампании.
Почему кампания не показывается после trackView(...) или triggerEvent(...)?
Проверьте PageContext, актуальность section, наличие подходящей кампании и то, что в activityContext передается контекст текущего экрана. Если SDK не сможет определить Activity, in-app-показ не произойдет.
Почему приложение не падает, даже если сеть недоступна?
Android SDK обрабатывает сетевые ошибки внутри себя. В этом случае контент просто не будет показан или не будет загружен.
Нужно ли передавать lng, utm и attributes всегда?
Нет. lng нужен только если проект использует мультирегиональность и региональные варианты данных в фиде. utm и attributes нужны только если на них завязаны правила таргетинга или аналитика.
Нужно ли отдельно передавать decisionId в engagement?
Нет. В публичных Android-методах engagement он не передается отдельным параметром. Используйте объекты slot, content и campaign, полученные из ответа SDK.