SKAdNetwork 4 可用:有关 SKAdNetwork 的最新版本,请参见《SKAdNetwork 4 实施指南》。
SKAdNetwork 3.0 S2S 实施指南
实施 Apple 的 SKAdNetwork 3.0 框架,利用服务器到服务器集成来衡量应用程序安装营销活动的绩效,同时不损害用户标识符,从而实现符合隐私标准的 iOS 归因。
概述
什么是 SKAdNetwork?
SKAdNetwork 是 Apple 的隐私保护归因框架,可在不需要用户级标识符的情况下测量 iOS 上应用安装广告营销活动的转化率。
该框架通过 App Store 服务器处理归因信息,将归因信息与用户标识符和时间数据断开,然后再发送给广告网络。
Apple 文档:SKAdNetwork 框架参考资料
SKAdNetwork 如何工作
归因过程完全通过 Apple 的基础架构进行,在确保用户隐私的同时实现了营销活动的性能测量。
归因流程:
- 广告点击:用户点击含有 SKAdNetwork 签名(包含网络、发布商和营销活动 ID)的广告
- 应用商店打开:设备存储归因数据并打开 App Store 进行安装
- 应用程序启动:用户首次安装并启动应用程序
- 注册:应用程序向 SKAdNetwork 框架注册,以表示安装成功
- 转换值:应用程序可选择更新代表安装后活动的转换值(0-63)
- 定时窗口:设备在首次启动或最后一次转换值更新后等待 24+ 小时
- 回传:应用商店向广告网络发送带有广告系列 ID、转换值和加密签名的归因回传
隐私设计:
- 回传不包含设备或用户标识符
- 最少 24 小时的延迟可防止时间相关性
- 应用程序永远不会知道用户点击了哪个广告
- 网络永远不会知道哪个特定用户安装了广告
功能与限制
SKAdNetwork 提供
- 最后点击归因:无需用户同意或 ATT 选择即可使用
- 营销活动细分:来源、营销活动和发布商粒度
- 转换值:64 个离散值(0-63),用于安装后测量
- 防止欺诈:加密签名验证归属真实性
当前限制:
- 无用户级数据:无法跟踪单个用户的使用过程
- 无直通视图:仅限基于点击的归因
- 转换值有限:每次安装只有一个 6 位值(0-63)
- 粒度有限:最多 100 个营销活动 ID,无广告组或创意细分
- 无长期群组:转换测量的时间范围有限
- 欺诈风险:转换值本身未签名,回传可能被复制
Singular 资源
Singular 为 SKAdNetwork 的实施和优化提供全面的资源。
Singular SKAdNetwork 解决方案
Singular的SKAdNetwork解决方案提供端到端的归因管理,从客户端实施到回传处理和营销活动优化。
解决方案组件
平台功能
在归因和分析工作流程中提供全面的 SKAdNetwork 支持。
| 组件 | 功能 |
|---|---|
| 客户端代码 | 用于 SKAdNetwork 框架注册和转换值管理的本地 iOS 代码示例 |
| 回传处理 | 验证和汇总来自所有广告网络的回传,并提供统一报告 |
| 欺诈保护 | 加密签名验证、交易 ID 重复数据删除和安全参数验证 |
| 转换管理 | 动态仪表板配置,可将安装后活动编码为转换值 |
| 报告 | 利用营销参数翻译和丰富营销活动 ID,以进行精细分析 |
| 合作伙伴反馈 | 将解码后的转换值作为事件和收入发送,用于合作伙伴优化 |
S2S 集成架构
实施组件
服务器到服务器 SKAdNetwork 集成包括两个主要实施领域。
1.客户端实施(必需):
- 在应用程序启动时注册 SKAdNetwork 框架
- 基于安装后活动的智能转换值管理
- 使用 SKAdNetwork 归因对营销活动进行优化至关重要
2.S2S 集成更新(可选):
- 用 SKAdNetwork 元数据丰富 Singular S2S 事件和会话
- 实现实施验证和故障排除
- 为调试提供转换值时间戳
客户端实施
使用 Singular 的本地 iOS 代码示例实施 SKAdNetwork 框架注册和转换值管理,以优化营销活动测量。
实施职责
核心功能
客户端代码处理 SKAdNetwork 测量的两个关键功能。
- SKAdNetwork 注册:启动后立即在框架中注册应用程序,以启用归因功能
-
转换值管理:
- 与 Singular 端点同步通信,以接收下一个转换值
- 向 Singular 报告会话、事件和收入
- 接收编码转换值,代表配置的安装后活动
- 收集 SKAdNetwork 元数据(首次调用时间戳、最后一次调用时间戳、当前值
SKAdNetwork 接口
方法定义
接口包括框架注册、会话跟踪、事件跟踪和收入测量方法。
// SKANSnippet.h
#import <Foundation/Foundation.h>
@interface SKANSnippet : NSObject
// Register for SKAdNetwork attribution.
// Call this method as soon as possible once app is launched
+ (void)registerAppForAdNetworkAttribution;
// Track retention and cohorts by calling after each session.
// Reports session details and updates conversion value if needed.
// Conversion value updates only if new value greater than previous.
// Optional callback runs once conversion value updated.
+ (void)updateConversionValueAsync:(void(^)(int, NSError*))handler;
// Track conversion events by calling after each event.
// Reports event details and updates conversion value if needed.
// Optional callback runs once conversion value updated.
+ (void)updateConversionValueAsync:(NSString*)eventName
withCompletionHandler:(void(^)(int, NSError*))handler;
// Track revenue by calling before each revenue event.
// Updates total revenue for next conversion value calculation.
// Note:
// 1. Call before 'updateConversionValueAsync' to ensure revenue included
// 2. Avoid calling on event retries to prevent double-counting
+ (void)updateRevenue:(double)amount andCurrency:(NSString*)currency;
// Gets current conversion value (nil if none set)
+ (NSNumber *)getConversionValue;
@end
注册方法
registerAppForAdNetworkAttribution 向 SKAdNetwork 框架注册应用程序,启动 24 小时归因计时器。
计时器行为:
- 设备在计时器过期 0-24 小时后发送安装通知
- 用更高值更新转换值,重置新的 24 小时间隔计时器
- Apple 参考资料:updateConversionValue 文档
转换值方法
方法根据安装后活动和配置的转换模型计算和更新转换值。
支持的活动:
- 会话:对于保留测量至关重要
- 转换事件:对安装后活动测量至关重要
- 收入事件收入测量的关键
接口实施
完整的实施代码
全面实施处理注册、转换管理和 Singular API 通信。
// SKANSnippet.m
#import "SKANSnippet.h"
#import <StoreKit/SKAdNetwork.h>
#define SESSION_EVENT_NAME @"__SESSION__"
#define SINGULAR_API_URL @"https://sdk-api-v1.singular.net/api/v1/conversion_value"
// Keys for UserDefaults storage
#define CONVERSION_VALUE_KEY @"skan_conversion_value"
#define FIRST_SKAN_CALL_TIMESTAMP @"skan_first_call_to_skadnetwork_timestamp"
#define LAST_SKAN_CALL_TIMESTAMP @"skan_last_call_to_skadnetwork_timestamp"
#define TOTAL_REVENUE_BY_CURRENCY_KEY @"skan_total_revenue_by_currency"
#define SECONDS_PER_DAY 86400
static NSLock *lockObject;
@implementation SKANSnippet
+ (void)registerAppForAdNetworkAttribution {
if ([SKANSnippet getFirstSkanCallTimestamp] != 0) {
return;
}
if (@available(iOS 11.3, *)) {
[SKAdNetwork registerAppForAdNetworkAttribution];
[SKANSnippet setFirstSkanCallTimestamp];
[SKANSnippet setLastSkanCallTimestamp];
}
}
+ (void)updateConversionValueAsync:(void(^)(int, NSError*))handler {
[SKANSnippet updateConversionValueAsync:SESSION_EVENT_NAME withCompletionHandler:handler];
}
+ (void)updateConversionValueAsync:(NSString*)eventName withCompletionHandler:(void(^)(int, NSError*))handler {
if (@available(iOS 14, *)) {
if ([SKANSnippet isSkanUpdateWindowOver]) {
return;
}
[SKANSnippet getConversionValueFromServer:eventName withCompletionHandler:handler];
}
}
+ (void)updateRevenue:(double)amount andCurrency:(NSString*)currency {
if (@available(iOS 14, *)) {
// Update total revenues
if (amount != 0 && currency) {
NSMutableDictionary* revenues = [[SKANSnippet getTotalRevenue] mutableCopy];
NSNumber* currentRevenue = @(0);
if ([revenues objectForKey:currency]) {
currentRevenue = [revenues objectForKey:currency];
}
currentRevenue = @([currentRevenue floatValue] + amount);
[revenues setObject:currentRevenue forKey:currency];
[SKANSnippet setTotalRevenue:revenues];
}
}
}
+ (NSNumber *)getConversionValue {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
if (![userDefaults objectForKey:CONVERSION_VALUE_KEY]) {
return nil;
}
return @([userDefaults integerForKey:CONVERSION_VALUE_KEY]);
}
+ (void)getConversionValueFromServer:(NSString*)eventName withCompletionHandler:(void(^)(int, NSError*))handler {
if (!lockObject) {
lockObject = [NSLock new];
}
// Making the lock async so it will not freeze the calling thread
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
[lockObject lock];
NSURLComponents *components = [NSURLComponents componentsWithString:SINGULAR_API_URL];
NSString* bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier];
components.queryItems = @[
[NSURLQueryItem queryItemWithName:@"a" value:@"YOUR_SDK_KEY"],
[NSURLQueryItem queryItemWithName:@"i" value:bundleIdentifier],
[NSURLQueryItem queryItemWithName:@"app_v" value:@"YOUR_APP_VERSION"],
[NSURLQueryItem queryItemWithName:@"n" value:eventName],
[NSURLQueryItem queryItemWithName:@"p" value:@"iOS"],
[NSURLQueryItem queryItemWithName:@"idfv" value:@"YOUR_IDFV"],
[NSURLQueryItem queryItemWithName:@"idfa" value:@"YOUR_IDFA"],
[NSURLQueryItem queryItemWithName:@"conversion_value" value:[[SKANSnippet getConversionValue] stringValue]],
[NSURLQueryItem queryItemWithName:@"total_revenue_by_currency" value:[SKANSnippet dictionaryToJsonString:[SKANSnippet getTotalRevenue]]],
[NSURLQueryItem queryItemWithName:@"first_call_to_skadnetwork_timestamp" value:[NSString stringWithFormat:@"%ld", [SKANSnippet getFirstSkanCallTimestamp]]],
[NSURLQueryItem queryItemWithName:@"last_call_to_skadnetwork_timestamp" value:[NSString stringWithFormat:@"%ld", [SKANSnippet getLastSkanCallTimestamp]]],
];
[[[NSURLSession sharedSession] dataTaskWithURL:components.URL
completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
[lockObject unlock];
if (handler) {
handler(-1, error);
}
return;
}
NSDictionary* parsedResponse = [SKANSnippet jsonDataToDictionary:data];
if (!parsedResponse) {
[lockObject unlock];
if (handler) {
handler(-1, [NSError errorWithDomain:bundleIdentifier
code:0
userInfo:@{NSLocalizedDescriptionKey:@"Failed parsing server response"}]);
}
return;
}
NSNumber *conversionValue = [parsedResponse objectForKey:@"conversion_value"];
if (!conversionValue) {
[lockObject unlock];
NSString *status = [parsedResponse objectForKey:@"status"];
if (!status || ![status isEqualToString:@"ok"]) {
if (handler) {
NSString *reason = [parsedResponse objectForKey:@"reason"];
if (!reason) {
reason = @"Got error from server";
}
handler(-1, [NSError errorWithDomain:bundleIdentifier
code:0
userInfo:@{NSLocalizedDescriptionKey:reason}]);
}
}
return;
}
NSNumber* currentValue = [SKANSnippet getConversionValue];
if ([conversionValue intValue] <= [currentValue intValue]) {
[lockObject unlock];
return;
}
[SKANSnippet setConversionValue:[conversionValue intValue]];
[SKANSnippet setLastSkanCallTimestamp];
if (![SKANSnippet getFirstSkanCallTimestamp]) {
[SKANSnippet setFirstSkanCallTimestamp];
}
[lockObject unlock];
if (handler) {
handler([conversionValue intValue], error);
}
}] resume];
});
}
+ (BOOL)isSkanUpdateWindowOver {
NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getLastSkanCallTimestamp];
return SECONDS_PER_DAY <= timeDiff;
}
@end
实用方法
元数据管理
实用程序方法管理 UserDefaults 中的 SKAdNetwork 元数据存储,这对转换值计算至关重要。
关键实施:这些实用程序维护准确计算转换值所需的元数据。如无必要,请勿修改。
+ (NSInteger)getFirstSkanCallTimestamp {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
return [userDefaults integerForKey:FIRST_SKAN_CALL_TIMESTAMP];
}
+ (NSInteger)getLastSkanCallTimestamp {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
return [userDefaults integerForKey:LAST_SKAN_CALL_TIMESTAMP];
}
+ (NSDictionary*)getTotalRevenue {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
if (![userDefaults objectForKey:TOTAL_REVENUE_BY_CURRENCY_KEY]) {
return [NSDictionary new];
}
return [userDefaults objectForKey:TOTAL_REVENUE_BY_CURRENCY_KEY];
}
+ (NSInteger)getCurrentUnixTimestamp {
return [[NSDate date]timeIntervalSince1970];
}
+ (void)setConversionValue:(int)value {
if (@available(iOS 14.0, *)) {
if (value <= [[SKANSnippet getConversionValue] intValue]) {
return;
}
[SKAdNetwork updateConversionValue:value];
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
[userDefaults setInteger:value forKey:CONVERSION_VALUE_KEY];
[userDefaults synchronize];
}
}
+ (void)setFirstSkanCallTimestamp {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
[userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp]
forKey:FIRST_SKAN_CALL_TIMESTAMP];
[userDefaults synchronize];
}
+ (void)setLastSkanCallTimestamp {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
[userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp]
forKey:LAST_SKAN_CALL_TIMESTAMP];
[userDefaults synchronize];
}
+ (void)setTotalRevenue:(NSDictionary *)values {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
[userDefaults setObject:values forKey:TOTAL_REVENUE_BY_CURRENCY_KEY];
[userDefaults synchronize];
}
+ (NSString*)dictionaryToJSONString:(NSDictionary*)dictionary {
if (!dictionary || [dictionary count] == 0){
return @"{}";
}
NSError *error;
NSData *JSONData = [NSJSONSerialization dataWithJSONObject:dictionary
options:0
error:&error];
if (error || !JSONData) {
return @"{}";
}
return [[NSString alloc] initWithData:JSONData
encoding:NSUTF8StringEncoding];
}
+ (NSDictionary*)JSONDataToDictionary:(NSData*)JSONData {
if (!JSONData) {
return nil;
}
NSError * error;
NSDictionary * parsedData = [NSJSONSerialization
JSONObjectWithData:JSONData
options:kNilOptions error:&error];
if (error || !parsedData) {
return nil;
}
return parsedData;
}
@end
S2S 集成更新
可选择加强服务器到服务器与 SKAdNetwork 元数据的集成,以进行实施验证和故障排除。
强制更新
转换值响应
转换模型激活后,S2S 端点会返回conversion_value 整数字段,其中包含客户端更新的下一个值。
响应处理:从 S2S 端点响应中解析conversion_value,并通过客户端代码应用到 SKAdNetwork 框架。
可选增强功能
元数据参数
用 SKAdNetwork 元数据丰富 S2S 会话和事件请求,以验证集成和排除实施问题。
| 参数 | 描述 |
|---|---|
skan_conversion_value
|
请求时的最新转换值 |
skan_first_call_timestamp
|
首次调用 SKAdNetwork API 的 Unix 时间戳 |
skan_last_call_timestamp
|
最近一次调用 SKAdNetwork API 的 Unix 时间戳 |
元数据提取代码
使用用于 S2S 传输的实用方法提取 SKAdNetwork 元数据值。
NSDictionary *skanMetadata = @{
@"skan_conversion_value":
[[SKANSnippet getConversionValue] stringValue],
@"skan_first_call_timestamp":
[NSString stringWithFormat:@"%ld", [SKANSnippet getFirstSkanCallTimestamp]],
@"skan_last_call_timestamp":
[NSString stringWithFormat:@"%ld", [SKANSnippet getLastSkanCallTimestamp]]
};
// Forward skanMetadata to your server for S2S API enrichment
将参数转发到服务器端,并附加到 Singular S2S API 请求中。 完整的参数文档:S2S API 参考资料
集成流程
为 S2S 客户提供从客户端转换管理到回传处理的完整 SKAdNetwork 流程。
流程阶段
端到端流程
- 转换值请求:应用程序代码与 Singular 端点同步通信,根据会话、事件和收入获取最新转换值
- 框架更新:应用程序根据收到的转换值更新 SKAdNetwork 框架
- 丰富元数据:应用程序用 SKAdNetwork 元数据丰富 S2S 事件和会话,以进行验证
- 计时器过期:上次更新 24 小时后,SKAdNetwork 向广告网络发送回帖
- 回帖转发:网络将回帖转发给 Singular(安全设置或常规设置
-
回帖处理:Singular 通过以下方式处理回帖
- 验证加密签名
- 使用配置模型解码转换值
- 在合作伙伴集成的基础上丰富营销参数
- 通过回传将解码数据发送给 BI 和合作伙伴
数据分离:SKAdNetwork 数据(安装和解码事件)可通过单独的报告、API、ETL 表和回传访问,以防止在测试和验证期间与现有数据集混合。
应用程序生命周期实施
在适当的应用程序生命周期点集成 SKAdNetwork 方法,以实现完整的归因覆盖。
实施示例
方法放置
// On app launch (in applicationDidFinishLaunching)
[SKANSnippet registerAppForAdNetworkAttribution];
// After each session handled
[SKANSnippet updateConversionValueAsync:^(int value, NSError *error) {
if (error) {
NSLog(@"Conversion value update failed: %@", error);
} else {
NSLog(@"Conversion value updated to: %d", value);
}
}];
// After handling non-revenue events
[SKANSnippet updateConversionValueAsync:@"event_name"
withCompletionHandler:^(int value, NSError *error) {
if (error) {
NSLog(@"Event conversion value update failed: %@", error);
} else {
NSLog(@"Event conversion value updated to: %d", value);
}
}];
// After handling revenue events
[SKANSnippet updateRevenue:15.53 andCurrency:@"USD"];
[SKANSnippet updateConversionValueAsync:@"revenue_event_name"
withCompletionHandler:^(int value, NSError *error) {
if (error) {
NSLog(@"Revenue conversion value update failed: %@", error);
} else {
NSLog(@"Revenue conversion value updated to: %d", value);
}
}];
测试实施
在生产部署前使用 Singular 的测试端点验证 SKAdNetwork 的实施。
测试环境
测试端点配置
用测试端点替换生产转换管理端点,以模拟应用程序流程。
测试端点 URL:
https://skadnetwork-testing.singular.net/api/v1/conversion_value
- 测试端点无需 API 密钥
- 按顺序返回转换值 0-2
- 第三次调用后返回空字符串
测试程序
验证步骤
-
更新端点:用测试终点 URL 替换
SINGULAR_API_URL常量 - 初始化转换值:确保默认转换值初始化为 0
- 生成事件:在应用程序内触发 3 个不同的事件
-
验证更新:确认在每个事件后调用
updateConversionValueAsync - 记录值:记录返回的转换值,以验证进程是否正确(0 → 1 → 2)
- 确认完成:第三次调用后,验证空响应和最终保留值
预期行为:
- 第一个事件:接收转换值 0
- 第二个事件接收转换值 1
- 第三个事件接收转换值 2
- 第四+个事件:空响应,值 2 继续存在
代码更新日志
跟踪随着时间推移应用的代码示例更新和关键修复。
版本历史
| 日期 | 更改 |
|---|---|
| 2020 年 10 月 1 日 |
|
| 2020 年 9 月 23 日 |
|
| 2020年 9月 15日 |
|