前言 分析用戶行為,需要標識用戶。選擇合適的用戶標識,可以提高用戶行為分析的準確性,尤其是是漏洞留存分析等,這些和用戶分析相關的功能。對於唯一標識一個用戶,我們需要考慮兩種場景。 用戶登陸之前如何標識 用戶登陸之後如何標識 一、登陸之前 業界一般使用 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。
(2)蒲公英
蒲公英提供了一個可以用來獲取 iOS 設備 UDID 。地址:https://www.pgyer.com/tools/udid,或者掃下方二維碼,按照蒲公英提示,然後就可以獲取設備 UDID。
結論: 由於從 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 地址。
凡是接入網路的設備都會有一個 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。
結論:從 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 的安全機制是從系統層面保證了存儲的敏感新信息不會被非法讀取或竊取。
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"
}