iOS 最優(yōu)無痕埋點方案
在移動互聯(lián)網時代,對于每個公司、企業(yè)來說,用戶的行為數(shù)據(jù)非常重要。重要到什么程度,用戶在這個頁面停留多久、點擊了什么按鈕、瀏覽了什么內容、什么手機、什么網絡環(huán)境、App什么版本等都需要清清楚楚。一些大廠的蠻多業(yè)務成果都是基于用戶操作行為進行推薦后二次轉換。另一方面是以日志的作用幫助開發(fā)者分析線上問題的一種輔助手段。
那么有了上述的訴求,那么技術人員如何滿足這些需求?引出來了一個技術點-“埋點”
作為一個開發(fā)者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:812157648,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!
0x01. 埋點手段
業(yè)界中對于代碼埋點主要有3種主流的方案:代碼手動埋點、可視化埋點、無痕埋點。簡單說說這幾種埋點方案。
- 代碼手動埋點:根據(jù)業(yè)務需求(運營、產品、開發(fā)多個角度出發(fā))在需要埋點地方手動調用埋點接口,上傳埋點數(shù)據(jù)。
- 可視化埋點:通過可視化配置工具完成采集節(jié)點,在前端自動解析配置并上報埋點數(shù)據(jù),從而實現(xiàn)可視化“無痕埋點”
- 無痕埋點:通過技術手段,完成對用戶行為數(shù)據(jù)無差別的統(tǒng)計上傳的工作。后期數(shù)據(jù)分析處理的時候通過技術手段篩選出合適的數(shù)據(jù)進行統(tǒng)計分析。
0x02. 技術選型
1. 代碼手動埋點
該方案情況下,如果需要埋點,則需要在工程代碼中,寫埋點相關代碼。因為侵入了業(yè)務代碼,對業(yè)務代碼產生了污染,顯而易見的缺點是埋點的成本較高、且違背了單一原則。
例1:假如你需要知道用戶在點擊“購買按鈕”時的相關信息(手機型號、App版本、頁面路徑、停留時間、動作等等),那么就需要在按鈕的點擊事件里面去寫埋點統(tǒng)計的代碼。這樣明顯的弊端就是在之前業(yè)務邏輯的代碼上面又多出了埋點的代碼。由于埋點代碼分散、埋點的工作量很大、代碼維護成本較高、后期重構很頭痛。
例2:假如 App 采用了 Hybrid 架構,當 App 的第一版本發(fā)布的時候 H5 的關鍵業(yè)務邏輯統(tǒng)計是由 Native 定義好關鍵邏輯(比如H5調起了Native的分享功能,那么存在一個分享的埋點事件)的橋接。假如某天增加了一個掃一掃功能,未定義掃一掃的埋點橋接,那么 H5 頁面變動的時候,Native 埋點代碼不去更新的話,變動的 H5 的業(yè)務就未被精確統(tǒng)計。
優(yōu)點:產品、運營工作量少,對照業(yè)務映射表就可以還原出相關業(yè)務場景、數(shù)據(jù)精細無須大量的加工和處理
缺點:開發(fā)工作量大、前期需要和運營、產品指定的好業(yè)務標識,以便產品和運營進行數(shù)據(jù)統(tǒng)計分析
2. 可視化埋點
可視化埋點的出現(xiàn),是為解決代碼埋點流程復雜、成本高、新開發(fā)的頁面(H5、或者服務端下發(fā)的 json 去生成相應頁面)不能及時擁有埋點能力
前端在「埋點編輯模式」下,以“可視化”的方式去配置、綁定關鍵業(yè)務模塊的路徑到前端可以唯一確定到view的xpath過程。
用戶每次操作的控件,都生成一個 xpath 字符串,然后通過接口將 xpath 字符串(view在前端系統(tǒng)中的唯一定位。以 iOS 為例,App名稱、控制器名稱、一層層view、同類型view的序號:“GoodCell.21.RetailTableView.GoodsViewController.*baoApp”)到真正的業(yè)務模塊(“寶App-商城控制器-分銷商品列表-第21個商品被點擊了”)的映射關系上傳到服務端。xpath 具體是什么在下文會有介紹。
之后操作 App 就生成對應的 xpath 和埋點數(shù)據(jù)(開發(fā)者通過技術手段將從服務端獲取的關鍵數(shù)據(jù)塞到前端的 UI 控件上。 iOS 端為例, UIView 的 accessibilityIdentifier 屬性可以設置我們從服務端獲取的埋點數(shù)據(jù))上傳到服務端。
優(yōu)點:數(shù)據(jù)量相對準確、后期數(shù)據(jù)分析成本低
缺點:前期控件的唯一識別、定位都需要額外開發(fā);可視化平臺的開發(fā)成本較高;對于額外需求的分析可能會比較困難
3. 無痕埋點
通過技術手段無差別地記錄用戶在前端頁面上的行為。可以正確的獲取 PV、UV、IP、Action、Time 等信息。
缺點:前期開發(fā)統(tǒng)計基礎信息的技術產品成本較高、后期數(shù)據(jù)分析數(shù)據(jù)量很大、分析成本較高(大量數(shù)據(jù)傳統(tǒng)的關系型數(shù)據(jù)庫壓力大)
優(yōu)點:開發(fā)人員工作量小、數(shù)據(jù)全面、無遺漏、產品和運營按需分析、支持動態(tài)頁面的統(tǒng)計分析
4. 如何選擇
結合上述優(yōu)缺點,我們選擇了無痕埋點+可視化埋點結合的技術方案。
怎么說呢?對于關鍵的業(yè)務開發(fā)結束上線后、通過可視化方案(類似于一個界面,想想看 Dreamwaver,你在界面上拖拖控件,簡單編輯下就可以生成對應的 HTML 代碼)點擊一下綁定對應關系到服務端。
那么這個對應關系是什么?我們需要唯一定位一個前端元素,那么想到的辦法就是不管 Native 和 Web 前端,控件或者元素來說就是一個樹形層級,DOM tree 或者 UI tree,所以我們通過技術手段定位到這個元素,以 Native iOS 為例子,假如點擊商品詳情頁的加入購物車按鈕會根據(jù) UI 層級結構生成一個唯一標識 “addCartButton.GoodsViewController.GoodsView.*BaoApp” 。但是用戶在使用 App 的時候,上傳的是這串東西的 MD5到服務端。
這么做有2個原因:服務端數(shù)據(jù)庫存儲這串很長的東西不是很好;埋點數(shù)據(jù)被劫持的話直接看到明文不太好。所以 MD5 再上傳。
0x03. 操刀就干
1. 數(shù)據(jù)的收集
實現(xiàn)方案由以下幾個關鍵指標:
- 現(xiàn)有代碼改動少、盡量不要侵入業(yè)務代碼去實現(xiàn)攔截系統(tǒng)事件
- 全量收集
- 如何唯一標識一個控件元素
2. 不侵入業(yè)務代碼攔截系統(tǒng)事件
以 iOS 為例。我們會想到 AOP(Aspect Oriented Programming)面向切面編程思想。動態(tài)地在函數(shù)調用前后插入相應的代碼,在 Objective-C 中我們可以利用 Runtime 特性,用 Method Swizzling 來 hook 相應的函數(shù)
為了給所有類方便地 hook,我們可以給 NSObject 添加個 Category,名字叫做 NSObject+MethodSwizzling
#pragma mark - public Method
+ (void)lbp_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
{
class_swizzleInstanceMethod(self, originalSelector, swizzledSelector);
}
+ (void)lbp_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
{
//類方法實際上是儲存在類對象的類(即元類)中,即類方法相當于元類的實例方法,所以只需要把元類傳入,其他邏輯和交互實例方法一樣。
Class class2 = object_getClass(self);
class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector);
}
#pragma mark - private method
void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL)
{
/*
Class class = [self class];
//原有方法
Method originalMethod = class_getInstanceMethod(class, originalSelector);
//替換原有方法的新方法
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
//先嘗試給源SEL添加IMP,這里是為了避免源SEL沒有實現(xiàn)IMP的情況
BOOL didAddMethod = class_addMethod(class,originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {//添加成功:表明源SEL沒有實現(xiàn)IMP,將源SEL的IMP替換到交換SEL的IMP
class_replaceMethod(class,swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {//添加失?。罕砻髟碨EL已經有IMP,直接將兩個SEL的IMP交換即可
method_exchangeImplementations(originalMethod, swizzledMethod);
}
*/
Method originMethod = class_getInstanceMethod(class, originalSEL);
Method replaceMethod = class_getInstanceMethod(class, replacementSEL);
if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod)))
{
class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod));
}else {
method_exchangeImplementations(originMethod, replaceMethod);
}
}
復制代碼
3. 全量收集
我們會想到 hook AppDelegate 代理方法、UIViewController 生命周期方法、按鈕點擊事件、手勢事件、各種系統(tǒng)控件的點擊回調方法、應用狀態(tài)切換等等。
| 動作 | 事件 |
|---|---|
| App 狀態(tài)的切換 | 給 Appdelegate 添加分類,hook 生命周期 |
| UIViewController 生命周期函數(shù) | 給 UIViewController 添加分類,hook 生命周期 |
| UIButton 等的點擊 | UIButton 添加分類,hook 點擊事件 |
| UICollectionView、UITableView 等的 | 在對應的 Cell 添加分類,hook 點擊事件 |
| 手勢事件 UITapGestureRecognizer、UIControl、UIResponder | 相應系統(tǒng)事件 |
以統(tǒng)計頁面的打開時間和統(tǒng)計頁面的打開、關閉的需求為例,我們對 UIViewController 進行 hook
static char *lbp_viewController_open_time = "lbp_viewController_open_time";
static char *lbp_viewController_close_time = "lbp_viewController_close_time";
@implementation UIViewController (lbpka)
// load 方法里面添加 dispatch_once 是為了防止手動調用 load 方法。
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
@autoreleasepool {
[[self class] lbp_swizzleMethod:@selector(viewWillAppear:) swizzledSelector:@selector(lbp_viewWillAppear:)];
[[self class] lbp_swizzleMethod:@selector(viewWillDisappear:) swizzledSelector:@selector(lbp_viewWillDisappear:)];
}
});
}
#pragma mark - add prop
- (void)setOpenTime:(NSDate *)openTime {
objc_setAssociatedObject(self,&lbp_viewController_open_time, openTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSDate *)getOpenTime{
return objc_getAssociatedObject(self, &lbp_viewController_open_time);
}
- (void)setCloseTime:(NSDate *)closeTime {
objc_setAssociatedObject(self,&lbp_viewController_close_time, closeTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSDate *)getCloseTime{
return objc_getAssociatedObject(self, &lbp_viewController_close_time);
}
- (void)lbp_viewWillAppear:(BOOL)animated {
NSString *className = NSStringFromClass([self class]);
NSString *refer = [NSString string];
//TODO:TODO 是否只埋本地有url的page
if ([self getPageUrl:className]) {
//設置打開時間
[self setOpenTime:[NSDate dateWithTimeIntervalSinceNow:0]];
if (self.navigationController) {
if (self.navigationController.viewControllers.count >=2) {
//獲取當前vc 棧中 上一個VC
UIViewController *referVC = self.navigationController.viewControllers[self.navigationController.viewControllers.count-2];
refer = [self getPageUrl:NSStringFromClass([referVC class])];
}
}
if (!refer || refer.length == 0) {
refer = @"unknown";
}
[UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer];
}
[self lbp_viewWillAppear:animated];
}
- (void)lbp_viewWillDisappear:(BOOL)animated {
NSString *className = NSStringFromClass([self class]);
if ([self getPageUrl:className]) {
[self setCloseTime:[NSDate dateWithTimeIntervalSinceNow:0]];
[UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]];
}
[self lbp_viewWillDisappear:animated];
}
#pragma mark - private method
- (NSString *)p_calculationTimeSpend {
if (![self getOpenTime] || ![self getCloseTime]) {
return @"unknown";
}
NSTimeInterval aTimer = [[self getCloseTime] timeIntervalSinceDate:[self getOpenTime]];
int hour = (int)(aTimer/3600);
int minute = (int)(aTimer - hour*3600)/60;
int second = aTimer - hour*3600 - minute*60;
return [NSString stringWithFormat:@"%d",second];
}
@end
復制代碼
4. 如何唯一標識一個控件元素
xpath 是移動端定義可操作區(qū)域的唯一標識。既然想通過一個字符串標識前端系統(tǒng)中可操作的控件,那么 xpath 需要2個指標:
- 唯一性:在同一系統(tǒng)中不存在不同控件有著相同的 xpath
- 穩(wěn)定性:不同版本的系統(tǒng)中,在頁面結構沒有變動的情況下,不同版本的相同頁面,相同的控件的 xpath 需要保持一致。
我們想到 Naive、H5 頁面等系統(tǒng)渲染的時候都是以樹形結構去繪制和渲染,所以我們以當前的 View 到系統(tǒng)的根元素之間的所有關鍵點(UIViewController、UIView、UIView容器(UITableView、UICollectionView等)、UIButton...)串聯(lián)起來這樣就唯一定位了控件元素。
為了精確定位元素節(jié)點,參看下圖
假設一個 UIView 中有三個子 view,先后順序是:label、button1、button2,那么深度依次為: 0、1、2。假如用戶做了某些操作將 label1 從父 view 中被移除了。此時 UIView 只有 2 個子view:button1、button2,而且深度變?yōu)榱耍?、1。

