服务器到服务器 - SKAdNetwork 4 实施指南

文档

SKAdNetwork 4.0 实施指南

实施 Apple 的 SKAdNetwork 4.0 框架,利用服务器到服务器集成实现注重隐私的 iOS 归因,通过增强的回传窗口和粗略的转换值测量应用程序安装活动,同时保护用户隐私。


概述

什么是 SKAdNetwork 4.0?

SKAdNetwork (SKAN) 是苹果公司注重隐私的归因框架,通过服务器到服务器实施稳健的验证和跟踪,在保护用户隐私的同时,实现对 iOS 应用程序安装广告活动的测量。

该框架可处理归因的所有关键方面,同时通过 Apple 规定的方法维护用户隐私,因此对于在 iOS 14.5 后环境中运营的移动营销人员至关重要。


主要功能

SKAdNetwork 4.0 为营销活动优化引入了增强的测量功能和灵活性。

  • 强大的验证功能:聚合来自所有网络的回传,内置欺诈保护功能
  • 动态转换管理:转换值编码仪表板配置
  • 增强的报告功能:丰富的营销参数和精细的数据洞察力
  • 安全的合作伙伴回传:解码转换值和收入跟踪
  • 全面验证:丰富的事件和会话跟踪,用于实施验证
  • 多个回传窗口:自动时间戳管理:0-2 天、3-7 天、8-35 天
  • 收入跟踪:支持广告货币化和货币规格的常规收入

前提条件

在实施 SKAN 4.0 之前,请先熟悉 SKAdNetwork 概念和以前的版本。


Singular SKAdNetwork 解决方案

Singular 的 SKAdNetwork 解决方案提供从客户端实施到回传处理和营销活动优化的端到端归因管理。

解决方案组件

平台功能

在归因和分析工作流程中提供全面的 SKAdNetwork 支持。

组件 功能
客户端代码 用于 SKAdNetwork 框架注册和转换值管理的本地 iOS 代码示例。可使用转换值 API 端点替代服务器端方法
回传处理 对来自所有广告网络的回传进行验证和汇总,并提供统一报告
欺诈保护 加密签名验证、重复数据删除和未签名数据的安全参数验证
转换管理 动态仪表板配置,可将安装后活动编码为转换值
报告 利用营销参数对营销活动 ID 进行翻译和充实,以进行细化分析
合作伙伴回传 将解码后的转换值作为事件和收入发送,用于合作伙伴优化

实施架构

两部分实施

客户端 SKAdNetwork 实施由两个主要部分组成。

1.客户端实施(必需)

  • 在应用程序启动时注册 SKAdNetwork 框架
  • 基于安装后活动的智能转换值管理
  • 对于使用 SKAdNetwork 归因进行营销活动优化至关重要
  • 可跟踪相关的安装后活动

2.服务器端集成更新(推荐)

  • 验证客户端实施并排除故障
  • 丰富通过会话事件端点发送的事件和会话
  • 启用 SKAdNetwork 元数据验证
  • 确认应用程序端实施正确

客户端实施

使用 Singular 的本地 iOS 代码示例实施 SKAdNetwork 框架注册和转换值管理,以便利用 SKAN 4.0 功能进行最佳营销活动测量。

实施责任

核心功能

代码示例支持 SKAdNetwork 注册和智能转换值管理。

  1. SKAdNetwork 注册:启动后立即在框架中注册应用程序,以启用归因功能
  2. 转换值管理
    • 与 Singular 端点同步通信,以接收下一个转换值
    • 向 Singular 报告会话、事件和收入
    • 接收编码转换值,代表配置的安装后活动
    • 按测量期收集 SKAdNetwork 元数据,用于验证和计算

元数据收集

代码收集重要的 SKAdNetwork 元数据,用于验证和转换值计算。

  • 对底层 SKAdNetwork 框架的首次调用时间戳
  • 最后调用 SKAdNetwork 框架的时间戳
  • 最后更新的回传值(粗略和精细值)
  • 设备产生的总收入和广告货币化总收入

集成流程

端到端流程

为 S2S 客户提供从客户端转换管理到回传处理的完整 SKAdNetwork 流程。

