服务器到服务器 - SKAdNetwork 3 实现指南(旧版)

旧版 — SKAdNetwork 3。 本指南介绍旧版 SKAdNetwork 3.0 实现方式,仅为现有集成保留。对于所有新的和当前的实现,请使用 SKAdNetwork 4 实现指南

SKAdNetwork 3.0 S2S 实现指南(旧版)

使用服务器到服务器集成实现 Apple 的 SKAdNetwork 3.0 框架,以进行隐私合规的 iOS 归因,从而在不泄露用户标识符的情况下衡量应用安装广告系列的表现。


概述

什么是 SKAdNetwork?

SKAdNetwork 是 Apple 的隐私保护型归因框架,能够在 iOS 上衡量应用安装广告 系列的转化率,而无需用户级别的标识符。

该框架通过 App Store 服务器处理归因,在将归因信息发送给广告网络之前, 将其与用户标识符和时间数据断开关联。

Apple 文档: SKAdNetwork Framework Reference


SKAdNetwork 的工作原理

归因过程完全通过 Apple 的基础设施进行,在实现广告系列表现衡量的同时确保 用户隐私。

SKAdNetwork 归因流程

归因流程:

  1. 广告点击: 用户点击包含 SKAdNetwork 签名(带有网络、发布商和广告系列 ID)的广告
  2. 打开 App Store: 设备存储归因数据并 打开 App Store 进行安装
  3. 应用启动: 用户首次安装并启动应用
  4. 注册: 应用向 SKAdNetwork 框架注册 以发出安装成功的信号
  5. Conversion Value: 应用可选地更新 conversion value(0-63),代表安装后的活动
  6. 计时器窗口: 设备在首次启动或最后一次 conversion value 更新后等待 24 小时以上
  7. Postback: App Store 向广告网络发送归因 postback, 其中包含广告系列 ID、conversion value 和加密 签名

隐私优先设计:

  • Postback 不包含任何设备或用户标识符
  • 至少 24 小时的延迟可防止时间关联
  • 应用永远不知道用户点击了哪个广告
  • 网络永远不知道具体是哪个用户进行了安装

功能与限制

SKAdNetwork 提供:

  • 最后点击归因: 无需用户同意 或 ATT 选择加入即可运作
  • 广告系列细分: 来源、广告系列和发布商 粒度
  • Conversion Value: 64 个离散值(0-63),用于 安装后衡量
  • 欺诈防护: 加密签名验证 归因的真实性

当前限制:

  • 无用户级别数据: 无法追踪单个用户 的旅程
  • 无浏览归因: 仅支持基于点击的归因
  • Conversion Value 有限: 每次安装仅有单个 6 位值(0-63)
  • 粒度有限: 最多 100 个广告系列 ID,无 广告组或素材细分
  • 无长期 cohort: 转化衡量的时间范围有限
  • 欺诈风险: conversion value 本身未签名, postback 可能被重复

Singular 资源

Singular 为 SKAdNetwork 的实现和优化提供全面的资源。


Singular SKAdNetwork 解决方案

Singular 的 SKAdNetwork 解决方案提供端到端的归因管理, 从客户端实现到 postback 处理和广告系列优化。

解决方案组件

平台功能

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

组件 功能
客户端代码 用于 SKAdNetwork 框架注册和 conversion value 管理的 原生 iOS 代码示例
Postback 处理 对来自所有广告网络的 postback 进行验证和聚合, 并提供统一报告
欺诈防护 加密签名验证、事务 ID 去重 以及安全参数校验
转化管理 动态仪表盘配置,用于将安装后 活动编码为 conversion value
报告 广告系列 ID 转换及使用营销参数进行丰富, 以便进行精细化分析
合作伙伴 Postback 将解码后的 conversion value 作为事件和收入发送, 用于合作伙伴优化

S2S 集成架构

实现组件

服务器到服务器 SKAdNetwork 集成由两个主要实现 领域组成。

1. 客户端实现(必需):

  • 应用启动时进行 SKAdNetwork 框架注册
  • 基于安装后活动的智能 conversion value 管理
  • 对于使用 SKAdNetwork 归因进行广告系列优化至关重要

2. S2S 集成更新(可选):

  • 使用 SKAdNetwork 元数据丰富 Singular S2S 事件和会话
  • 支持实现验证和问题排查
  • 提供 conversion value 时间戳以便调试

