iOS全埋点解决方案-用户标识

前言

​ 分析用户行为,需要标识用户。选择合适的用户标识,可以提高用户行为分析的准确性,尤其是是漏洞留存分析等,这些和用户分析相关的功能。对于唯一标识一个用户,我们需要考虑两种场景。

  • 用户登陆之前如何标识
  • 用户登陆之后如何标识

一、登陆之前

业界一般使用 iOS 设备的某个特定属性或者某几个特定属性组合方式,来唯一标识一台 iOS 设备。此时的用户 ID 一般称为设备 ID 或者匿名 ID。苹果公司为了维护整个生态系统的健康发展,也会极力阻止个人或者组织去唯一标识一台 iOS 设备。因此我们唯一能做的,就是在现有的条件及政策下,努力寻找一种最优的解决方案。

1.1 UDID

​ UDID (Unique Device Identifier,设备唯一标识符)是和设备相关且只和设备相关的。他是一个由 40 位 16 进制组成的序列。在 iOS 5 之前,我们可以通过下面的代码段获取当前设备的UDID。

// uudi = 00008020-000A4D260104003A
NSString *udid = [[UIDevice currentDevice] uniqueIdentifier];

但从 iOS 5 开始,苹果公司为了保护用户的隐私,就不在支持通过上面的方法获取 UDID。

不过我们任然可以通过下面 2 种方式获取 iOS 设备的 UDID。

(1) Xcode

​ 把手机连接上电脑,启动 Xcode,依次点击 window->Device and Simulators, 然后就可以看到你连接到电脑上的 iOS 设备,其中,Identifier 就是设备的 UDID。

image-20220406143708678

(2)蒲公英

​ 蒲公英提供了一个可以用来获取 iOS 设备 UDID 。地址:https://www.pgyer.com/tools/udid,或者扫下方二维码,按照蒲公英提示,然后就可以获取设备 UDID。

image-20220406144257621

结论: 由于从 iOS 5 开始,苹果公司不容许 iOS 应用程序通过代码获取 UDID,因此,UDID 不适合作为 iOS 设备的 ID 。

1.2 UUID

​ UUID含义是通用唯一识别码 (Universally Unique Identifier),是由一个 32 为 16 进制组成的序列,使用短横线来连接。格式为:8-4-4-4-12(数字代表位数,加上4个端横线,一共 36 位)。

C1F88522-DE2A-4315-B5BA-F557DFFAE3B9

​ UUID 能在任何时刻、不借助任何服务器的情况下生成,且在某一特定的时空下是全球唯一的。

​ 从 iOS 6 开始,iOS 应用程序可以通过 NSUUID 类来获取 UUID:

// NSUUID 获取
NSString *uuid = [NSUUID UUID].UUIDString;

// CFUUIDRef 获取
CFUUIDRef cfuuidRef = CFUUIDCreate(kCFAllocatorDefault);
NSString *uuid = (NSString *)CFBridgingRelease(CFUUIDCreateString(kCFAllocatorDefault, cfuuidRef));

生成的 UUID,系统不会做持久化存储,,因此每次调用的时候都会是获得到一个全新的 UUID,如果用户删除应用程序并再次安装,将无法做到唯一标识 iOS 设备,因此 UUID 也不适合作为 iOS 设备 ID。

1.3 MAC 地址

​ MAC 地址是用来标识互联网上的每一个站点,它是由一个 12 为的十六进制组成的序列。

C2:B3:01:60:6D:4E

​ 我们可以通过真机,点击设置->通用->关于本机->无线局域网地址,查看 iOS 设备的 MAC 地址。

image-20220406152649791

​ 凡是接入网络的设备都会有一个 MAC 地址,用来区分每个设备的唯一性。一个 iOS 设备可能会有多个 MAC 地址,这是因为它可能会有多个设备接入网络,比如 WiFi、SIM 卡等。一般的情况下,只获取 WiFi 的 MAC 地址即可,即 en0 的地址。

​ 从 iOS 7 之前,可以通过代码获取到 WiFi 的 MAC 地址。但 iOS 7 开始,苹果公司禁止 iOS 应用程序获取 MAC 地址,因为 MAC 地址也不适合作为 iOS 设备 ID。

