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

文档
要实施较新版本的 SKAdNetwork,请参阅《SKAdNetwork 4 实施指南》

SKAdNetwork 概述

SKAdNetwork 是 Apple 提供的一个框架,用于对 iOS 上的应用程序安装广告活动进行隐私友好的测量(了解更多信息)。该框架有助于衡量应用程序安装广告活动的转换率,而不会泄露用户的身份信息。

SKAdNetwork 如何工作?

在 SKAdNetwork 中,归因过程由 App Store 通过苹果公司的服务器进行。然后,将归因信息与用户标识符和时间信息分离,并发送给网络。

Screen_Shot_2020-09-16_at_18.57.56.png

当广告被点击并打开商店时,发布应用程序和网络会提供一些基本信息,如网络、发布者和营销活动 ID。如果广告商应用程序已启动并注册 SKAdNetwork,设备就会向网络发送成功转换通知。它将在报告附加值的同时报告可由广告应用程序报告的转换值。

该通知将在首次发布至少 24 小时后发送,并且不会包含任何设备或用户身份信息。

此外,App Store 在处理过程中不会让广告应用程序知道原始广告和发布者。这样,网络就会收到安装通知,而对安装用户一无所知。

使用 SKAdNetwork 时应注意什么?

SKAdNetwork 有一些主要优势。它为您提供以下所有信息:

  • 最后点击归因,无需同意即可使用
  • 来源、营销活动和发布商细分
  • 安装后转换值(多达 64 个离散值)
  • 验证安装的加密签名

不过,SKAdNetwork 目前的形式还很简陋,需要多个实体精心实施和协调,才能确保其正常运行。

以下是 SKAdNetwork 目前的一些限制:

  • 没有用户级数据
  • 没有透视属性
  • 转换值范围有限:
    • 每次安装/重新安装只有一个转换事件
    • 最多 64 个转换值(6 位)
  • 粒度有限:
    • 最多 100 个营销活动值
    • 广告组和创意级别无表示
  • 无LTV/长队列
  • 欺诈风险:
    • 转换值本身没有签名(可被操纵)
    • 回传可以复制。

为了克服这些问题,Singular 发布了 SKAdNetwork 实施的公共标准,并发布了几篇博文,帮助您了解 SKAdNEtwork:

SKAdNetwork S2S实施

SKAdNetwork 解决方案由以下部分组成:

  • 实施SKAdNetwork的客户端代码
  • 来自所有网络的回传验证和聚合
  • 欺诈保护:
    • 签名验证和交易 ID 删除。
    • 与网络进行安全设置,以验证未签名的参数(转换值和地理数据)。
  • 转换值管理: 可在 Singular 的仪表板上动态配置哪些安装后活动应编码到 SKAdNetwork 转换值中。
  • 报告:翻译有限的 SKAdNetwork 营销活动 ID,并使用更多营销参数和粒度来丰富数据。
  • 合作伙伴回传: 它为发送 SKAdNetwork 的回帖提供了将转换值解码为事件和收入的功能,这对其优化至关重要。

对于使用服务器到服务器 Singular 集成的客户,SKAdNetwork 的实施包括两个主要部分:

  1. SKAdNetwork 客户端实施: 这部分对于向 SKAdNetwork 注册应用程序和智能管理 SKAdNetwork 转换值至关重要。这意味着,通过实施它,您将能够根据 SKAdNetwork 归属及其相关的安装后活动优化您的营销活动。
  2. S2S 集成更新(可选):这部分对于验证客户端实施和排除故障非常重要。通过使用 SKAdNetwork 数据(转换值和更新时间戳)丰富通过 Singular 的 S2S 会话和事件端点发送到 SIngular today 的事件和会话,Singular 可以验证应用程序端的实施是否正确。

SKAdNetwork 客户端实施

Singular 提供了支持注册 SKAdNetwork 和管理转换值的代码示例。这些代码示例负责以下部分:

  1. SKAdNetwork 支持和注册
  2. 转换值管理:
    • 代码与 Singular 的端点同步通信,根据配置的转换模型接收下一个转换值。它报告事件/会话/收入,作为回应,获得下一个转换值,这是一个编码数字,代表在 Singular 的仪表板中配置测量的安装后活动。
    • 代码还会收集 SKAdnetwork 元数据,用于验证和计算下一个转换值:
      • 底层 SKAdNetwork 框架的首次调用时间戳
      • 底层 SKAdNetwork 框架的最后一次调用时间戳
      • 最后更新的转换值