SKAdNetwork 4.0 S2S Integration Flow

  1. 转换值请求:应用程序代码与 Singular 端点同步通信,根据会话、事件和收入获取最新转换值
  2. 框架更新:应用程序根据收到的转换值更新 SKAdNetwork 框架
  3. 丰富元数据:应用程序用 SKAdNetwork 元数据丰富 S2S 事件和会话,以进行验证
  4. 计时器过期:计时器过期后,SKAdNetwork 向广告网络发送回帖
  5. 回帖转发:网络将回帖转发给 Singular(安全设置或常规设置
  6. 回帖处理:Singular 通过以下方式处理回帖
    • 验证加密签名
    • 使用配置模型解码转换值
    • 使用来自合作伙伴集成的网络信息进行充实
    • 通过回传将解码后的数据发送给 BI 和合作伙伴

数据分离:SKAdNetwork 数据(安装和解码事件)可通过单独的报告、API、ETL 表和回传访问,以防止在测试和验证期间与现有数据集混合。


SKAdNetwork 接口

SKAdNetwork 集成的完整接口定义,提供归因跟踪、转换值更新和收入管理的方法。

方法定义

归因注册

在首次启动应用程序时初始化 SKAN 归因跟踪,将初始转换值设置为 0 并建立基线时间戳。

Objective-C
+ (void)registerAppForAdNetworkAttribution;

转换值管理

方法根据应用程序捕获的安装后活动和动态配置的选定转换模型更新转换值。

支持的活动

  • 会话:对留存率测量至关重要
  • 转换事件:对安装后活动测量至关重要
  • 收入事件收入测量的关键

会话跟踪

管理基于会话的跟踪,以进行保留和群组分析,并为更新后操作提供可选的完成处理程序。

Objective-C
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

事件跟踪

在向 Singular 发送数据之前处理转换事件跟踪,根据事件上下文更新转换值。

Objective-C
+ (void)updateConversionValuesAsync:(NSString *)eventName 
                withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

收益管理

跟踪收入事件,为广告货币化和常规收入保持不同的总计。必须在转换值更新前调用。

Objective-C
+ (void)updateRevenue:(double)amount 
          andCurrency:(NSString *)currency 
     isAdMonetization:(BOOL)admon;

数据检索

返回全面的 SKAN 数据字典,包括转换值、时间戳和收入跟踪。

字典包含

  • 当前和以前的细粒度转换值
  • 不同回传窗口的粗略值
  • 窗口锁定时间戳
  • 按货币跟踪收入
  • 广告货币化和常规收入的单独跟踪
Objective-C
+ (NSDictionary *)getSkanDetails;

实施注意事项

  • 方法使用异步模式,以防止阻塞主线程
  • 收入跟踪必须先于转换值更新
  • 支持细粒度(0-63)和粗粒度(低/中/高)转换值
  • 对不同的回传窗口进行单独跟踪
  • 通过完成处理程序实现全面的错误处理

完整接口代码

SKANSnippet.h

Objective-C
//SKANSnippet.h

#import <Foundation/Foundation.h>

@interface SKANSnippet : NSObject

// Register for SKAdNetwork attribution.
// Call this method as soon as possible on first app launch.
// Sets conversion value to 0 and updates timestamp for additional processing.
+ (void)registerAppForAdNetworkAttribution;

// Track retention and cohorts by calling for each app open.
// Reports session details and updates conversion value if needed.
// Optional callback runs once conversion value is updated.
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// Track conversion events by calling after each event and before sending to Singular.
// Reports event details and updates conversion value if needed.
// Optional callback runs once conversion value is updated.
+ (void)updateConversionValuesAsync:(NSString *)eventName 
                withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// Track revenue by calling before every revenue event.
// Updates total revenue for next conversion value calculation.
// Note:
// 1. Call before 'updateConversionValuesAsync' to ensure revenue included
// 2. Avoid calling on event retries to prevent double-counting
+ (void)updateRevenue:(double)amount 
          andCurrency:(NSString *)currency 
     isAdMonetization:(BOOL)admon;

// Gets current fine, coarse, window locked values saved in dictionary.
// Contains all relevant SKAN values including:
// - skan_current_conversion_value
// - prev_fine_value  
// - skan_first_call_to_skadnetwork_timestamp
// - skan_last_call_to_skadnetwork_timestamp
// - skan_total_revenue_by_currency
// - skan_total_admon_revenue_by_currency
// - p0_coarse, p1_coarse, p2_coarse
// - p0_window_lock, p1_window_lock, p2_window_lock
// - Previous coarse values and revenue per window
+ (NSDictionary *)getSkanDetails;

@end

SKAdNetwork 实现

Apple SKAdNetwork 4.0 界面的完整实施代码,管理多个回传窗口的归因跟踪、转换值和收入报告。

实施概述

常量和配置

该实现定义了三个不同的回传窗口,用于跟踪用户活动和转换。

Objective-C
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)
  • 粗略值(低/中/高映射到 0-2)
  • 按货币跟踪每个回传窗口的收入
  • 回传窗口和锁定状态的时间戳管理
  • 精细和粗略转换的前值跟踪