1.4 IDFA

​ IDFA(identifier For Advertising,广告标识符),主要用于广告推广,还量等跨应用的设备追踪。它也是一个由 32 为十六进制组成的序列,格式与 UUID 一致。在同一个 iOS 设备上,同一时刻,所有的应用程序获取到的 IDFA 都是相同的。

​ 从 iOS 6 开始,我们可以利用 AdSupport.framework 库提供的方法来获取 IDFA,代码片段如下:

#import <AdSupport/AdSupport.h>

NSString *idfa = [[[ASIdentifierManager sharedManager] advertisingIdentifier] UUIDString];
模拟器运行:80DCBD13-4209-4344-848A-1F16BE48B897

但是,IDFA 的值并不是固定不变的。

目前,一下操作均会改变 IDFA 的值

  • 通过设置->通用->还原->抹掉所有内容和设置。
  • 通过 ITunes 还原设备
  • 通过设置->隐私->广告->限制广告追踪

一旦用户限制了广告追踪,我们获取到的 IDFA 将是一个固定的 IDFA,即一连串零:00000000-0000-0000-0000-000000000000。因此,在获取 IDFA 之前,我们可以利用 AdSupport.framework 库提供的接口来判断用户是否限制了广告追踪。

BOOL isLimitAdTracking = [[ASIdentifierManager sharedManager] isAdvertisingTrackingEnabled];

​ 通过设置->隐私->广告->限制广告追踪,用户一旦还原了广告标识符,系统将会生成一个全新的 IDFA。

结论:IDFA 的使用有一些限制条件,但对于上述操作,只有在特定的情况下才会发生,或者只有专业人士才有可能执行这些操作。同时,IDFA 能解决应用程序卸载重装唯一标识设备问题。因此,IDFA 目前来说比较合适作为 iOS 设备 ID 属性。

1.5 IDFV

​ IDFV(identifier For Vendor,应用开发商标识符)是为了应用开发商标识用户,适用于分析用户在应用内的行为等,它也是一个由 32 为十六进制组成的序列,格式与 UUID 一致。

​ 每一个 iOS 设备在所属同一个 Vendor 的应用里,获取到的 IDFV 是相同的。Vendor 是通过翻转后的 BundleID 的前两部分进行匹配的,如果相同就属于通一个 Vendor。和 IDFA 相比, IDFV 不会出现获取不到的场景。

​ 但 IDFV 也有一个很大的缺点:如果用户将属于此 Vendor 的所有的应用程序都卸载, IDFV 的值会被系统重置。即使重装该 Vendor 的应用程序,获取到的也是一个全新的 IDFV。一下的操作都会重置 IDFV。

  • 通过设置->通用->还原->抹掉所有内容和设置
  • 通过 iTunes 还原设备
  • 卸载设备上某个开发者账号下的所有的应用程序

​ 在 iOS 应用程序内,可以通过 UIDevice 类来获取 IDFV,代码片段如下:

NSString *idfv = [[[UIDevice currentDevice] identifierForVendor] UUIDString];

获取到的 IDFV : 80DCBD13-4209-4344-848A-1F16BE48B897

结论:和 IDFA 相比,特别是在解决应用程序卸载重装的问题上,IDFV 不太适合作为 iOS 设备 ID。

1.6 IMEI

​ IMEI(International Mobile Equipment Identity,国际移动设备身份码)是由 15 未纯数字组成的串,并且是全球唯一的。任何一部手机,在其生成并组装完成智慧,都会被写入一个全球唯一的 IMEI。我们可以通过设置->通用->关于本机,查看本机的 IMEI。

image-20220406161744827

结论:从 iOS 2 开始,苹果公司提供了相应的接口来获取 IMEI。但后来为了保护用户隐私,从 iOS 5 开始,苹果公司就不在容许应用程序获取 IMEI。因此,IMEI 也不合适作为 iOS 设备 ID。

1.7 最佳实践