可以看出僅僅由于其中某個子 view 的改變,卻導致其它子 view 的深度都發(fā)生了變化。因此,在設計的時候需要注意,在新增/移除某一 view 時,盡量減少對已有 view 的深度的影響,調整了對節(jié)點的深度的計算方式:采用當前 view 位于其父 view 中的所有 與當前 view 同類型 子view 中的索引值。
我們再看一下上面的這個例子,最初 label、button1、button2 的深度依次是:0、0、1。在 label 被移除后,button1、button2 的深度依次為:0、1??梢钥闯觯谶@個例子中,label 的移除并未對 button1、button2 的深度造成影響,這種調整后的計算方式在一定程度上增強了 xpath 的抗干擾性。
另外,調整后的深度的計算方式是依賴于各節(jié)點的類型的,因此,此時必須要將各節(jié)點的名稱放到viewPath中,而不再是僅僅為了增加可讀性。
在標識控件元素的層級時,需要知道「當前 view 位于其父 view 中的所有 與當前 view 同類型 子view 中的索引值」。參看上圖,如果不是同類型的話,則唯一性得不到保證。
5. 同類型的 view 的唯一定位問題
有個問題,比如我們點擊的元素是 UITableViewCell,那么它雖然可以定位到類似于這個標示 xxApp.GoodsViewController.GoodsTableView.GoodsCell,同類型的 Cell 有多個,所以單憑借這個字符串是沒有辦法定位具體的那個 Cell 被點擊了。
當然有解決方案啦。
-
找出當前元素在父層同類型元素中的索引。根據(jù)當前的元素遍歷當前元素的父級元素的子元素,如果出現(xiàn)相同的元素,則需要判斷當前元素是所在層級的第幾個元素
對當前的控件元素的父視圖的全部子視圖進行遍歷,如果存在和當前的控件元素同類型的控件,那么需要判斷當前控件元素在同類型控件元素中的所處的位置,那么則可以唯一定位。舉例:GoodsCell-3.GoodsTableView.GoodsViewController.xxApp
//UIResponder分類
- (NSString *)lbp_identifierKa
{
// if (self.xq_identifier_ka == nil) {
if ([self isKindOfClass:[UIView class]]) {
UIView *view = (id)self;
NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath];
NSMutableString *str = [NSMutableString string];
//特殊的 加減購 因為帶有spm但是要區(qū)分加減 需要帶TreeNode
NSString *className = [NSString stringWithUTF8String:object_getClassName(view)];
if (!view.accessibilityIdentifier || [className isEqualToString:@"lbpButton"]) {
[str appendString:sameViewTreeNode];
[str appendString:@","];
}
while (view.nextResponder) {
[str appendFormat:@"%@,", NSStringFromClass(view.class)];
if ([view.class isSubclassOfClass:[UIViewController class]]) {
break;
}
view = (id)view.nextResponder;
}
self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@"%@",str]];
// self.xq_identifier_ka = [NSString stringWithFormat:@"%@",str];
}
// }
return self.xq_identifier_ka;
}
// UIView 分類
- (NSString *)obtainSameSuperViewSameClassViewTreeIndexPat
{
NSString *classStr = NSStringFromClass([self class]);
//cell的子view
//UITableView 特殊的superview (UITableViewContentView)
//UICollectionViewCell
BOOL shouldUseSuperView =
([classStr isEqualToString:@"UITableViewCellContentView"]) ||
([[self.superview class] isKindOfClass:[UITableViewCell class]])||
([[self.superview class] isKindOfClass:[UICollectionViewCell class]]);
if (shouldUseSuperView) {
return [self obtainIndexPathByView:self.superview];
}else {
return [self obtainIndexPathByView:self];
}
}
- (NSString *)obtainIndexPathByView:(UIView *)view
{
NSInteger viewTreeNodeDepth = NSIntegerMin;
NSInteger sameViewTreeNodeDepth = NSIntegerMin;
NSString *classStr = NSStringFromClass([view class]);
NSMutableArray *sameClassArr = [[NSMutableArray alloc]init];
//所處父view的全部subviews根節(jié)點深度
for (NSInteger index =0; index < view.superview.subviews.count; index ++) {
//同類型
if ([classStr isEqualToString:NSStringFromClass([view.superview.subviews[index] class])]){
[sameClassArr addObject:view.superview.subviews[index]];
}
if (view == view.superview.subviews[index]) {
viewTreeNodeDepth = index;
break;
}
}
//所處父view的同類型subviews根節(jié)點深度
for (NSInteger index =0; index < sameClassArr.count; index ++) {
if (view == sameClassArr[index]) {
sameViewTreeNodeDepth = index;
break;
}
}
return [NSString stringWithFormat:@"%ld",sameViewTreeNodeDepth];
復制代碼
}