客户端实现

使用 Singular 的原生 iOS 代码示例实现 SKAdNetwork 框架注册和 conversion value 管理, 以实现最佳的广告系列衡量。

实现职责

核心功能

客户端代码为 SKAdNetwork 衡量处理两个关键功能。

  1. SKAdNetwork 注册: 在应用启动后立即向框架注册, 以启用归因
  2. Conversion Value 管理:
    • 与 Singular endpoint 同步通信以接收 下一个 conversion value
    • 向 Singular 报告会话、事件和收入
    • 接收表示已配置的安装后活动的 编码 conversion value
    • 收集 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 小时内发送安装通知
  • 使用更高的值更新 conversion value 会重置计时器, 开始新的 24 小时间隔
  • Apple 参考: updateConversionValue Documentation

Conversion Value 方法

这些方法根据安装后活动和已配置的转化模型 计算并更新 conversion value。

支持的活动:

  • 会话: 对留存衡量至关重要
  • 转化事件: 对安装后事件 衡量至关重要
  • 收入事件: 对收入衡量至关重要

接口实现

完整实现代码

完整实现处理注册、转化管理以及 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

实用方法

元数据管理

实用方法管理 SKAdNetwork 元数据在 UserDefaults 中的存储, 对 conversion value 计算至关重要。

关键实现: 这些实用方法维护 准确计算 conversion value 所需的元数据。除非必要,否则请勿 修改。

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 元数据增强服务器到服务器集成, 以进行实现验证和问题排查。

必需的更新

Conversion Value 响应

一旦转化模型激活,S2S endpoint 会返回 conversion_value 整数字段,其中包含用于 客户端更新的下一个值。

响应处理: 从 S2S endpoint 响应中解析 conversion_value ,并通过客户端代码将其应用于 SKAdNetwork 框架。


可选增强功能

元数据参数

使用 SKAdNetwork 元数据丰富 S2S 会话和事件请求,以验证 集成并排查实现问题。

参数 描述
skan_conversion_value 请求时的最新 conversion value
skan_first_call_timestamp 首次调用 SKAdNetwork API 的 Unix 时间戳
skan_last_call_timestamp 最近一次调用 SKAdNetwork API 的 Unix 时间戳

元数据提取代码

使用实用方法提取 SKAdNetwork 元数据值以进行 S2S 传输。

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 Reference


集成流程

面向 S2S 客户的完整 SKAdNetwork 流程,从客户端转化 管理到 postback 处理。

SKAdNetwork S2S 集成流程

流程阶段

端到端流程

  1. Conversion Value 请求: 应用代码 与 Singular endpoint 同步通信,根据会话、事件和收入 获取最新的 conversion value
  2. 框架更新: 应用使用接收到的 conversion value 更新 SKAdNetwork 框架
  3. 元数据丰富: 应用使用 SKAdNetwork 元数据丰富 S2S 事件和会话以进行验证
  4. 计时器到期: 从最后一次更新起 24 小时以上后, SKAdNetwork 向广告网络发送 postback
  5. Postback 转发: 网络将 postback 转发到 Singular(安全设置或常规)
  6. Postback 处理: Singular 通过以下方式处理 postback:
    • 验证加密签名
    • 使用已配置的模型解码 conversion value
    • 根据合作伙伴集成使用营销参数进行丰富
    • 通过 postback 将解码后的数据发送到 BI 和合作伙伴

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


应用生命周期实现

在适当的应用生命周期节点集成 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 的测试 endpoint 验证 SKAdNetwork 实现。

测试环境

测试 Endpoint 配置

使用测试 endpoint 替换生产环境的转化管理 endpoint, 以模拟应用流程。

测试 Endpoint URL:

https://skadnetwork-testing.singular.net/api/v1/conversion_value
  • 测试 endpoint 无需 API 密钥
  • 依次返回 conversion value 0-2
  • 第三次调用后返回空字符串

测试步骤

验证步骤

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

预期行为:

  • 第一个事件:接收 conversion value 0
  • 第二个事件:接收 conversion value 1
  • 第三个事件:接收 conversion value 2
  • 第四个及以后的事件:空响应,值 2 保持不变

代码变更日志

追踪代码示例的更新和随时间应用的关键修复。

版本历史

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