S2S 集成更新

必须更新

一旦有了活动转换模型,我们的 S2S 端点就会开始返回一个名为 "conversion_value "的新 int 字段,其中包含客户端代码中要更新的下一个值。

可选

为了验证集成并排除潜在的实施问题,我们建议使用我们的代码示例来扩展您当前的 S2S 集成。Singular 的 S2S 事件和会话端点已经支持获取额外的 SKAdNetwork 参数,例如

  • 最新转换值
  • 调用底层 SKAdNetwork 框架的最后时间戳
  • 调用底层SKAdNetwork框架的第一个时间戳

集成流程

Screen_Shot_2020-09-16_at_18.59.13.png

上图说明了 S2S 客户的 SKAdNetwork 流程:

  1. 首先,应用程序中的代码与 Singular 服务器通信(通过 SKAdNetwork 专用端点),根据应用程序中发生的事件/会话/收入事件同步获取最新转换值,并将此值更新到 SKAdNetwork 框架。
  2. 其次,应用程序用 SKAdNetwork 数据丰富现有事件和会话,这些数据随后将用于验证。
  3. 一旦应用程序完成用新的转换值更新 SKAdNetwork 框架,且 SKAdNetwork 定时器到期,SKAdNetwork 回传将被发送到网络。
  4. 网络将把它转发给 Singular(通过安全设置或常规设置)。
  5. Singular 将通过以下方式处理回传
    • 验证其签名
    • 根据配置的转换模型解码转换值
    • 根据与合作伙伴的集成情况和 SKAdNetwork 内部营销活动 ID,用更多面向外部的营销参数丰富回传内容
    • 将解码后的回帖发送给 BI 和合作伙伴

需要注意的是,SKAdNetwork 信息(包括安装和解码事件)将通过一组不同的报告/API/ETL 表和回传进行访问,以避免与现有数据集混合。在接下来的几周内,这一点尤为重要,您可以将 SKAdNetwork 与您现有的营销活动同时进行测量和测试。

SKAdNetwork 本地 iOS 代码示例

SKAdNetwork 界面

该界面包括以下 SKAdNetwork 组件:

注册 SKAdNetwork:

  • 该方法负责将您的应用程序注册到 SKAdNetwork。如果设备有该应用的归属数据,底层 Apple API 方法会生成一个通知,并启动一个 24 小时计时器。
  • 设备会在计时器到期后的 0-24 小时内将安装通知发送到广告网络的回传端点。
  • 请注意,如果您更新的转换值大于之前的值,就会重置第一个计时器,进入新的 24 小时间隔(更多信息请点击此处)。

转换值管理和更新:

  • 转换值是根据以下方法捕获的设备安装后活动和用户可动态配置的选定转换模型计算得出的。
  • 本节中的方法负责根据选定的转换模型和报告的安装后活动从 Singular 端点提取下一个转换值(请参阅上述文档)。
  • 以下方法根据以下安装后活动更新转换值:
      • 会话:对使用 SKAdNetwork 的用户的保留测量至关重要
      • 转换事件:对于使用 SKAdNetwork 测量安装后转换事件至关重要
      • 收入事件:对于使用 SKAdNetwork 进行收入测量至关重要
//  SKANSnippet.h


#import <Foundation/Foundation.h>

@interface
SKANSnippet : NSObject

// Register for SKAdNetwork attribution.

// You should call this method as soon as possible once app is launched

+ (void)registerAppForAdNetworkAttribution;

// To track retention and cohorts you need to call this method after 

// each session. It reports the session details and updates the conversion 

// value due to this session if needed. The conversion value will be 

// updated only if the new value is greater than the previous value.

// The callback passed to the method is optional, you can use it to 

// run code once the conversion value is updated. 

+ (void)updateConversionValueAsync:(void(^)(int, NSError*))handler;

// To track conversion events with SKAdNetwork you need to call this 

// method after each event. 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)updateConversionValueAsync:(NSString*)eventName
  withCompletionHandler:(void(^)(int, NSError*))handler;

// To track revenue with SKAdNetwork you need to call this 

// method before each  revenue event .

// It will update the total revenue, so when you call 'updateConversionValueAsync', 

// the new conversion value will be determined according to the total amount of revenue.

// Note:

// 1. Call this method before calling 'updateConversionValueAsync' to 

