SKAdNetwork 4 实施指南
SKAdNetwork 使用案例
SKAdNetwork (SKAN) 是苹果公司注重隐私的归因框架,可在保护用户隐私的同时测量 iOS 应用程序安装广告活动。服务器到服务器 (S2S) 实施通过在服务器之间直接发送 SKAdNetwork 数据,为验证和跟踪广告活动效果提供了一种稳健的方法,从而确保准确的归因和转换跟踪。
该框架处理归因的所有关键方面,同时通过苹果公司规定的方法维护用户隐私,使其成为移动营销人员在iOS 14.5后环境中运营的重要工具。
关键点
- 对来自所有网络的回传进行强大的验证和聚合,并内置欺诈保护功能
- 通过仪表板配置进行动态转换值管理
- 通过丰富的营销参数和细粒度数据洞察增强报告功能
- 用于解码转换值和收入跟踪的安全合作伙伴回传系统
- 通过丰富的事件和会话跟踪对客户端实施进行全面验证
- 跨多个回传窗口(0-2 天、3-7 天、8-35 天)的自动时间戳管理
- 支持广告货币化和常规收入跟踪,并提供货币规格
前提条件
- 在SKAN 4.0 常见问题中了解 SKAdNetwork4.0。
- 有关 SKAdNetwork 的更多背景信息,请参阅《SKAdNetwork 3.0 S2S 实施指南》。
关键组件
Singular的SKAdNetwork解决方案由以下部分组成:
-
实施 SKAdNetwork 的客户端代码
使用转换值 API 端点,提供另一种服务器端方法。 - 来自所有网络的回传验证和聚合
-
欺诈保护:
- 签名验证和交易 ID 删除。
- 与网络进行安全设置,以验证未签名的参数(转换值和地理数据)。
- 转换值管理: 可在 Singular 的仪表板上动态配置哪些安装后活动应编码到 SKAdNetwork 转换值中。
- 报告:翻译有限的 SKAdNetwork 营销活动 ID,并使用更多营销参数和粒度来丰富数据。
- 合作伙伴回传: 它为发送 SKAdNetwork 回帖提供了将转换值解码为事件和收入的功能,这对其优化至关重要。
SKAdNetwork 客户端实施包括两个主要部分:
- SKAdNetwork 客户端实施: 这部分对于将应用程序注册到 SKAdNetwork 和智能管理 SKAdNetwork 转换值至关重要。这意味着,通过实施该部分,您将能够根据 SKAdNetwork 的属性及其相关的安装后活动优化您的营销活动。
- 服务器端集成更新:这一部分对于验证客户端实施并排除故障非常重要。通过使用 SKAdNetwork 元数据丰富通过会话和事件端点发送到 Singular 的事件和会话,Singular 可以验证应用程序端的实施是否正确。
开始使用
SKAdNetwork 客户端实施
Singular 提供了SKAdNetwork 接口代码片段,支持注册 SKAdNetwork 和管理转换值。这些代码示例负责以下部分:
- SKAdNetwork 支持和注册
- 转换值管理:
- 代码与 Singular 的端点同步通信,根据配置的转换模型接收下一个转换值。它报告事件/会话/收入,作为回应,获得下一个转换值,这是一个编码数字,代表在 Singular 的仪表板中配置测量的安装后活动。
- 代码还按测量周期收集 SKAdnetwork 元数据。元数据用于验证和计算下一个转换值:
- 对底层 SKAdNetwork 框架的首次调用时间戳
- 底层 SKAdNetwork 框架的最后一次调用时间戳
- 最后更新的回传值(粗略和精细回传值)
- 设备产生的总收入和总 Admon 收入
集成流程
上图说明了 S2S 客户的 SKAdNetwork 流程:
- 首先,应用程序中的代码与 Singular 服务器通信(通过 SKAdNetwork 的专用端点),根据应用程序中发生的事件/会话/收入事件同步获取最新转换值,并用此值更新 SKAdNetwork 框架。
- 其次,应用程序用 SKAdNetwork 数据丰富现有事件和会话,这些数据随后将用于验证。
- 一旦应用程序完成用新的转换值更新 SKAdNetwork 框架,且 SKAdNetwork 定时器到期,SKAdNetwork 回传将被发送到网络。
- 网络将把它转发给 Singular(通过安全设置或常规设置)。
- Singular 将通过以下方式处理回传
- 验证其签名
- 根据配置的转换模型解码转换值
- 用网络信息丰富回传内容。通过加入 SKAdNetwork 和网络活动 ID,从与合作伙伴的集成中收集数据。
- 将解码后的回帖发送给 BI 和合作伙伴
需要注意的是,SKAdNetwork 信息(包括安装和解码事件)将通过一组不同的报告/API/ETL 表和回传进行访问,以避免与现有数据集混合。在接下来的几周内,这一点尤为重要,因为您可以将 SKAdNetwork 与您现有的营销活动并行测量和测试。
SKAdNetwork 接口
此头文件定义了 SKAdNetwork (SKAN) 集成的公共接口,为 iOS 应用程序中的归因跟踪、转换值更新和收入管理提供了方法。
归因注册
在首次启动应用程序时初始化 SKAN 归因跟踪,将初始转换值设置为 0 并建立基准时间戳。如果设备拥有该应用程序的归因数据,底层 Apple API 方法会生成通知。
+ (void)registerAppForAdNetworkAttribution;
转换值管理
- 转换值的计算基于以下方法捕获的设备安装后活动和用户可动态配置的选定转换模型。
- 本节中的方法负责根据选定的转换模型和报告的安装后活动从 Singular 端点提取下一个转换值(请参阅上述文档)。
- 以下方法根据以下安装后活动更新转换值:
- 会话:对使用 SKAdNetwork 的用户的保留测量至关重要
- 转换事件:对于使用 SKAdNetwork 测量安装后转换事件至关重要
- 收入事件:对于使用 SKAdNetwork 进行收入测量至关重要
会话跟踪
管理基于会话的跟踪,以进行保留和群组分析,并为更新后操作提供可选的完成处理程序。
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;
事件跟踪
在向 Singular 发送数据之前处理转换事件跟踪,根据事件上下文更新转换值。
+ (void)updateConversionValuesAsync:(NSString *)eventName
withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;
收入管理
跟踪收入事件,保持广告货币化和常规收入的独立总数,要求在转换值更新前调用。
+ (void)updateRevenue:(double)amount
andCurrency:(NSString *)currency
isAdMonetization:(BOOL)admon;
数据检索
返回全面的 SKAN 数据,包括
- 当前和以前的细粒度转换值
- 不同回传窗口的粗略值
- 窗口锁定时间戳
- 按货币跟踪收入
- 广告货币化和常规收入的单独跟踪
+ (NSDictionary *)getSkanDetails;
实施注意事项
- 方法使用异步模式,以防止阻塞主线程
- 收入跟踪必须先于转换值更新
- 支持细粒度(0-63)和粗粒度(低/中/高)转换值
- 对不同的回传窗口进行单独跟踪
- 通过完成处理程序实现全面的错误处理
SKANSnippet.h 接口代码
//SKANSnippet.h
#import <Foundation/Foundation.h>
@interface SKANSnippet : NSObject
// Register for SKAdNetwork attribution.
// You should call this method as soon as possible once the app is launched for the first time.
// This function sets the conversion value to be 0 and updates the timestamp for additional processing.
+ (void)registerAppForAdNetworkAttribution;
// To track retention and cohorts you need to call this method for each app open.
// It reports the session details and updates the conversion value due to this session if needed.
// The callback passed to the method is optional, you can use it to run code once the conversion value is updated.
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;
// To track conversion events with SKAdNetwork you need to call this method after each event and before this event is sent to Singular.
// It reports the event details and updates the conversion value due to this event if needed.
// The callback passed to the method is optional, you can use it to run code once the conversion value is updated.
+ (void)updateConversionValuesAsync:(NSString *)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;
// To track revenue with SKAdNetwork you need to call this method before every revenue event.
// It will update the total revenue, so when you call 'updateConversionValuesAsync', the new conversion value will be determined according to the total amout of revenue.
// Note:
// 1. Call this method before calling 'updateConversionValuesAsync' to make sure that revenue is updated.
// 2. In case of retrying an event, avoid calling this method so the same revenue will not be counted twice.
+ (void)updateRevenue:(double)amount andCurrency:(NSString *)currency isAdMonetization:(BOOL)admon;
// Gets current fine, coarse, window locked values. saved in the dictionary under "fineValue", "coarseValue", "windowLock".
// In addition, contains all other relevant values for SKAN purposes.
// e.g.
// {
// "skan_current_conversion_value": 3,
// "prev_fine_value": 2,
// "skan_first_call_to_skadnetwork_timestamp": 167890942,
// "skan_last_call_to_skadnetwork_timestamp": 167831134,
// "skan_total_revenue_by_currency": { "USD": 1.2 },
// "skan_total_admon_revenue_by_currency": { "USD": 0.8 },
// "p0_coarse": 0,
// "p1_coarse": 1,
// "p2_coarse": nil,
// "p0_window_lock": 167890942,
// "p1_window_lock": nil,
// "p2_window_lock": nil,
// "p0_prev_coarse_value": 0,
// "p1_prev_coarse_value": 0,
// "p2_prev_coarse_value": nil,
// "p0_total_iap_revenue": nil,
// "p1_total_iap_revenue": nil,
// "p2_total_iap_revenue": nil,
// "p0_total_admon_revenue": nil,
// "p1_total_admon_revenue": nil,
// "p2_total_admon_revenue": nil
// }
+ (NSDictionary *)getSkanDetails;
@end
SKAdNetwork 接口实现
该代码为 iOS 应用程序实现了 Apple 的 SKAdNetwork (SKAN) 接口,管理多个回传窗口的归因跟踪、转换值和收入报告。
常量和配置
该实现定义了三个不同的回传窗口,用于跟踪用户活动和转换。
static NSInteger firstSkan4WindowInSec = 3600 * 24 * 2; // 48 hours
static NSInteger secondSkan4WindowInSec = 3600 * 24 * 7; // 7 days
static NSInteger thirdSkan4WindowInSec = 3600 * 24 * 35; // 35 days
主要功能
- 归因注册:处理初始应用程序归因设置和首次转换值跟踪
- 转换值管理:跨多个回传窗口更新和跟踪转换值。
- 收入跟踪:对广告货币化和常规收入事件进行单独跟踪。
- 数据持久性:使用 NSUserDefaults 跨应用程序会话存储 SKAN 相关数据。
- 线程安全:执行 NSLock,以便在网络调用期间进行线程安全操作。
数据存储结构
- 细粒度转换值(0-63)
- 粗略值(低/中/高)
- 按货币跟踪收入
- 回传窗口的时间戳管理
- 精细和粗略转换的前值跟踪
隐私考虑
- 实现 iOS 15.4+ 和 iOS 16.1+ 的特定功能
- 根据 Apple 隐私指南处理回溯转换值更新
- 对不同收入类型进行单独跟踪,以确保准确归因
技术说明
- 使用异步操作进行网络调用和值更新
- 对转换值进行错误处理和验证
- 支持传统和粗略的转换值跟踪
- 管理具有不同持续时间和要求的多个回传窗口
S2S 集成更新(推荐)
使用以下 SKAdNetwork 元数据更新您的 S2S 集成。
应通过会话通知端点(Session Notification Endppint)和事件通知端点(Event Notification Endppoint)将此元数据转发给报告给 Singular 的每个会话和每个事件。这些数据用于验证 SKAdNetwork 的实施。
元数据结构
转换值
使用 "数据检索 "方法提取元数据字典,并将其转发到服务器,作为会话和事件端点 API 请求的查询参数。
NSDictionary *values = [SKANSnippet getSkanDetails];
SKANSnippet.m 实现代码
// SKANSnippet.m
#import "SKANSnippet.h"
#import <StoreKit/SKAdNetwork.h>
#import <UIKit/UIKit.h>
#define SESSION_EVENT_NAME @"__SESSION__"
#define SINGULAR_API_URL @"https://sdk-api-v1.singular.net/api/v2/conversion_value"
// SKAN Keys for NSUserDefaults persistency and requests
#define CONVERSION_VALUE_KEY @"skan_current_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 @"skan_total_revenue_by_currency"
#define TOTAL_ADMON_REVENUE_BY_CURRNECY @"skan_total_admon_revenue_by_currency"
#define SKAN_UPDATED_CONVERSION_VALUE @"conversion_value"
#define SKAN_UPDATED_COARSE_VALUE @"skan_updated_coarse_value"
#define SKAN_UPDATED_LOCK_WINDOW_VALUE @"skan_updated_lock_window_value"
#define P0_COARSE @"p0_coarse"
#define P1_COARSE @"p1_coarse"
#define P2_COARSE @"p2_coarse"
#define P0_WINDOW_LOCK_TS @"p0_window_lock"
#define P1_WINDOW_LOCK_TS @"p1_window_lock"
#define P2_WINDOW_LOCK_TS @"p2_window_lock"
#define P0_PREV_FINE_VALUE @"prev_fine_value"
#define P0_PREV_COARSE_VALUE @"p0_prev_coarse_value"
#define P1_PREV_COARSE_VALUE @"p1_prev_coarse_value"
#define P2_PREV_COARSE_VALUE @"p2_prev_coarse_value"
#define TOTAL_REVENUE_P0 @"p0_total_iap_revenue"
#define TOTAL_REVENUE_P1 @"p1_total_iap_revenue"
#define TOTAL_REVENUE_P2 @"p2_total_iap_revenue"
#define TOTAL_ADMON_REVENUE_P0 @"p0_total_admon_revenue"
#define TOTAL_ADMON_REVENUE_P1 @"p1_total_admon_revenue"
#define TOTAL_ADMON_REVENUE_P2 @"p2_total_admon_revenue"
@implementation SKANSnippet
static NSInteger firstSkan4WindowInSec = 3600 * 24 * 2; //48 hours in sec
static NSInteger secondSkan4WindowInSec = 3600 * 24 * 7;
static NSInteger thirdSkan4WindowInSec = 3600 * 24 * 35;
static NSLock *lockObject;
+ (void)registerAppForAdNetworkAttribution {
if ([SKANSnippet getFirstSkanCallTimestamp] != 0) {
return;
}
if (@available(iOS 15.4, *)) {
[SKAdNetwork updatePostbackConversionValue:0 completionHandler:nil];
[SKANSnippet setFirstSkanCallTimestamp];
[SKANSnippet setLastSkanCallTimestamp];
[SKANSnippet valuesHasBeenUpdated:@(0) coarseValue:nil lockWindow:NO];
}
}
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler {
[SKANSnippet updateConversionValuesAsync:SESSION_EVENT_NAME withCompletionHandler:handler];
}
+ (void)updateConversionValuesAsync:(NSString *)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler {
if ([SKANSnippet isSkanWindowOver]) {
return;
}
[SKANSnippet getConversionValueFromServer:eventName withCompletionHandler:handler];
}
+ (void)updateRevenue:(double)amount andCurrency:(NSString *)currency isAdMonetization:(BOOL)admon {
// Update total revenues
if (amount == 0 || !currency ) {
return;
}
[SKANSnippet addToTotalRevenue:@(amount) withCurrency:currency isAdmon:admon];
}
+ (NSDictionary *)getSkanDetails {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *res = [NSMutableDictionary dictionary];
//current fine
[res setValue:[[userDefaults valueForKey:CONVERSION_VALUE_KEY] stringValue] forKey:CONVERSION_VALUE_KEY];
//prev fine
[res setValue:[[userDefaults valueForKey:P0_PREV_FINE_VALUE] stringValue] forKey:P0_PREV_FINE_VALUE];
//current coarse
[res setValue:[[userDefaults valueForKey:P0_COARSE] stringValue] forKey:P0_COARSE];
[res setValue:[[userDefaults valueForKey:P1_COARSE] stringValue] forKey:P1_COARSE];
[res setValue:[[userDefaults valueForKey:P2_COARSE] stringValue] forKey:P2_COARSE];
//prev coarse
[res setValue:[[userDefaults valueForKey:P0_PREV_COARSE_VALUE] stringValue] forKey:P0_PREV_COARSE_VALUE];
[res setValue:[[userDefaults valueForKey:P1_PREV_COARSE_VALUE] stringValue] forKey:P1_PREV_COARSE_VALUE];
[res setValue:[[userDefaults valueForKey:P2_PREV_COARSE_VALUE] stringValue] forKey:P2_PREV_COARSE_VALUE];
//lock windows ts
[res setValue:[[userDefaults valueForKey:P0_WINDOW_LOCK_TS] stringValue] forKey:P0_WINDOW_LOCK_TS];
[res setValue:[[userDefaults valueForKey:P1_WINDOW_LOCK_TS] stringValue] forKey:P1_WINDOW_LOCK_TS];
[res setValue:[[userDefaults valueForKey:P2_WINDOW_LOCK_TS] stringValue] forKey:P2_WINDOW_LOCK_TS];
//total revenues
[res setValue:[userDefaults valueForKey:TOTAL_REVENUE_BY_CURRENCY] forKey:TOTAL_REVENUE_BY_CURRENCY];
[res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_BY_CURRNECY] forKey:TOTAL_ADMON_REVENUE_BY_CURRNECY];
//revenue per window
[res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P0] forKey:TOTAL_REVENUE_P0];
[res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P1] forKey:TOTAL_REVENUE_P1];
[res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P2] forKey:TOTAL_REVENUE_P2];
[res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P0] forKey:TOTAL_ADMON_REVENUE_P0];
[res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P1] forKey:TOTAL_ADMON_REVENUE_P1];
[res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P2] forKey:TOTAL_ADMON_REVENUE_P2];
//skan TS
[res setValue:[[userDefaults valueForKey:LAST_SKAN_CALL_TIMESTAMP] stringValue] forKey:LAST_SKAN_CALL_TIMESTAMP];
[res setValue:[[userDefaults valueForKey:FIRST_SKAN_CALL_TIMESTAMP] stringValue] forKey:FIRST_SKAN_CALL_TIMESTAMP];
return res;
}
#pragma mark - internal
+ (BOOL)validateValues:(NSNumber *)conversionValue coarse:(NSNumber *)coarseValue{
if ([conversionValue intValue] < 0 || 63 < [conversionValue intValue]) {
return NO;
}
if (coarseValue) {
if ([coarseValue intValue] > 2 || [coarseValue intValue] < 0) {
return NO;
}
}
return YES;
}
+ (NSURLComponents *)prepareQueryParams:(NSString *)bundleIdentifier eventName:(NSString *)eventName {
NSURLComponents *components = [NSURLComponents componentsWithString:SINGULAR_API_URL];
NSString *API_KEY = @"YOUR API KEY";
NSString *APP_VERSION = @"YOUR APP VERSION";
NSString *IDFV = @"IDFV";
NSString *IDFA = @"IDFA";
NSMutableArray *queryItems = [@[
[NSURLQueryItem queryItemWithName:@"a" value:API_KEY],
[NSURLQueryItem queryItemWithName:@"v" value:[[UIDevice currentDevice] systemVersion]],
[NSURLQueryItem queryItemWithName:@"i" value:bundleIdentifier],
[NSURLQueryItem queryItemWithName:@"app_v" value:APP_VERSION],
[NSURLQueryItem queryItemWithName:@"n" value:eventName],
[NSURLQueryItem queryItemWithName:@"p" value:@"iOS"],
[NSURLQueryItem queryItemWithName:@"idfv" value:IDFV],
[NSURLQueryItem queryItemWithName:@"idfa" value:IDFA]
] mutableCopy];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[skanValues enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
if ([obj isKindOfClass:[NSDictionary class]]) {
[queryItems addObject:[NSURLQueryItem queryItemWithName:key value:[SKANSnippet dictionaryToJsonString:obj]]];
} else {
[queryItems addObject:[NSURLQueryItem queryItemWithName:key value:obj]];
}
}];
components.queryItems = queryItems;
return components;
}
+ (void)getConversionValueFromServer:(NSString*)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError*))handler {
if (!lockObject) {
lockObject = [NSLock new];
}
@try {
// 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];
NSString *bundleIdentifier = @"YOUR BUNDLE IDENTIFIER";
NSURLComponents *components = [SKANSnippet prepareQueryParams:bundleIdentifier eventName:eventName];
[[[NSURLSession sharedSession] dataTaskWithURL:components.URL
completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
[lockObject unlock];
if (handler) {
handler(nil, nil, NO, error);
}
return;
}
NSDictionary *parsedResponse = [SKANSnippet jsonDataToDictionary:data];
if (!parsedResponse) {
[lockObject unlock];
if (handler) {
handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
code:0
userInfo:@{NSLocalizedDescriptionKey:@"Failed parsing server response"}]);
}
return;
}
NSNumber *conversionValue = [parsedResponse objectForKey:SKAN_UPDATED_CONVERSION_VALUE];
NSNumber *coarseValue = [parsedResponse objectForKey:SKAN_UPDATED_COARSE_VALUE];
BOOL lockWindow = [[parsedResponse objectForKey:SKAN_UPDATED_LOCK_WINDOW_VALUE] boolValue];
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(nil, nil, NO, [NSError errorWithDomain:bundleIdentifier
code:0
userInfo:@{NSLocalizedDescriptionKey:reason}]);
}
}
return;
}
if(![SKANSnippet validateValues:conversionValue coarse:coarseValue]) {
if (handler) {
handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
code:0
userInfo:@{NSLocalizedDescriptionKey:@"Illegal values recieved"}]);
}
return;
}
if (![SKANSnippet getFirstSkanCallTimestamp]) {
[SKANSnippet setFirstSkanCallTimestamp];
}
[SKANSnippet setConversionValues:conversionValue coarseValue:coarseValue lockWindow:lockWindow handler:handler];
[lockObject unlock];
}] resume];
});
} @catch (id exception) {
NSLog(@"%@", exception);
}
}
+ (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];
}
+ (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];
}
+ (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];
}
+ (NSInteger)getCurrentUnixTimestamp {
return [[NSDate date] timeIntervalSince1970];
}
+ (void)setConversionValues:(NSNumber *)conversionValue coarseValue:(NSNumber *)coarse lockWindow:(BOOL)lockWindow handler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError*))handler {
@try {
__block void(^skanResultHandler)(NSError * _Nullable error) = ^(NSError * _Nullable error) {
if (handler) {
if (error) {
handler(nil, nil, NO, error);
} else {
handler(conversionValue, coarse, lockWindow, nil);
}
}
[SKANSnippet valuesHasBeenUpdated:conversionValue coarseValue:coarse lockWindow:lockWindow];
};
if (@available(iOS 16.1, *)) {
[SKAdNetwork updatePostbackConversionValue:[conversionValue integerValue] coarseValue:[SKANSnippet resolveCoarseValueFrom:coarse] lockWindow:lockWindow completionHandler:^(NSError * _Nullable error) {
skanResultHandler(error);
}];
} else {
if (@available(iOS 15.4, *)) {
[SKAdNetwork updatePostbackConversionValue:[conversionValue integerValue] completionHandler:^(NSError * _Nullable error) {
skanResultHandler(error);
}];
}
}
} @catch (id exception) {
NSLog(@"%@", exception);
}
}
+ (NSNumber *)getConversionValue {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
if (![userDefaults objectForKey:CONVERSION_VALUE_KEY]) {
return @(0);
}
return @([userDefaults integerForKey:CONVERSION_VALUE_KEY]);
}
+ (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;
}
+ (NSInteger)getCurrentSkanWindow {
NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getFirstSkanCallTimestamp];
if (timeDiff < firstSkan4WindowInSec) { return 0; }
if (timeDiff < secondSkan4WindowInSec) { return 1; }
if (timeDiff < thirdSkan4WindowInSec) { return 2; }
return -1;
}
// persist updated conversion values based on the active skan window.
+ (void)valuesHasBeenUpdated:(NSNumber *)fineValue coarseValue:(NSNumber *)coarseValue lockWindow:(BOOL)lockWindow {
NSNumber *currentPersistedFineValue;
NSNumber *currentPersistedCoarseValue;
NSInteger window = [SKANSnippet getCurrentSkanWindow];
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
switch (window) {
case 0:
currentPersistedFineValue = [userDefaults objectForKey:CONVERSION_VALUE_KEY];
currentPersistedCoarseValue = [userDefaults objectForKey:P0_COARSE];
[userDefaults setValue:fineValue forKey:CONVERSION_VALUE_KEY];
[userDefaults setValue:currentPersistedFineValue forKey:P0_PREV_FINE_VALUE];
[userDefaults setValue:coarseValue forKey:P0_COARSE];
[userDefaults setValue:currentPersistedCoarseValue forKey:P0_PREV_COARSE_VALUE];
if (lockWindow) {
[userDefaults setObject:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P0_WINDOW_LOCK_TS];
}
break;
case 1:
currentPersistedCoarseValue = [userDefaults objectForKey:P1_COARSE];
[userDefaults setValue:coarseValue forKey:P1_COARSE];
[userDefaults setValue:currentPersistedCoarseValue forKey:P1_PREV_COARSE_VALUE];
if (lockWindow) {
[userDefaults setObject:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P1_WINDOW_LOCK_TS];
}
break;
case 2:
currentPersistedCoarseValue = [userDefaults objectForKey:P2_COARSE];
[userDefaults setValue:coarseValue forKey:P2_COARSE];
[userDefaults setValue:currentPersistedCoarseValue forKey:P2_PREV_COARSE_VALUE];
if (lockWindow) {
[userDefaults setValue:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P2_WINDOW_LOCK_TS];
}
break;
}
[SKANSnippet setLastSkanCallTimestamp];
}
+ (BOOL)isSkanWindowOver {
NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getFirstSkanCallTimestamp];
return thirdSkan4WindowInSec <= timeDiff;
}
// Revenues are being accumulated and saved by Ad monetization and non ad monetization events, total sum and break down by skan windows.
+ (void)addToTotalRevenue:(NSNumber *)newRevenue withCurrency:(NSString *)currency isAdmon:(BOOL)isAdmon {
NSString *key = isAdmon ? TOTAL_ADMON_REVENUE_BY_CURRNECY : TOTAL_REVENUE_BY_CURRENCY;
[SKANSnippet addToTotalRevenue:newRevenue withCurrency:currency forKey:key];
NSInteger window = [SKANSnippet getCurrentSkanWindow];
switch (window) {
case 0:
key = isAdmon ? TOTAL_ADMON_REVENUE_P0 : TOTAL_REVENUE_P0 ;
break;
case 1:
key = isAdmon ? TOTAL_ADMON_REVENUE_P1 : TOTAL_REVENUE_P1 ;
break;
case 2:
key = isAdmon ? TOTAL_ADMON_REVENUE_P2 : TOTAL_REVENUE_P2 ;
break;
case -1:
key = nil;
return;
}
[SKANSnippet addToTotalRevenue:newRevenue withCurrency:currency forKey:key];
}
// Coarse value is being sent on requests and responses as an Int and being translated into the system defined coarse value upon API execution.
+ (NSString *)resolveCoarseValueFrom:(NSNumber *)value {
if(@available(iOS 16.1, *)) {
if (!value) {
return nil;
}
switch ([value integerValue]) {
case 0:
return SKAdNetworkCoarseConversionValueLow;
case 1:
return SKAdNetworkCoarseConversionValueMedium;
case 2:
return SKAdNetworkCoarseConversionValueHigh;
default:
return nil;
}
}
return nil;
}
+ (NSDictionary*)getTotalRevenue:(NSString *)revenueKey {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
if (![userDefaults objectForKey:revenueKey]){
[userDefaults setObject:[NSDictionary dictionary] forKey:revenueKey];
}
return [userDefaults objectForKey:revenueKey];
}
+ (void)addToTotalRevenue:(NSNumber *)newRevenue withCurrency:(NSString *)currency forKey:(NSString *)revenueKey {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *revenues = [[SKANSnippet getTotalRevenue:revenueKey] mutableCopy];
NSNumber *currentRevenue = 0;
if ([revenues objectForKey:currency]) {
currentRevenue = [revenues objectForKey:currency];
}
currentRevenue = @([currentRevenue floatValue] + [newRevenue floatValue]);
[revenues setObject:currentRevenue forKey:currency];
[userDefaults setObject:revenues forKey:revenueKey];
[userDefaults synchronize];
}
@end
应用程序生命周期流程示例
该代码演示了 iOS 应用程序生命周期中 SKAdNetwork (SKAN) 归因的关键集成点,处理应用程序启动、会话、事件和收入跟踪。
实施注意事项
- 代码使用异步方法进行转换值更新,以防止阻塞主线程
- 所有与 SKAN 相关的数据都以字典格式收集,然后发送到服务器
- 实施过程遵循 Apple 隐私优先的原则,同时仍允许进行必要的属性跟踪
- 收入跟踪包括货币价值和货币规格,以便提供准确的财务报告
应用程序首次启动
该代码将应用程序注册到 SKAdNetwork,以进行归因跟踪,并将初始会话数据发送到 Singular 的端点。仅在首次启动应用程序时运行,以建立归因跟踪。
仅在首次启动应用程序时运行,以建立归因跟踪。
[SKANSnippet registerAppForAdNetworkAttribution];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //to Singular launch EP
会话管理
该部分会在每次会话后更新转换值,并发送更新的 SKAN 详情,以跟踪用户参与情况。
[SKANSnippet updateConversionValuesAsync:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues]
事件跟踪
该代码通过更新转换值和向 Singular 的事件端点发送事件数据来处理非营收事件。
[SKANSnippet updateConversionValuesAsync:@"event_name" withCompletionHandler:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"]
收入跟踪
该部分通过更新货币收入金额和相关转换值来管理收入事件。然后将数据发送到 Singular 的事件端点,以跟踪与购买相关的活动。
[SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"];
[SKANSnippet updateConversionValuesAsync:@"revenue_event_name" withCompletionHandler:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"]
转换值 API
SKAdNetwork 转换值可通过两种方法报告:
- 在客户端直接执行 SKAdNetwork 接口--见上文
- 使用转换值 API 端点进行服务器端集成
这两种方法都能保持相同的数据流和报告完整性,因此您可以选择最适合您的技术架构的实现方式。转换值 API 端点接受与客户端接口相同的参数,确保一致的归因跟踪。
内容
转换值 API 端点
HTTP 方法和转换值端点
GET https://sdk-api-v1.singular.net/api/v2/conversion_value
必填参数
下表列出了从服务器支持转换 API 所需的参数和可选参数。所有列出的参数都是查询参数。
必填参数 | |
---|---|
API 密钥 | |
参数 | 说明 |
|
a参数指定 Singular SDK 密钥。 请从 Singular UI 的主菜单 "开发工具"下获取 SDK 密钥。 注意:请勿使用报告 API 密钥,否则会导致数据被拒绝。 示例值:
|
设备标识符参数 | |
参数 | 说明 |
|
idfa参数指定了广告商标识符 (IDFA),可帮助广告商跟踪用户操作(如广告点击、应用安装)并将其归属于特定广告系列,从而实现精确的广告定位和广告系列优化。 从 iOS 14.5 开始,用户必须通过应用程序跟踪透明度 (ATT) 框架选择加入,然后应用程序才能访问 IDFA。如果用户不选择加入 IDFA,那么 IDFA 将不可用,从而限制了跟踪功能。
示例值:
|
参数 | 说明 |
|
idfv参数指定了供应商标识符 (IDFV),这是 Apple 分配给设备的唯一标识符,专门针对特定供应商或开发者。该标识符在特定设备上来自同一供应商的所有应用程序中保持一致,允许供应商在其应用程序生态系统中跟踪用户行为和交互,而无需识别用户个人身份。
示例值:
|
设备参数 | |
参数 | 说明 |
|
p参数指定应用程序的平台。由于此 API 仅用于 iOS,因此该值必须是 iOS。 示例值:
|
参数 | 说明 |
|
v参数指定会话时设备的操作系统版本。 示例值:
|
应用程序参数 | |
参数 | 说明 |
|
i参数指定应用程序标识符。 这是 iOS 应用程序的捆绑 ID。(区分大小写) 示例值:
|
参数 | 说明 |
|
app_v参数指定应用程序版本。 示例
|
事件参数 | |
参数 | 说明 |
|
n参数指定跟踪事件的名称。
示例值:
|
转换值参数 | |
参数 | 说明 |
支持的平台:
|
上一次会话/事件通知时的最新 SKAdNetwork 转换值。这是一个介于(0-63)之间的整数。
示例值:
|
参数 | 说明 |
支持的平台:
|
上一次会话/事件通知时,postback_sequence 1 的最新 SKAdNetwork 粗转换值。这是一个介于(0-2)之间的整数。
示例值:
|
参数 | 支持的平台 |
支持的平台:
|
上一次会话/事件通知时,postback_sequence 2 的最新 SKAdNetwork 粗转换值。这是一个介于(0-2)之间的整数。
示例值:
|
收入跟踪参数 | |
参数 | 支持的平台 |
支持的平台:
|
使用 IAP 或所有收入模式时必须使用。设备产生的 IAP 收入的当前总和,不包括任何广告货币化收入。
示例值:
|
参数 | 说明 |
支持的平台:
|
使用 Admon 或所有收入转换模式时必须使用。设备产生的广告货币化收入的当前总和。
示例值:
|
时间戳参数 | |
参数 | 支持的平台 |
支持的平台:
|
首次调用底层 SKAdNetwork API 的 Unix 时间戳。
示例值:
|
参数 | 说明 |
支持的平台:
|
发出会话通知时,最近一次调用底层 SKAdNetwork API 的 Unix 时间戳。
示例值:
|
请求正文
调用此方法时,请勿提供请求正文。必须使用带查询参数的 GET 方法发送请求。
请求示例
以下代码示例可能不代表所有支持的参数。在执行请求时,请确保包含上面列出的所有所需参数,并在从生产实例发送数据前验证所传递的值是否正确。建议使用唯一的 `i`参数(应用程序标识符)进行开发和测试。
PYTHON
import requests
params = {
'a': 'sdk_key_here',
'p': 'iOS',
'i': 'com.singular.app',
'v': '16.1',
'idfa': 'DFC5A647-9043-4699-B2A5-76F03A97064B',
'idfv': '21DB6612-09B3-4ECC-84AC-B353B0AF1334',
'n': '__SESSION__',
'app_v': '1.2.3',
'skan_current_conversion_value': 7,
'p1_coarse': 0,
'p2_coarse': 1,
'skan_total_revenue_by_currency': {"USD":9.99},
'skan_total_admon_revenue_by_currency': {"USD":1.2},
'skan_first_call_to_skadnetwork_timestamp': 1510090877,
'skan_last_call_to_skadnetwork_timestamp': 1510090877
}
response = requests.get('https://sdk-api-v1.singular.net/api/v2/conversion_value', params=params)
print(response.json())
CURL
curl -G 'https://sdk-api-v1.singular.net/api/v2/conversion_value' \
--data-urlencode 'a=sdk_key_here' \
--data-urlencode 'p=iOS' \
--data-urlencode 'i=com.singular.app' \
--data-urlencode 'v=16.1' \
--data-urlencode 'idfa=DFC5A647-9043-4699-B2A5-76F03A97064B' \
--data-urlencode 'idfv=21DB6612-09B3-4ECC-84AC-B353B0AF1334' \
--data-urlencode 'n=__SESSION__' \
--data-urlencode 'app_v=1.2.3' \
--data-urlencode 'skan_current_conversion_value=7' \
--data-urlencode 'p1_coarse=0' \
--data-urlencode 'p2_coarse=1' \
--data-urlencode 'skan_total_revenue_by_currency={"USD":9.99}' \
--data-urlencode 'skan_total_admon_revenue_by_currency={"USD":1.2}' \
--data-urlencode 'skan_first_call_to_skadnetwork_timestamp=1510040127' \
--data-urlencode 'skan_last_call_to_skadnetwork_timestamp=1510090877'
HTTP
GET /api/v2/conversion_value
?a=sdk_key_here
&p=iOS
&i=com.singular.app
&v=16.1
&idfa=DFC5A647-9043-4699-B2A5-76F03A97064B
&idfv=21DB6612-09B3-4ECC-84AC-B353B0AF1334
&n=__SESSION__
&app_v=1.2.3
&skan_current_conversion_value=7
&p1_coarse=0
&p2_coarse=1
&skan_total_revenue_by_currency=%7B%22USD%22%3A9.99%7D
&skan_total_admon_revenue_by_currency=%7B%22USD%22%3A1.2%7D
&skan_first_call_to_skadnetwork_timestamp=1510090877
&skan_last_call_to_skadnetwork_timestamp=1510090877 HTTP/1.1
Host: sdk-api-v1.singular.net
Accept: application/json
JAVA 示例
// Base URL
String baseUrl = "https://sdk-api-v1.singular.net/api/v2/conversion_value";
// Parameters
Map < String, String > params = new HashMap < > ();
params.put("a", "sdk_key_here");
params.put("p", "iOS");
params.put("i", "com.singular.app");
params.put("v", "16.1");
params.put("idfa", "DFC5A647-9043-4699-B2A5-76F03A97064B");
params.put("idfv", "21DB6612-09B3-4ECC-84AC-B353B0AF1334");
params.put("n", "__SESSION__");
params.put("app_v", "1.2.3");
params.put("skan_current_conversion_value", "7");
params.put("p1_coarse", "0");
params.put("p2_coarse", "1");
params.put("skan_first_call_to_skadnetwork_timestamp", "1510040127");
params.put("skan_last_call_to_skadnetwork_timestamp", "1510090877");
// JSON objects
JSONObject revenueJson = new JSONObject().put("USD", 9.99);
params.put("skan_total_revenue_by_currency", revenueJson.toString());
JSONObject admonJson = new JSONObject().put("USD", 1.2);
params.put("skan_total_admon_revenue_by_currency", admonJson.toString());
// Build URL with encoded parameters
StringBuilder urlBuilder = new StringBuilder(baseUrl);
urlBuilder.append('?');
for (Map.Entry < String, String > entry: params.entrySet()) {
if (urlBuilder.length() > baseUrl.length() + 1) {
urlBuilder.append('&');
}
urlBuilder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
.append('=')
.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
}
// Create connection
URL url = new URL(urlBuilder.toString());
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
// Get response
int responseCode = conn.getResponseCode();
BufferedReader in = new BufferedReader(
new InputStreamReader(conn.getInputStream())
);
String inputLine;
StringBuilder response = new StringBuilder();
while ((inputLine = in .readLine()) != null) {
response.append(inputLine);
} in .close();
// Check application-level status
System.out.println("HTTP Status Code: " + responseCode);
System.out.println("Response: " + response.toString());
// Disconnect
conn.disconnect();
请求响应
以下是一个成功的 API 响应,其中返回了一个新的转换值。
HTTP 响应 | |
---|---|
|
响应体中没有任何错误或原因的200 - ok表示请求已发送到队列中进行处理。 响应:
|
响应参数
下表定义了响应参数。
键 | 说明 | 示例 值 |
---|---|---|
|
新的精细转换值 |
|
|
新的粗换算值 |
|
|
与 SKAN 回传测量周期相对应:
表示应更新哪个粗换算值键:即 p0_coarse、p1_coarse、p2_coarse |
|
|
已成功处理 |
|
可能的响应错误
- 距离上次转换更新已超过 24 小时(28032 小时),更新窗口已关闭:
- 计算小时数为
(skan_last_call_to_skadnetwork_timestamp) - (skan_first_call_to_skadnetwork_timestamp)
- 计算小时数为
- 未知平台错误 - 非 iOS 平台
- 转换管理:给出的参数 %x 无效
- 转换管理:未找到应用程序的转换模型:%x
- 测量周期无效: %x
- 转换管理:无法找到所有者的货币: %customer
可选参数
下表列出了用于支持 SKAdNetwork 第 4 版的可选参数。所有列出的参数都是查询参数。
可选参数 | |
---|---|
转换值参数 | |
参数 | 说明 |
支持的平台:
|
非必填项。此键由 skan_current_conversion_value 键映射而来。换句话说,在评估要返回给响应的 skan_updated_coarse_value 时,模型不会使用 p0_coarse,而是使用 skan_current_conversion_value。这是一个介于 (0-2) 之间的整数。
示例值:
|
参数 | 说明 |
支持的平台:
|
p0 先前的粗略值。这是一个介于(0-2)之间的整数。
示例值:
|
参数 | 说明 |
支持的平台:
|
p1 先前的粗略值。这是一个介于(0-2)之间的整数。
示例值:
|
支持的平台: iOS 16.1+
|
p2 的上一个粗略值。这是一个介于 (0-2) 之间的整数。
示例值:
|
收入跟踪 | |
支持的平台:
|
p0 的 IAP 收入总额,不包括广告货币化收入。
示例值
|
支持的平台:
|
p1 的 IAP 收入总额(不包括广告货币化收入)。
示例值:
|
支持的平台:
|
p2 的 IAP 收入总额(不包括广告货币化收入)。
示例值:
|
支持的平台:
|
p0 的广告货币化收入总额。
示例值:
|
支持的平台:
|
p1 的广告货币化收入总额。
示例值:
|
支持的平台:
|
p2 的广告盈利总收入。
示例值:
|
时间戳参数 | |
参数 | 说明 |
支持的平台:
|
p0 上一次带窗口锁更新的 Unix 时间戳 注意:Singular转换模型目前不考虑窗口锁定,但最终可能会考虑。
示例值:
|
参数 | 说明 |
支持的平台:
|
p1 上一次带窗口锁更新的 Unix 时间戳 注意:Singular转换模型目前不考虑窗口锁定,但最终可能会考虑。
示例值:
|
参数 | 说明 |
支持的平台:
|
p2 上一次带窗口锁更新的 Unix 时间戳 注意:Singular转换模型目前不考虑窗口锁定,但最终可能会考虑。
示例值
|