在 iOS SDK 中首次启动时显示 AA 定向付费墙
Apple Ads (AA) 归因数据会在 Adapty.activate() 之后异步到达。如果过早调用 getPaywall,归因数据往往尚未就位,Adapty 会按默认目标受众解析版位——从而绕过你基于 AA 市场细分的付费墙。AdaptyProfile.appliedExternalAttributionProviders 让应用能够检测 AA 归因何时已应用到用户画像,从而让付费墙请求等到 AA 市场细分正确解析后再发出。
开始之前
你需要:
- Adapty iOS SDK 3.17.1 或更高版本。
- 在 Adapty 中为应用配置 Apple Ads。请参阅 Apple Ads。
以下示例使用 SDK 4.1+ 的属性名 appliedExternalAttributionProviders。在 SDK 3.17.1–4.0 版本中,该属性名为 appliedAttributionSources。
工作原理
调用 Adapty.activate() 后,SDK 会在后台向 Apple 请求 Apple Ads 归因数据,并将结果转发至 Adapty 的后端。当 AA 成为该用户画像的活跃归因来源时,SDK 会返回一个更新后的 AdaptyProfile,其 appliedExternalAttributionProviders 数组中将包含 .appleAds。
数组为空可能意味着以下任一情况:
- 该用户画像的 Apple Ads 归因尚未处理完成。
- 完全没有收到任何归因数据。
即使传入空数组,调用 getPaywall 依然是安全的——Adapty 会根据当前用户画像状态匹配相应的目标受众,通常为默认目标受众。
等待仅适用于首次启动。一旦 Apple Ads 归因数据被记录,它就会永久存储在用户画像中。在后续每次启动时,缓存的用户画像已携带 .appleAds(位于 appliedExternalAttributionProviders 中),didLoadLatestProfile 会立即触发并返回该值,getPaywall 也会直接返回基于 Apple Ads 市场细分的付费墙,无需任何等待。
实现
首次启动时,监听用户画像中的 .appleAds 字段,并设置一个硬超时——即便 Apple Ads 归因数据始终未到达,这些用户也需要看到付费墙。
- 激活 SDK。 请参阅安装与配置 iOS SDK。
- 订阅用户画像更新,方法是遵循
AdaptyDelegate协议并实现didLoadLatestProfile。如果尚未设置委托,请参阅监听订阅状态更新。 - 监测
appliedExternalAttributionProviders中是否出现.appleAds。 一旦出现,即可请求付费墙——Adapty 将返回 AA 细分对应的实验变体:
extension <YourAdaptyDelegateImpl>: AdaptyDelegate {
nonisolated func didLoadLatestProfile(_ profile: AdaptyProfile) {
if profile.appliedExternalAttributionProviders.contains(where: { $0 == .appleAds }) {
// load paywall via Adapty.getPaywall(placementId:)
}
}
}
- 同步启动一个 3–5 秒的计时器。 如果计时器触发时
.appleAds尚未出现,直接请求付费墙: 无论哪条路径先触发,都应加载付费墙;另一条路径应被跳过。使用一个状态标志(例如hasLoadedPaywall)进行去重,避免付费墙被重复获取。为该版位配置备用付费墙,确保网络请求失败时用户不会陷入等待。
完整示例
以下实现将归因与超时进行竞争,同时预取默认受众的付费墙,并返回合适的付费墙。调用方只需等待一个异步函数——无需在调用处管理代理或状态标志。
ProfileObserver 是一个可复用的单例,用于发布来自 AdaptyDelegate 的用户画像更新。PaywallLoader.getPaywallOrDefault 使用结构化并发 TaskGroup 执行竞争逻辑:
- 如果归因数据在
timeout时间内到达,则通过getPaywall(placementId:)返回按目标受众细分的付费墙。 - 如果
timeout先超时,则通过getPaywallForDefaultAudience(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 PaywallLoader {
static func getPaywallOrDefault(
placementId: String,
timeout: TimeInterval
) async throws -> AdaptyPaywall {
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 defaultPaywallTask = Task {
try await Adapty.getPaywallForDefaultAudience(placementId: placementId)
}
do {
// Race two child tasks: whichever finishes first wins.
let result = try await withThrowingTaskGroup(of: AdaptyPaywall.self) { group in
// 1. Wait for attribution, then ask Adapty for the segmented paywall.
group.addTask {
await waitForAttribution()
return try await Adapty.getPaywall(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.
defaultPaywallTask.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 defaultPaywallTask.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
}
}
}
在 Adapty.activate() 完成后,将 ProfileObserver 注册到 AdaptyDelegate 一次即可:
Adapty.delegate = ProfileObserver.shared
在启动屏中调用:
do {
let paywall = try await PaywallLoader.getPaywallOrDefault(
placementId: "YOUR_PLACEMENT_ID",
timeout: 5
)
// present the paywall
} catch {
// handle the error or show a fallback paywall
}
如果你的应用已经在使用 AdaptyDelegate 处理其他事务(例如监听订阅更新),请不要将 Adapty.delegate = ProfileObserver.shared 直接设置,而是在现有的 delegate 中将 didLoadLatestProfile 转发给 ProfileObserver.shared。