6. 同類型的view,但是點擊的意義卻不一樣。如何唯一標識?
問題5說明的是在一個界面上有多個不同的 view,他們的類型是同一種(CycleBannerView,但是數(shù)據(jù)源不一樣,那么當數(shù)據(jù)源長度大于1的時候會輪播,下面會展示 UIPageControl。如果數(shù)據(jù)源是1個,那么就不會輪播和展示 UIPageControl)。情況6是同一種類型的 View,但是根據(jù)展示的內容不一樣,點擊的意義也不一樣。也就是運營需要去知道用戶到底點擊的是哪一個。如下圖所示,「立即搶購」和「分享賺傭金」是同一種類型的 View,但是點擊意義不一樣,需要我們需要唯一標識出來。之前的方法通過 “viewPath 配合同類型的 view 去加索引值“ 的方式還是沒有辦法唯一標識出來。所以想到一個方案,給 NSObject 添加一個分類,在分類里面添加一個協(xié)議。讓需要復用但需要唯一標識的 view 去實現(xiàn)協(xié)議方法,因為是給 NSObject 分類添加的協(xié)議,所以 view 不需要去指定遵循。

關鍵步驟:
添加 NSObject 的 Category。在分類里面聲明唯一標識的協(xié)議
在生成 viewPath 的地方去拿出當前 view 的唯一標識(view 調用協(xié)議方法)。然后拼接之前拿出的 viewPath
//NSObject+UniqueIdentify.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class NSObject;
@protocol UniqueIdentify<NSObject>
@optional
- (NSString *)setUniqueIdentifier;
@end
@interface NSObject (UniqueIdentify)<UniqueIdentify>
@end
NS_ASSUME_NONNULL_END
//NSObject+UniqueIdentify.m
#import "NSObject+UniqueIdentify.h"
@implementation NSObject (UniqueIdentify)
@end
復制代碼
//MallTGoodTagView.h
extern NSString * _Nonnull const ImmediateyPurchase;
extern NSString * _Nonnull const ShareToAward;
//MallTGoodTagView.m
NSString *const ImmediateyPurchase = @"立即搶購";
NSString *const ShareToAward = @"分享賺傭金";
- (NSString *)setUniqueIdentifier
{
if (self.tagString) {
return self.tagString;
} else {
return NSStringFromClass([self class]);
}
}
復制代碼
//UIResponder Category 生成 viewPath
- (NSString *)lbp_identifierKa
{
// if (self.xq_identifier_ka == nil) {
if ([self isKindOfClass:[UIView class]]) {
UIView *view = (id)self;
NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath];
NSMutableString *str = [NSMutableString string];
//特殊的 加減購 因為帶有spm但是要區(qū)分加減 需要帶TreeNode
NSString *className = [NSString stringWithUTF8String:object_getClassName(view)];
if (!view.accessibilityIdentifier || [className isEqualToString:@"lbpButton"]) {
[str appendString:sameViewTreeNode];
[str appendString:@","];
}
while (view.nextResponder) {
if ([view respondsToSelector:@selector(setUniqueIdentifier)]) {
NSString *unqiueIdentifier = [view setUniqueIdentifier];
if (unqiueIdentifier) {
[str appendFormat:@"%@,", unqiueIdentifier];
}
}00
[str appendFormat:@"%@,", NSStringFromClass(view.class)];
if ([view.class isSubclassOfClass:[UIViewController class]]) {
break;
}
view = (id)view.nextResponder;
}
self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@"%@",str]];
// self.xq_identifier_ka = [NSString stringWithFormat:@"%@",str];
}
// }
return self.xq_identifier_ka;
}
復制代碼