​ 通过上面的介绍,他们各有优缺点,但都不是非常完美的方案。总体来说,有两个问题。1. 无法保证唯一性;2.受到相关政策的限制。关于设备 ID ,到底有没有一种完美的方案呢?很遗憾,目前看起来没有,我们只能在有限的条件和限制下,寻找一种比较完美的方案。

(1)方案一

​ 按照优先级顺序获取:IDFA->IDFV->UUID

第一步:在 SensorsAnalyticsSDK 类中,新增一个属性,用于保存 anonymousId,

然后在 SensorsAnalyticsSDK.m 文件中新增 anonymousId 声明

@interface SensorsAnalyticsSDK : NSObject
/// 设备 ID (匿名 ID)
@property (nonatomic, copy) NSString *anonymousId;
@end
@implementation SensorsAnalyticsSDK {
    NSString *_anonymousId;
}
@end

第二步:新增 - saveAnonymousId:方法,用于保存 anonymousId

- (void)saveAnonymousId:(NSString *)anonymousId {
    // 保存设备ID
    [[NSUserDefaults standardUserDefaults] setObject:anonymousId forKey:SensorsAnalyticsAnonymousId];
    [[NSUserDefaults standardUserDefaults] synchronize];
}

第三步:重写 anonymousId set 方法

- (void)setAnonymousID:(NSString *)anonymousId {
    _anonymousId = anonymousId;
    // 保存设备ID
    [self saveAnonymousId:anonymousId];
}

第四步:重写 anonymousId get 方法

- (NSString *)anonymousId {
    if (_anonymousId) {
        return _anonymousId;
    }
    
    // 从 NSUserDefaults 读取
    _anonymousId = [[NSUserDefaults standardUserDefaults] objectForKey:SensorsAnalyticsAnonymousId];
    if (_anonymousId) {
        return _anonymousId;
    }
    
    // 获取 IDFA
    Class cls = NSClassFromString(@"ASIdentifierManager");
    if (cls) {
       // 获取 ASIdentifierManager 单例对象
        id manager = [cls performSelector:@selector(sharedManager)];
        SEL selector = NSSelectorFromString(@"isAdvertisingTrackingEnabled");
        BOOL (*isAdvertisingTrackingEnabled)(id, SEL) = (BOOL (*)(id, SEL))[manager methodForSelector:selector];
        if (isAdvertisingTrackingEnabled(manager, selector)) {
            // 使用 IDFA 作为设备 ID
            _anonymousId = [(NSUUID *) [manager performSelector:@selector(advertisingIdentifier)] UUIDString];
        }
    }
    
    // 使用 IDFV 作为设备 ID
    if (!_anonymousId) {
        _anonymousId = UIDevice.currentDevice.identifierForVendor.UUIDString;
    }
    // 使用 UUID 作为设备 ID
    if (!_anonymousId) {
        _anonymousId = NSUUID.UUID.UUIDString;
    }
    
    [self saveAnonymousId:_anonymousId];
    return _anonymousId;
}

第五步:修改 - track: properties: 方法,新增 distinct_id 字段

- (void)track:(NSString *)eventName properties:(nullable NSDictionary<NSString *, id> *)properties {
    NSMutableDictionary *event = [NSMutableDictionary dictionary];
    // 设置事件 distinct_id 字段,用于唯一标识一个用户
    event[@"distinct_id"] = self.anonymousId;
    // 设置事件名称
    event[@"event"] = eventName;
    // 事件发生的时间戳,单位毫秒
    event[@"time"] = [NSNumber numberWithLong:NSDate.date.timeIntervalSince1970 *1000];
    
    NSMutableDictionary *eventProperties = [NSMutableDictionary dictionary];
    // 添加预置属性
    [eventProperties addEntriesFromDictionary:self.automaticProperties];
    // 添加自定义属性
    [eventProperties addEntriesFromDictionary:properties];
    // 判断是否是被动启动状态
    if (self.isLaunchedPassively) {
        eventProperties[@"$app_state"] = @"background";
    }
    // 设置事件属性
    event[@"propeerties"] = eventProperties;
    
    // 打印
    [self printEvent:event];
}

第六步:测试验证

