Показ пейвола с таргетингом Apple Ads при первом запуске в iOS SDK

Apple Ads (AA) атрибуция поступает асинхронно после вызова Adapty.activate(). Если вызвать getFlow слишком рано, атрибуция ещё не успевает применится, и Adapty разрешает плейсмент по аудитории по умолчанию — минуя пейволы с AA-сегментацией. AdaptyProfile.appliedExternalAttributionProviders позволяет приложению определить, применена ли AA-атрибуция к профилю, чтобы запрос пейвола дождался корректного разрешения AA-сегментации.

Important

appliedExternalAttributionProviders отображает только данные Apple Ads. Атрибуция от других провайдеров видна в профилях пользователей и доступна в фильтрах сегментов, но пока не здесь.

Прежде чем начать

Вам потребуются:

  • Adapty iOS SDK 4.1 или новее.
  • Apple Ads, настроенный для приложения в Adapty. См. Apple Ads.
Note

В примерах используются названия API из SDK 4.1. В SDK 4.0 свойство профиля называется appliedAttributionSources. В SDK 3.x это свойство имеет то же устаревшее название, а пейволы получаются с помощью getPaywall/getPaywallForDefaultAudience, которые возвращают AdaptyPaywall. См. Миграция Adapty iOS SDK на v4.1.

Как это работает

После вызова Adapty.activate() SDK запрашивает атрибуцию Apple Ads у Apple в фоновом режиме и передаёт результат в бэкенд Adapty. Когда AA становится активным источником атрибуции для профиля, SDK возвращает обновлённый AdaptyProfile, в массиве appliedExternalAttributionProviders которого содержится .appleAds.

Пустой массив может означать любое из следующего:

  • Атрибуция Apple Ads для этого профиля ещё не обработана.
  • Атрибуция вообще не поступала.
  • Атрибуция поступила от другого провайдера, который в этом массиве не отражается.

Даже с пустым массивом вызов getFlow безопасен — Adapty разрешает запрос, опираясь на аудиторию, соответствующую текущему состоянию профиля (как правило, это аудитория по умолчанию).

Important

Ожидание применяется только при первом запуске. Как только атрибуция Apple Ads зафиксирована, она сохраняется в профиле навсегда. При каждом последующем запуске кешированный профиль уже содержит .appleAds в appliedExternalAttributionProviders, didLoadLatestProfile срабатывает с этим значением немедленно, и getFlow возвращает пейвол, сегментированный по Apple Ads, без какой-либо задержки.

Реализация

При первом запуске следите за .appleAds в профиле и используйте жёсткий таймаут — если атрибуция Apple Ads так и не придёт, эти пользователи всё равно должны увидеть пейвол.

  1. Активируйте SDK. См. Установка и настройка iOS SDK.
  2. Подпишитесь на обновления профиля, реализовав AdaptyDelegate и метод didLoadLatestProfile. Если делегат ещё не настроен, см. Отслеживание обновлений подписки.
  3. Следите за .appleAds в appliedExternalAttributionProviders. Когда оно появится, запросите пейвол — Adapty вернёт вариант с AA-сегментацией:
extension <YourAdaptyDelegateImpl>: AdaptyDelegate {
    nonisolated func didLoadLatestProfile(_ profile: AdaptyProfile) {
        if profile.appliedExternalAttributionProviders.contains(where: { $0 == .appleAds }) {
            // load the paywall via Adapty.getFlow(placementId:)
        }
    }
}
  1. Запустите таймер на 3–5 секунд параллельно с подпиской. Если таймер сработает раньше, чем появится .appleAds, всё равно запросите пейвол:

Whichever path fires first should load the paywall; the other path should be skipped. Use a single state flag (for example, hasLoadedPaywall) to deduplicate so the paywall isn’t fetched twice. Configure a резервный пейвол for the placement so the user is never stuck if the network request fails.

Полный пример