7. 數(shù)據(jù)如何處理
A. 如何處理業(yè)務數(shù)據(jù)
利用系統(tǒng)提供的 accessibilityIdentifier 官方給出的解釋是標識用戶界面元素的字符串
/*
A string that identifies the user interface element.
default == nil
*/
@property(nullable, nonatomic, copy) NSString *accessibilityIdentifier NS_AVAILABLE_IOS(5_0);
服務端下發(fā)唯一標識
接口獲取的數(shù)據(jù),里面有當前元素的唯一標識。比如在 UITableView 的界面去請求接口拿到數(shù)據(jù),那么在在獲取到的數(shù)據(jù)源里面會有一個字段,專門用來存儲動態(tài)化的經常變動的業(yè)務數(shù)據(jù)。
cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categories[indexPath.section] children][indexPath.row].spmContent yy_modelToJSONString];
復制代碼
B. 基礎數(shù)據(jù)
設計上分為2個 pod 庫,一個是 TriggerKit(專門用來 hook 機會需要的所有事件,頁面停留時間、頁面標識、view標識),另一個是 Appmonitor(專門用來提供基礎數(shù)據(jù)、埋點數(shù)據(jù)的維護、上傳機制)。所以在 Appmonitor 里面有個類叫做 UserTrackDataCenter 的類,專門提供一些基礎數(shù)據(jù)(系統(tǒng)版本、操作系統(tǒng)、地理位置、網絡等信息)。
對外暴露出一些方法,用來將埋點數(shù)據(jù)交給 Appmonitor 去維護埋點數(shù)據(jù),達到合適的“機制”再去上傳埋點數(shù)據(jù)到服務端。
+ (void)clickEventUuid:(NSString *)uuid otherParam:(NSDictionary *)otherParam spmContent:(NSDictionary *)spmContent {
if (uuid) {
NSMutableDictionary *params = [[NSMutableDictionary alloc] initWithDictionary:otherParam];
params[SDGStatisticEventtagKey] = @"clickMonitorV1";
NSMutableDictionary *valueDict = [[NSMutableDictionary alloc] initWithDictionary:spmContent];
valueDict[@"xpath"] = uuid?:@"";
params[SDGStatisticEventtagValue] = valueDict?:@{};
[[AppMonotior shareInstance] traceEvent:[AMStatisticEvent eventWithInfo:params]];
}
}
復制代碼
8. 數(shù)據(jù)的上報
數(shù)據(jù)通過上面的辦法收集完了,那么如何及時、高效的上傳到后端,給運營分析、處理呢?
App 運行期間用戶會點擊非常多的數(shù)據(jù),如果實時上傳的話對于網絡的利用率較低,所以需要考慮一個機制去控制用戶產生的埋點數(shù)據(jù)的上傳。
思路是這樣的。對外部暴露出一個接口,用來將產生的數(shù)據(jù)往數(shù)據(jù)中心存儲。用戶產生的數(shù)據(jù)會先保存到 AppMonitor 的內存中去,設置一個臨界值(memoryEventMax = 50),如果存儲的值達到設置的臨界值 memoryEventMax,那么將內存中的數(shù)據(jù)寫入文件系統(tǒng),以 zip 的形式保存下來,然后上傳到埋點系統(tǒng)。如果沒有達到臨界值但是存在一些 App 狀態(tài)切換的情況,這時候需要及時保存數(shù)據(jù)到持久化。當下次打開 App 就去從本地持久化的地方讀取是否有未上傳的數(shù)據(jù),如果有就上傳日志信息,成功后刪除本地的日志壓縮包。
App 應用狀態(tài)的切換策略如下:
- didFinishLaunchWithOptions:內存日志信息寫入硬盤
- didBecomeActive:上傳
- willTerimate:內存日志信息寫入硬盤
- didEnterBackground:內存日志信息寫入硬盤
下面的代碼是 App 埋點數(shù)據(jù)的保存與上傳
// 將App日志信息寫入到內存中。當內存中的數(shù)量到達一定規(guī)模(超過設置的內存中存儲的數(shù)量)的時候就將內存中的日志存儲到文件信息中
- (void)joinEvent:(NSDictionary *)dictionary
{
if (dictionary) {
NSDictionary *tmp = [self createDicWithEvent:dictionary];
if (!s_memoryArray) {
s_memoryArray = [NSMutableArray array];
}
[s_memoryArray addObject:tmp];
if ([s_memoryArray count] >= s_flushNum) {
[self writeEventLogsInFilesCompletion:^{
[self startUploadLogFile];
}];
}
}
}
// 外界調用的數(shù)據(jù)傳遞入口(App埋點統(tǒng)計)
- (void)traceEvent:(AMStatisticEvent *)event
{
// 線程鎖,防止多處調用產生并發(fā)問題
@synchronized (self) {
if (event && event.userInfo) {
[self joinEvent:event.userInfo];
}
}
}
// 將內存中的數(shù)據(jù)寫入到文件中,持久化存儲
- (void)writeEventLogsInFilesCompletion:(void(^)(void))completionBlock
{
NSArray *tmp = nil;
@synchronized (self) {
tmp = s_memoryArray;
s_memoryArray = nil;
}
if (tmp) {
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *jsonFilePath = [weakSelf createTraceJsonFile];
if ([weakSelf writeArr:tmp toFilePath:jsonFilePath]) {
NSString *zipedFilePath = [weakSelf zipJsonFile:jsonFilePath];
if (zipedFilePath) {
[AppMonotior clearCacheFile:jsonFilePath];
if (completionBlock) {
completionBlock();
}
}
}
});
}
}
// 從App埋點統(tǒng)計壓縮包文件夾中的每個壓縮包文件上傳服務端,成功后就刪除本地的日志壓縮包
- (void)startUploadLogFile
{
NSArray *fList = [self listFilesAtPath:[self eventJsonPath]];
if (!fList || [fList count] == 0) {
return;
}
[fList enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
if (![obj hasSuffix:@".zip"]) {
return;
}
NSString *zipedPath = obj;
unsigned long long fileSize = [[[NSFileManager defaultManager] attributesOfItemAtPath:zipedPath error:nil] fileSize];
if (!fileSize || fileSize < 1) {
return;
}
// 調用接口上傳埋點數(shù)據(jù)
[self uploadZipFileWithPath:zipedPath completion:^(NSString *completionResult) {
if ([completionResult isEqual:@"OK"]) {
[AppMonotior clearCacheFile:zipedPath];
}
}];
}];
}
復制代碼
使用的時候就是在 hook 系統(tǒng)事件的時候,去調用統(tǒng)計頁面上傳數(shù)據(jù)
//UIViewController
[UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer]; // 頁面出現(xiàn)
[UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]]; //頁面消失
復制代碼