{
  "propeerties" : {
    "$model" : "x86_64",
    "$manufacturer" : "Apple",
    "$lib_version" : "1.0.0",
    "$os" : "iOS",
    "$app_version" : "1.0",
    "$os_version" : "15.2",
    "$lib" : "iOS"
  },
  "event" : "$AppStart",
  "time" : 1649238293741,
  "distinct_id" : "80DCBD13-4209-4344-848A-1F16BE48B897"
}
(2)方案二

​ 使用 Keychain 工具, Keychain 是 OS X 和 iOS 都提供了一种安全存储敏感信息工具。 Keychain 的安全机制是从系统层面保证了存储的敏感新信息不会被非法读取或窃取。

1c9e8103-fae2-45f4-832c-c528d2e0c2f6

Keychain 特点:

  • 保存在 Keychain 中的数据,即使应用程序被卸载,数据仍然存在;重写安装应用程序,我们也可以从 Keychain 中读取这些数据。
  • Keychain 中的数据可以通过 Group 的方式实现应用程序之间共享,只要应用程序具有相同的 TeamID 即可。
  • 保存在 Keychain 中的数据都是经过加密的,因此非常安全。

使用 Keychain 对方案一进行优化:

第一步:新增 SensorsAnalyticsKeychainItem 工具类,用于在 keychain 中保存,读取及删除数据。

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface SensorsAnalyticsKeychainItem : NSObject

- (instancetype)init NS_UNAVAILABLE;

- (instancetype)initWithService:(NSString *)service key:(NSString *)key;

- (instancetype)initWithService:service accessGroup:(nullable NSString *)accessGroup key:(NSString *)key NS_DESIGNATED_INITIALIZER;

- (nullable NSString *)value;

- (void)update:(NSString *)value;

- (void)remove;

@end

NS_ASSUME_NONNULL_END

#import "SensorsAnalyticsKeychainItem.h"

#import <Security/Security.h>

@interface SensorsAnalyticsKeychainItem()

@property (nonatomic, strong) NSString *service;
@property (nonatomic, strong) NSString *accessGroup;
@property (nonatomic, strong) NSString *key;

@end

@implementation SensorsAnalyticsKeychainItem

- (instancetype)initWithService:(NSString *)service key:(NSString *)key {
    return [self initWithService:service accessGroup:nil key:key];
}

- (instancetype)initWithService:(id)service accessGroup:(NSString *)accessGroup key:(NSString *)key {
    self = [super init];
    if (self) {
        _service = service;
        _key = key;
        _accessGroup = accessGroup;
    }
    return self;
}

- (nullable NSString *)value {
    NSMutableDictionary *query = [SensorsAnalyticsKeychainItem keychainQueryWithService:self.service accessGroup:self.accessGroup key:self.key];
    query[(NSString *) kSecMatchLimit] = (id)kSecMatchLimitOne;
    query[(NSString *) kSecReturnAttributes] = (id)kCFBooleanTrue;
    query[(NSString *) kSecReturnData] = (id)kCFBooleanTrue;
    CFTypeRef queryResult;
    OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &queryResult);
    
    if (status == errSecItemNotFound) {
        return nil;
    }
    if (status != noErr) {
        NSLog(@"Get item value error %d", (int)status);
    }
    
    NSData *data = [(__bridge_transfer NSDictionary *)queryResult objectForKey:(NSString *)kSecValueData];
    if (!data) {
        return nil;
    }
    NSString *value = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    NSLog(@"Get item value %@", value);
    return value;
}

- (void)update:(NSString *)value {
    NSData *encodedValue = [value dataUsingEncoding:NSUTF8StringEncoding];
    
    NSMutableDictionary *query = [SensorsAnalyticsKeychainItem keychainQueryWithService:self.service accessGroup:self.accessGroup key:self.key];
    
    NSString *originalValue = [self value];
    
    if (originalValue) {
        NSMutableDictionary *arrtibutesToUpdate = [[NSMutableDictionary alloc] init];
        arrtibutesToUpdate[(NSString *)kSecValueData] = encodedValue;
        
        OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query, (__bridge CFDictionaryRef)arrtibutesToUpdate);
        if (status == noErr) {
            NSLog(@"update item ok");
        } else {
            NSLog(@"update item error %d", (int)status);
        }
    } else {
        [query setObject:encodedValue forKey:(id)kSecValueData];
        OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
        if (status == noErr) {
            NSLog(@"add item ok");
        } else {
            NSLog(@"add item error %d", (int)status);
        }
    }
    
}