隐私考虑

  • 实现 iOS 15.4+ 和 iOS 16.1+ 的特定功能
  • 根据 Apple 隐私指南处理回传转换值更新
  • 对不同收入类型进行单独跟踪,以确保准确归因

技术说明

  • 使用异步操作进行网络调用和值更新
  • 对转换值进行错误处理和验证
  • 支持传统和粗略的转换值跟踪
  • 管理具有不同持续时间和要求的多个回传窗口

完整实现代码

SKANSnippet.m

重要:在生产使用前,请用您应用程序中的实际值替换占位符值(您的 API KEY、您的应用程序版本等)。

Objective-C
//  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_CURRENCY @"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_CURRENCY] forKey:TOTAL_ADMON_REVENUE_BY_CURRENCY];
    //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]) {
                    [lockObject unlock];
                    if (handler) {
                        handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                 code:0
                                                             userInfo:@{NSLocalizedDescriptionKey:@"Illegal values received"}]);
                    }
                    
                    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];
    
}

+ (void)setLastSkanCallTimestamp {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp] forKey:LAST_SKAN_CALL_TIMESTAMP];
    
}

+ (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_CURRENCY : 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];
    
}

@end

S2S 集成更新

加强与 SKAdNetwork 元数据的服务器到服务器集成,以便进行实施验证和故障排除(建议用于所有实施)。

元数据结构

数据检索

使用getSkanDetails 方法提取元数据字典并转发给服务器,以便在会话和事件端点 API 请求中作为查询参数添加。

关键:通过会话通知端点事件通知端点向 Singular 报告的每个会话和每个事件都应转发元数据。

Objective-C
NSDictionary *skanMetadata = [SKANSnippet getSkanDetails];

// Forward skanMetadata to your server for S2S API enrichment

应用程序生命周期实施

在适当的应用程序生命周期点集成 SKAdNetwork 方法,以便利用 SKAN 4.0 功能实现完整的归因覆盖。

实施示例

实施说明

  • 使用异步方法更新转换值,以防止阻塞主线程
  • 在服务器传输之前,以字典格式收集所有 SKAN 相关数据
  • 遵循 Apple 隐私优先的方法,同时实现基本的归因跟踪
  • 收入跟踪包括货币价值和货币规格,以便准确报告

应用程序首次发布

在 SKAdNetwork 注册应用程序,以进行归因跟踪,并将初始会话数据发送到 Singular 的端点。仅在首次启动应用程序时运行,以建立归因跟踪。

Objective-C
[SKANSnippet registerAppForAdNetworkAttribution];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues]; // to Singular launch EP

会话管理

在每次会话后更新转换值,并发送更新的 SKAN 详情,以跟踪用户参与情况。

Objective-C
[SKANSnippet updateConversionValuesAsync:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Conversion value update failed: %@", error);
    } else {
        NSLog(@"Values updated - Fine: %@, Coarse: %@, Lock: %d", fine, coarse, lock);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues];

事件跟踪

通过更新转换值和向 Singular 的事件端点发送事件数据来处理非营收事件。