總結下來關鍵步驟:
- hook 系統(tǒng)的各種事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 應用程序、控制器生命周期。在做本來的邏輯之前添加額外的監(jiān)控代碼
- 對于點擊的元素按照視圖樹生成對應的唯一標識(addCartButton.GoodsView.GoodsViewController) 的 md5 值
- 在業(yè)務開發(fā)完畢,進入埋點的編輯模式,將 md5 和關鍵的頁面的關鍵事件(運營、產品想統(tǒng)計的關鍵模塊:App層級、業(yè)務模塊、關鍵頁面、關鍵操作)給綁定起來。比如 addCartButton.GoodsView.GoodsViewController.tbApp 對應了 tbApp-商城模塊-商品詳情頁-加入購物車功能。
- 將所需要的數(shù)據(jù)存儲下來
- 設計機制等到合適的時機去上傳數(shù)據(jù)
舉例說明一個完整的埋點上報流程
埋點模塊分為2個pod組件庫,TriggerKit 負責攔截系統(tǒng)事件,拿到埋點數(shù)據(jù)。Appmonitor 負責收集埋點數(shù)據(jù),本地持久化或內存儲存,等到合適時機去上傳埋點數(shù)據(jù)。
-
通過接口獲取數(shù)據(jù),給對應的 view 的 accessibilityIdentifier 屬性綁定埋點數(shù)據(jù)
接口拿到的數(shù)據(jù)綁定埋點數(shù)據(jù)到view -
hook 系統(tǒng)事件,點擊拿到 view,獲取 accessibilityIdentifier 屬性值
hook系統(tǒng)事件獲取accessibilityIdentifier -
將數(shù)據(jù)向的數(shù)據(jù)中心發(fā)送,數(shù)據(jù)中心處理數(shù)據(jù)(埋點數(shù)據(jù)結合App基礎信息,圖上 UserTrackDataCenter 對象)。根據(jù)情況將數(shù)據(jù)存儲到內存或者本地,等到合適的時機去上傳
攔截系統(tǒng)事件后將數(shù)據(jù)交給數(shù)據(jù)中心處理
原文作者:虛心學習的HZK
原文地址:http://002ii.cn/wX76p