- (void)remove {
    NSMutableDictionary *query = [SensorsAnalyticsKeychainItem keychainQueryWithService:self.service accessGroup:self.accessGroup key:self.key];
    OSStatus status = SecItemDelete((__bridge CFDictionaryRef)query);
    
    if (status != noErr && status != errSecItemNotFound) {
        NSLog(@"remove item %d", (int)status);
    }
}

#pragma mark - private
+ (NSMutableDictionary *)keychainQueryWithService:(NSString *)service accessGroup:(nullable NSString *)accessGroup key:(NSString *)key {
    NSMutableDictionary *query = [[NSMutableDictionary alloc] init];
    query[(NSString *)kSecClass] = (NSString *)kSecClassGenericPassword;
    query[(NSString *)kSecAttrService] = (NSString *)service;
    query[(NSString *)kSecAttrAccount] = key;
    query[(NSString *)kSecAttrAccessGroup] = accessGroup;
    return query;
}

@end

第二步:在 SensorsAnalyticsSDK.m 文件中 ,- saveAnonymousId: 方法中使用 keychain 进行保存

#import "SensorsAnalyticsKeychainItem.h"

static NSString *const SensorsAnalyticsKeychainService = @"cn.sensorsdata.SensorsAnalytics.id";

- (void)saveAnonymousId:(NSString *)anonymousId {
    // 保存设备ID
    [[NSUserDefaults standardUserDefaults] setObject:anonymousId forKey:SensorsAnalyticsAnonymousId];
    [[NSUserDefaults standardUserDefaults] synchronize];
    
    //
    SensorsAnalyticsKeychainItem *item = [[SensorsAnalyticsKeychainItem alloc] initWithService:SensorsAnalyticsKeychainService key:SensorsAnalyticsAnonymousId];
    if (anonymousId) {
        // 当设备ID 不为空时,将其保存到 keychain 中
        [item update:anonymousId];
    } else {
        [item remove];
    }
}

第三步:修改 SensorsAnalyticsSDK.m 文件中 - anonymousId get 方法

- (NSString *)anonymousId {
    if (_anonymousId) {
        return _anonymousId;
    }
    
    // 从 keychain 获取
    SensorsAnalyticsKeychainItem *item = [[SensorsAnalyticsKeychainItem alloc] initWithService:SensorsAnalyticsKeychainService key:SensorsAnalyticsAnonymousId];
    _anonymousId = item.value;
    if (_anonymousId) {
        return _anonymousId;
    }
    // 从 NSUserDefaults 读取
    _anonymousId = [[NSUserDefaults standardUserDefaults] objectForKey:SensorsAnalyticsAnonymousId];
    if (_anonymousId) {
        return _anonymousId;
    }
    
    // 获取 IDFA
    Class cls = NSClassFromString(@"ASIdentifierManager");
    if (cls) {
       // 获取 ASIdentifierManager 单例对象
        id manager = [cls performSelector:@selector(sharedManager)];
        SEL selector = NSSelectorFromString(@"isAdvertisingTrackingEnabled");
        BOOL (*isAdvertisingTrackingEnabled)(id, SEL) = (BOOL (*)(id, SEL))[manager methodForSelector:selector];
        if (isAdvertisingTrackingEnabled(manager, selector)) {
            // 使用 IDFA 作为设备 ID
            _anonymousId = [(NSUUID *) [manager performSelector:@selector(advertisingIdentifier)] UUIDString];
        }
    }
    
    // 使用 IDFV 作为设备 ID
    if (!_anonymousId) {
        _anonymousId = UIDevice.currentDevice.identifierForVendor.UUIDString;
    }
    // 使用 UUID 作为设备 ID
    if (!_anonymousId) {
        _anonymousId = NSUUID.UUID.UUIDString;
    }
    
    [self saveAnonymousId:_anonymousId];
    return _anonymousId;
}

