SKAdNetwork 3.0 S2S 集成指南

要实施较新版本的 SKAdNetwork,请参阅[BETA] SKAdNetwork 4.0 S2S Implementation

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
  
  // 注册 SkAdNetwork 归属。
  // 应用程序启动后,应尽快调用此方法
  + (void)registerAppForAdNetworkAttribution;
  
  /* 要跟踪保留率和队列,需要在每次会话后调用此方法。它会报告会话详情,并在需要时更新该会话的转换值。只有当新值大于前值时,才会更新转换值。 传递给该方法的回调是可选的;您可以在转换值更新后使用它来运行代码。*/
  + (void)updateConversionValueAsync:(void(^)(int, NSError*))handler;
  
  /* 要使用 SKAdNetwork 跟踪转换事件,您需要在每个 EVENT 事件后调用此方法。它将报告事件详情,并在需要时更新该事件导致的转换值。 传递给该方法的回调是可选的,您可以在转换值更新后使用它来运行代码。*/
  + (void)updateConversionValueAsync:(NSString*)eventName
withCompletionHandler:(void(^)(int, NSError*))handler; /* 要使用 SKAdNetwork 跟踪收入,您需要在每次收入事件之前调用此方法。 它将更新总收入,因此当您调用 "updateConversionValueAsync "时、 新的转换值将根据总收入确定。
请注意: 1. 在调用 "updateConversionValueAsync "之前调用此方法,以确保收入已更新。
2. 在重试事件时,避免调用此方法 这样相同的收入就不会被计算两次。*/ + (void)updateRevenue:(double)amount andCurrency:(NSString*)currency; // 获取当前转换值(无则为 nil) + (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 参考资料中搜索这些参数。

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

  // 应用程序启动时
  [SKANSnippet registerAppForAdNetworkAttribution];
  
  // 处理完每个会话后
  [SKANSnippet updateConversionValueAsync:handler];
  
  // 处理无收入事件后
  [SKANSnippet updateConversionValueAsync:@"event_name" 
withCompletionHandler:handler]; // 处理完收入事件后 [SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"]; [SKANSnippet updateConversionValueAsync:@"revenue_event_name" withCompletionHandler:handler];

代码更改日志

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

测试您的实施

转换管理

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

    • https://skadnetwork-testing.singular.net/api/v1/conversion_value
    • 无需 API 密钥

建议的测试流程:

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