Objective-C
[SKANSnippet updateConversionValuesAsync:@"event_name" 
                   withCompletionHandler:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Event conversion update failed: %@", error);
    } else {
        NSLog(@"Event values updated - Fine: %@, Coarse: %@", fine, coarse);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"];

收入跟踪

通过用货币和相关转换值更新收入金额来管理收入事件,然后将购买相关活动的数据发送到 Singular 的事件端点。

Objective-C
[SKANSnippet updateRevenue:15.53 andCurrency:@"USD" isAdMonetization:NO];
[SKANSnippet updateConversionValuesAsync:@"revenue_event_name" 
                   withCompletionHandler:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Revenue conversion update failed: %@", error);
    } else {
        NSLog(@"Revenue values updated - Fine: %@, Coarse: %@", fine, coarse);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"];

转换值 API

使用 REST API 端点报告 SKAdNetwork 转换值的服务器端替代方法,而不是客户端接口实现。

API 概述

实现方法

SKAdNetwork 转换值可通过两种方法报告,其数据流和报告完整性完全相同。

  1. 直接 SKAdNetwork 接口:客户端实施(见上文
  2. 服务器端集成:使用转换值 API 端点

转换值 API 端点接受与客户端接口相同的参数,确保一致的归因跟踪,并可灵活选择最适合技术架构的实施方式。


API 端点

HTTP 方法和 URL

GET https://sdk-api-v1.singular.net/api/v2/conversion_value

所需参数

API 密钥

参数 说明
a 开发人员工具中的单个 SDK 密钥,请勿使用报告 API 密钥。

例如 sdkKey_afdadsf7asf56

设备标识符

参数 说明
idfa 用于广告跟踪和归因的广告商标识符 (IDFA)。 从 iOS 14.5 开始,需要 ATT 框架选择加入。如果不可用,请省略(不要传递 NULL 或空字符串)。

示例:DFC5A647-9043-4699-B2A5-76F03A97064B
idfv 供应商标识符 (IDFV) - 无论 ATT 状态如何,所有请求都需要。每个供应商/开发商在其应用程序生态系统中都是唯一的。

示例:21DB6612-09B3-4ECC-84AC-B353B0AF1334

设备参数

参数 说明
p 应用程序的平台(此 API 必须为 "iOS")。

示例 iOS
v 会话时设备的操作系统版本。

示例:OS 16.1

应用程序参数

参数 说明
i 应用程序标识符(iOS 应用程序的捆绑 ID,区分大小写)。

例如 com.singular.app
app_v 应用程序版本。

示例:应用程序版本: 1.2.3

事件参数

参数 描述
n 跟踪的事件名称(最多 32 个 ASCII 字符)。会话使用__SESSION__ 。对于事件,使用通过事件 API 发送到 Singular 的相同名称和大小写。

例如 sng_add_to_cart

转换值参数

参数 说明
skan_current_conversion_value
iOS 15.4+
上一次会话/事件时的最新 SKAdNetwork 转换值(0-63)。

例如 7
p1_coarse
iOS 16.1+
postback_sequence 1 的最新 SKAdNetwork 粗转换值 (0-2)。

示例 0
p2_coarse
iOS 16.1+
postback_sequence 2 (0-2) 的最新 SKAdNetwork 粗换算值。

示例 1

收入跟踪参数

参数 说明
skan_total_revenue_by_currency
iOS 15.4 以上
IAP 或所有收入模式必填。当前 IAP 收入总计(不包括广告货币化),JSON URL 编码字符串。

示例: %7B%22USD%22%3A9.99%7D
skan_total_admon_revenue_by_currency
iOS 15.4+
Admon 或所有收入模式必填。当前广告货币化收入总计,JSON URL 编码字符串。

示例: %7B%22USD%22%3A1.2%7D

时间戳参数

参数 说明
skan_first_call_to_skadnetwork_timestamp
iOS 15.4+
首次调用底层 SKAdNetwork API 的 Unix 时间戳。

例如 1483228800
skan_last_call_to_skadnetwork_timestamp
iOS 15.4+
此会话通知时最近一次调用底层 SKAdNetwork API 的 Unix 时间戳。

示例 1483228800

请求示例

实现示例

代码示例展示了核心所需参数。实施时,请包含所有必要参数,并在投入使用前验证正确值。

PythoncURLHTTP
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())

响应格式

成功响应

HTTP 200 - 无错误或原因的 ok 响应,表示请求已发送到队列进行处理。

{
   "conversion_value":1,
   "skan_updated_coarse_value":0,
   "postback_sequence_index":0,
   "status":"ok"
}

响应参数

关键字 说明 示例
conversion_value 新的精细转换值 0-63
skan_updated_coarse_value 新的粗换算值 0-2
postback_sequence_index SKAN 回传测量周期(0=回传 1,1=回传 2,2=回传 3)。表示要更新哪个粗略值键 0-2
status 处理状态 ok

可能出现的错误

  • 距离上次转换更新已超过 24 小时(28032 小时),更新窗口已关闭
  • 未知平台错误 - 非 iOS 平台
  • 转换管理:给出的参数无效
  • 转换管理:未找到应用程序的转换模型
  • 测量周期无效
  • 转换管理:无法找到所有者的货币