第四步:测试验证,卸载 APP 重新安装时 distinct_id 的值不变

{
  "propeerties" : {
    "$model" : "x86_64",
    "$manufacturer" : "Apple",
    "$title" : "标题2",
    "$lib_version" : "1.0.0",
    "$os" : "iOS",
    "$app_version" : "1.0",
    "$screen_name" : "ViewController",
    "$os_version" : "15.2",
    "$lib" : "iOS"
  },
  "event" : "$AppViewScreen",
  "time" : 1649302123586,
  "distinct_id" : "68120246-9BBC-45F4-B37C-4299BA12674A"
}

二、登陆之后

​ 用户一旦注册或者登陆用于程序,那么其在用户系统里肯定是唯一的。我们可以提供 - login:方法。当应用程序获取用户登陆的 ID 智慧,通过调用 - login:方法把登陆的 ID 传给 SDK,之后用户触发事件,可使用登陆 ID 来标识。

第一步:在 SensorsAnalyticsSDK.h 文件中声明 -login:方法:并在.m文件中实现

@interface SensorsAnalyticsSDK : NSObject

/// 用户登陆设置登陆 ID
/// @param loginId 用户的登陆 ID
- (void)login:(NSString *)loginId;

@end
static NSString *const SensorsAnalyticsLoginId = @"cn.sensorsdata.login_id";

/// 登陆 ID
@property (nonatomic, copy) NSString *loginId;

- (instancetype)init {
    self = [super init];
    if (self) {
        _automaticProperties = [self collectAutomaticProperties];

        // 设置是否需是被动启动标记
        _launchedPassively = UIApplication.sharedApplication.backgroundTimeRemaining != UIApplicationBackgroundFetchIntervalNever;
        
        _loginId = [[NSUserDefaults standardUserDefaults] objectForKey:SensorsAnalyticsLoginId];
        
        // 添加应用程序状态监听
        [self setupListeners];
    }
    return self;
}

#pragma mark - login
- (void)login:(NSString *)loginId {
    self.loginId = loginId;
    
    [[NSUserDefaults standardUserDefaults] setObject:self.loginId forKey:SensorsAnalyticsLoginId];
    [[NSUserDefaults standardUserDefaults] synchronize];
}

第二步:修改 SensorsAnalyticsSDK 类别 track 中的 - track: properties: 方法,给字段 distinct_id 赋值

- (void)track:(NSString *)eventName properties:(nullable NSDictionary<NSString *, id> *)properties {
    NSMutableDictionary *event = [NSMutableDictionary dictionary];
    // 设置事件 distinct_id 字段,用于唯一标识一个用户
    event[@"distinct_id"] = self.loginId ?: self.anonymousId;
    // 设置事件名称
    event[@"event"] = eventName;
    // 事件发生的时间戳,单位毫秒
    event[@"time"] = [NSNumber numberWithLong:NSDate.date.timeIntervalSince1970 *1000];
    
    NSMutableDictionary *eventProperties = [NSMutableDictionary dictionary];
    // 添加预置属性
    [eventProperties addEntriesFromDictionary:self.automaticProperties];
    // 添加自定义属性
    [eventProperties addEntriesFromDictionary:properties];
    // 判断是否是被动启动状态
    if (self.isLaunchedPassively) {
        eventProperties[@"$app_state"] = @"background";
    }
    // 设置事件属性
    event[@"propeerties"] = eventProperties;
    
    // 打印
    [self printEvent:event];
}

第三步:测试验证,在应用程序内调用 -login 方法

 {
  "propeerties" : {
    "$model" : "x86_64",
    "$manufacturer" : "Apple",
    "$element_type" : "UIImageView",
    "$lib_version" : "1.0.0",
    "$os" : "iOS",
    "$app_version" : "1.0",
    "$screen_name" : "ViewController",
    "$os_version" : "15.2",
    "$lib" : "iOS"
  },
  "event" : "$AppClick",
  "time" : 1649311931458,
  "distinct_id" : "1234567"
}
posted @ 2022-04-27 18:02  任淏  阅读(924)  评论(0编辑  收藏  举报