Реализация ниже запускает гонку атрибуции против таймаута, параллельно предзагружает пейвол для аудитории по умолчанию и возвращает подходящий пейвол. Вызывающий код ожидает одну async-функцию — никаких делегатов или флагов состояния на стороне вызова.

ProfileObserver — переиспользуемый синглтон, публикующий обновления профиля из AdaptyDelegate. FlowLoader.getFlowOrDefault запускает гонку с помощью TaskGroup из structured concurrency:

  • Если атрибуция поступает в пределах timeout, возвращается пейвол для нужного сегмента через getFlow(placementId:).
  • Если timeout истекает раньше, возвращается заранее загруженный пейвол для аудитории по умолчанию через getFlowForDefaultAudience(placementId:).

/// Demonstrates how to fetch a paywall that depends on attribution being applied,
/// falling back to the default-audience paywall if attribution doesn't arrive in time.
///
/// Stateless and self-contained: every call kicks off its own default-audience
/// prefetch and races it against attribution + segmented fetch.
enum FlowLoader {
    static func getFlowOrDefault(
        placementId: String,
        timeout: TimeInterval
    ) async throws -> AdaptyFlow {
        struct TimedOut: Error {}

        // Kick off the default-audience request immediately so it has the full
        // `timeout` window to load. We'll either cancel it on success or await
        // its result on timeout — never a duplicate network call.
        let defaultFlowTask = Task {
            try await Adapty.getFlowForDefaultAudience(placementId: placementId)
        }

        do {
            // Race two child tasks: whichever finishes first wins.
            let result = try await withThrowingTaskGroup(of: AdaptyFlow.self) { group in
                // 1. Wait for attribution, then ask Adapty for the segmented paywall.
                group.addTask {
                    await waitForAttribution()
                    return try await Adapty.getFlow(placementId: placementId)
                }
                // 2. Time-bomb: throws `TimedOut` after `timeout` seconds.
                group.addTask {
                    try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
                    throw TimedOut()
                }
                guard let value = try await group.next() else { throw CancellationError() }
                group.cancelAll() // stop the loser (sleeper or the attribution wait).
                return value
            }
            // Segmented paywall won — we no longer need the default-audience prefetch.
            defaultFlowTask.cancel()
            return result
        } catch is TimedOut {
            // Attribution didn't apply in time — return the prefetched default
            // (instant if already done, otherwise we await the in-flight request).
            return try await defaultFlowTask.value
        }
    }

    /// Suspends until a profile with the desired attribution source is observed.
    /// `@Published.values` emits the current profile immediately on subscription,
    /// so this returns on the first iteration if attribution is already applied.
    @MainActor
    private static func waitForAttribution() async {
        for await profile in ProfileObserver.shared.$profile.values {
            if profile?.appliedExternalAttributionProviders.contains(.appleAds) == true { return }
        }
    }
}

@MainActor
final class ProfileObserver: AdaptyDelegate {
    static let shared = ProfileObserver()

    @Published private(set) var profile: AdaptyProfile?

    nonisolated func didLoadLatestProfile(_ profile: AdaptyProfile) {
        Task { @MainActor [weak self] in
            self?.profile = profile
        }
    }
}

Подключите ProfileObserver к AdaptyDelegate один раз, после того как Adapty.activate() завершится:

Adapty.delegate = ProfileObserver.shared

Вызовите с экрана-заставки:

do {
    let flow = try await FlowLoader.getFlowOrDefault(
        placementId: "YOUR_PLACEMENT_ID",
        timeout: 5
    )
    // present the paywall
} catch {
    // handle the error or show a fallback paywall
}

Если в вашем приложении уже используется AdaptyDelegate для других целей (например, для отслеживания обновлений подписки), передавайте didLoadLatestProfile в ProfileObserver.shared из существующего делегата вместо того, чтобы устанавливать Adapty.delegate = ProfileObserver.shared.