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

文档

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 Attribution Flow

归因流程

  1. 广告点击:用户点击含有 SKAdNetwork 签名(包含网络、发布商和营销活动 ID)的广告
  2. 应用商店打开:设备存储归因数据并打开 App Store 进行安装
  3. 应用程序启动:用户首次安装并启动应用程序
  4. 注册:应用程序向 SKAdNetwork 框架注册,以表示安装成功
  5. 转换值:应用程序可选择更新代表安装后活动的转换值(0-63)
  6. 定时窗口:设备在首次启动或最后一次转换值更新后等待 24+ 小时
  7. 回传:应用商店向广告网络发送带有广告系列 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 测量的两个关键功能。

  1. SKAdNetwork 注册:启动后立即在框架中注册应用程序,以启用归因功能
  2. 转换值管理
    • 与 Singular 端点同步通信,以接收下一个转换值
    • 向 Singular 报告会话、事件和收入
    • 接收编码转换值,代表配置的安装后活动
    • 收集 SKAdNetwork 元数据(首次调用时间戳、最后一次调用时间戳、当前值

SKAdNetwork 接口

方法定义

接口包括框架注册、会话跟踪、事件跟踪和收入测量方法。

SKANSnippet.h
//  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
//  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 元数据存储,这对转换值计算至关重要。

关键实施:这些实用程序维护准确计算转换值所需的元数据。如无必要,请勿修改。

Utility Methods
+ (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 元数据值。

Objective-C
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 流程。

SKAdNetwork S2S Integration Flow

流程阶段

端到端流程

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

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


应用程序生命周期实施

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

实施示例

方法放置

Objective-C
// 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
  • 第三次调用后返回空字符串

测试程序

验证步骤

  1. 更新端点:用测试终点 URL 替换SINGULAR_API_URL常量
  2. 初始化转换值:确保默认转换值初始化为 0
  3. 生成事件:在应用程序内触发 3 个不同的事件
  4. 验证更新:确认在每个事件后调用updateConversionValueAsync
  5. 记录值:记录返回的转换值,以验证进程是否正确(0 → 1 → 2)
  6. 确认完成:第三次调用后,验证空响应和最终保留值

预期行为

  • 第一个事件:接收转换值 0
  • 第二个事件接收转换值 1
  • 第三个事件接收转换值 2
  • 第四+个事件:空响应,值 2 继续存在

代码更新日志

跟踪随着时间推移应用的代码示例更新和关键修复。

版本历史

日期 更改
2020 年 10 月 1 日
  • 修复 [关键]:纠正了getConversionValueFromServer 初始化中的锁定机制
  • 改进:服务器响应失败原因现已在错误处理中返回
2020 年 9 月 23 日
  • 修复 [重要]: setConversionValue 现在能正确调用updateConversionValue
  • 已改进:增强了从服务器检索转换值时的错误处理功能
2020年 9月 15日
  • 更改了 [重要]:当未设置时,getConversionValue默认值从空更改为 0
  • 改进:收入报告和处理增强
  • 改进:收入报告和处理增强异步转换值检索实现