// make sure that revenue is updated.

// 2. In case of retrying an event, avoid calling this method

// so the same revenue will not count twice.

+ (void)updateRevenue:(double)amount andCurrency:(NSString*)currency;

// Gets the current conversion value (nil if none)

+ (NSNumber *)getConversionValue;

 @end

SKAdNetwork 接口实现

//  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

@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 = @"";
        
        components.queryItems = @[
            [NSURLQueryItem queryItemWithName:@"a" value:@""],
            [NSURLQueryItem queryItemWithName:@"i" value:bundleIdentifier],
            [NSURLQueryItem queryItemWithName:@"app_v" value:@""],
            [NSURLQueryItem queryItemWithName:@"n" value:eventName],
            [NSURLQueryItem queryItemWithName:@"p" value:@"iOS"],
            [NSURLQueryItem queryItemWithName:@"idfv" value:@""],
            [NSURLQueryItem queryItemWithName:@"idfa" value:@""],
            [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;
}

SKAdNetwork 元数据和实用程序方法

本节负责实现上述实现中使用的实用程序。请注意,这些实用程序对于维护和存储计算下一个转换值所需的元数据至关重要

我们将执行以下实用程序:

将值保存到 UserDefaults(以及检索它们):

  • 对底层 SKAdNetwork API 的首次调用时间戳
  • 最后一次调用 SKAdNetwork API 的时间戳
  • 按货币计算的总收入

在不同表示法之间转换值:

  • 字典到 JSON 字符串
  • JSON 转换为字典
+ (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 集成(该元数据应在报告给 Singular 的每个会话和事件中转发,以便进行 SKAdNetwork 实施验证):

  • skan_conversion_value - 最新转换值
  • skan_first_call_timestamp - 首次调用底层 SKAdNetwork API 的 Unix 时间戳
  • skan_last_call_timestamp - 最近一次调用底层 SKAdNetwork API 的 Unix 时间戳

下面的代码片段展示了如何提取这些值:

NSDictionary *values = @{
    @"skan_conversion_value":
      [[SKANSnippet getConversionValue] stringValue]],
    @"skan_first_call_timestamp": 
      [NSString stringWithFormat:@"%ld", [SKANSnippet getFirstSkanCallTimestamp]],
    @"skan_last_call_timestamp": 
      [NSString stringWithFormat:@"%ld", [SKANSnippet getLastSkanCallTimestamp]]
};

现在,一旦您将这些参数发送到服务器端,就可以通过我们的服务器到服务器 API 端点转发这些参数。要了解更多信息,请在我们的服务器到服务器 API 参考资料中搜索这些参数。

应用程序生命周期流程示例

// On app launch

[SKANSnippet registerAppForAdNetworkAttribution];

// After each session is handled

[SKANSnippet updateConversionValueAsync:handler];

// After handling non-revenue events

[SKANSnippet updateConversionValueAsync:@"event_name" 
  withCompletionHandler:handler];

// After handling revenue events

[SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"];
[SKANSnippet updateConversionValueAsync:@"revenue_event_name" withCompletionHandler:handler];

代码更改日志

  • 2020 年 10 月 1 日
    • 修复 [重要]`getConversionValueFromServer` 中的锁定机制未正确启动的问题
    • 改进: 从服务器响应中返回失败原因
  • 2020/09/23
    • 修复[重要]`setConversionValue`现在调用`updateConversionValue`。
    • 改进: 从服务器检索最新转换值时的错误处理
  • 2020/09/15
    • 已更改 [重要]: 如果未设置转换值,`getConversionValue`返回的默认值是0,而不是空值
    • 已改进:收入报告和处理
    • 改进了:收入报告和处理异步检索下一个转换值

测试您的实施

转换管理

为模拟应用程序流程并测试您的实施情况,请使用以下测试端点替换转换管理生产端点(SINGULAR_API_URL)

建议的测试流程:

  • 在应用程序中生成 3 个不同的事件。
    • 如果一切执行正确
      • 每个事件发生后,都要调用 updateConversionValueAsync(确保默认转换值初始化为 0)。
      • 测试端点从应用程序接收当前转换值并返回下一个值。
  • 记录返回的转换值,确保一切正常。
    • 测试端点的返回值介于 0-2 之间。
    • 因此,第三次调用后,它将返回一个空字符串,最后一个值将用作 SKAdNetwork 的最终转换值。