1. 程式人生 > IOS開發 >史上最強無痕埋點

史上最強無痕埋點

在移動網際網路時代,對於每個公司、企業來說,使用者的行為資料非常重要。重要到什麼程度,使用者在這個頁面停留多久、點選了什麼按鈕、瀏覽了什麼內容、什麼手機、什麼網路環境、App什麼版本等都需要清清楚楚。一些大廠的蠻多業務成果都是基於使用者操作行為進行推薦後二次轉換。另一方面是以日誌的作用幫助開發者分析線上問題的一種輔助手段。

那麼有了上述的訴求,那麼技術人員如何滿足這些需求?引出來了一個技術點-“埋點”

0x01. 埋點手段

業界中對於程式碼埋點主要有3種主流的方案:程式碼手動埋點、視覺化埋點、無痕埋點。簡單說說這幾種埋點方案。

  • 程式碼手動埋點:根據業務需求(運營、產品、開發多個角度出發)在需要埋點地方手動呼叫埋點介面,上傳埋點資料。
  • 視覺化埋點:通過視覺化配置工具完成採集節點,在前端自動解析配置並上報埋點資料,從而實現視覺化“無痕埋點”
  • 無痕埋點:通過技術手段,完成對使用者行為資料無差別的統計上傳的工作。後期資料分析處理的時候通過技術手段篩選出合適的資料進行統計分析。

0x02. 技術選型

1. 程式碼手動埋點

該方案情況下,如果需要埋點,則需要在工程程式碼中,寫埋點相關程式碼。因為侵入了業務程式碼,對業務程式碼產生了汙染,顯而易見的缺點是埋點的成本較高、且違背了單一原則

例1:假如你需要知道使用者在點選“購買按鈕”時的相關資訊(手機型號、App版本、頁面路徑、停留時間、動作等等),那麼就需要在按鈕的點選事件裡面去寫埋點統計的程式碼。這樣明顯的弊端就是在之前業務邏輯的程式碼上面又多出了埋點的程式碼。由於埋點程式碼分散、埋點的工作量很大、程式碼維護成本較高、後期重構很頭痛。

例2:假如 App 採用了 Hybrid 架構,當 App 的第一版本釋出的時候 H5 的關鍵業務邏輯統計是由 Native 定義好關鍵邏輯(比如H5調起了Native的分享功能,那麼存在一個分享的埋點事件)的橋接。假如某天增加了一個掃一掃功能,未定義掃一掃的埋點橋接,那麼 H5 頁面變動的時候,Native 埋點程式碼不去更新的話,變動的 H5 的業務就未被精確統計。

優點:產品、運營工作量少,對照業務對映表就可以還原出相關業務場景、資料精細無須大量的加工和處理

缺點:開發工作量大、前期需要和運營、產品指定的好業務標識,以便產品和運營進行資料統計分析

2. 視覺化埋點

視覺化埋點的出現,是為解決程式碼埋點流程複雜、成本高、新開發的頁面(H5、或者服務端下發的 json 去生成相應頁面)不能及時擁有埋點能力

前端在「埋點編輯模式」下,以“視覺化”的方式去配置、繫結關鍵業務模組的路徑到前端可以唯一確定到view的xpath過程。

使用者每次操作的控制元件,都生成一個 xpath 字串,然後通過介面將 xpath 字串(view在前端系統中的唯一定位。以 iOS 為例,App名稱、控制器名稱、一層層view、同類型view的序號:“GoodCell.21.RetailTableView.GoodsViewController.*baoApp”)到真正的業務模組(“寶App-商城控制器-分銷商品列表-第21個商品被點選了”)的對映關係上傳到服務端。xpath 具體是什麼在下文會有介紹。

之後操作 App 就生成對應的 xpath 和埋點資料(開發者通過技術手段將從服務端獲取的關鍵資料塞到前端的 UI 控制元件上。 iOS 端為例, UIView 的 accessibilityIdentifier 屬性可以設定我們從服務端獲取的埋點資料)上傳到服務端。

優點:資料量相對準確、後期資料分析成本低

缺點:前期控制元件的唯一識別、定位都需要額外開發;視覺化平臺的開發成本較高;對於額外需求的分析可能會比較困難

3. 無痕埋點

通過技術手段無差別地記錄使用者在前端頁面上的行為。可以正確的獲取 PV、UV、IP、Action、Time 等資訊。

缺點:前期開發統計基礎資訊的技術產品成本較高、後期資料分析資料量很大、分析成本較高(大量資料傳統的關係型資料庫壓力大)

優點:開發人員工作量小、資料全面、無遺漏、產品和運營按需分析、支援動態頁面的統計分析

4. 如何選擇

結合上述優缺點,我們選擇了無痕埋點+視覺化埋點結合的技術方案。

怎麼說呢?對於關鍵的業務開發結束上線後、通過視覺化方案(類似於一個介面,想想看 Dreamwaver,你在介面上拖拖控制元件,簡單編輯下就可以生成對應的 HTML 程式碼)點選一下繫結對應關係到服務端。

那麼這個對應關係是什麼?我們需要唯一定位一個前端元素,那麼想到的辦法就是不管 Native 和 Web 前端,控制元件或者元素來說就是一個樹形層級,DOM tree 或者 UI tree,所以我們通過技術手段定位到這個元素,以 Native iOS 為例子,假如點選商品詳情頁的加入購物車按鈕會根據 UI 層級結構生成一個唯一標識 “addCartButton.GoodsViewController.GoodsView.*BaoApp” 。但是使用者在使用 App 的時候,上傳的是這串東西的 MD5到服務端。

這麼做有2個原因:服務端資料庫儲存這串很長的東西不是很好;埋點資料被劫持的話直接看到明文不太好。所以 MD5 再上傳。

0x03. 操刀就幹

1. 資料的收集

實現方案由以下幾個關鍵指標:

  • 現有程式碼改動少、儘量不要侵入業務程式碼去實現攔截系統事件
  • 全量收集
  • 如何唯一標識一個控制元件元素

2. 不侵入業務程式碼攔截系統事件

以 iOS 為例。我們會想到 **AOP(Aspect Oriented Programming)**面向切面程式設計思想。動態地在函式呼叫前後插入相應的程式碼,在 Objective-C 中我們可以利用 Runtime 特性,用 Method Swizzling 來 hook 相應的函式

為了給所有類方便地 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,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沒有實現IMP的情況
     BOOL didAddMethod = class_addMethod(class,method_getImplementation(swizzledMethod),method_getTypeEncoding(swizzledMethod));
     if (didAddMethod) {//新增成功:表明源SEL沒有實現IMP,將源SEL的IMP替換到交換SEL的IMP
     class_replaceMethod(class,swizzledSelector,method_getImplementation(originalMethod),method_getTypeEncoding(originalMethod));
     } else {//新增失敗:表明源SEL已經有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 生命週期方法、按鈕點選事件、手勢事件、各種系統控制元件的點選回撥方法、應用狀態切換等等。

動作 事件
App 狀態的切換 給 Appdelegate 新增分類,hook 生命週期
UIViewController 生命週期函式 給 UIViewController 新增分類,hook 生命週期
UIButton 等的點選 UIButton 新增分類,hook 點選事件
UICollectionView、UITableView 等的 在對應的 Cell 新增分類,hook 點選事件
手勢事件 UITapGestureRecognizer、UIControl、UIResponder 相應系統事件

以統計頁面的開啟時間和統計頁面的開啟、關閉的需求為例,我們對 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 是移動端定義可操作區域的唯一標識。既然想通過一個字串標識前端系統中可操作的控制元件,那麼 xpath 需要2個指標:

  • 唯一性:在同一系統中不存在不同控制元件有著相同的 xpath
  • 穩定性:不同版本的系統中,在頁面結構沒有變動的情況下,不同版本的相同頁面,相同的控制元件的 xpath 需要保持一致。

我們想到 Naive、H5 頁面等系統渲染的時候都是以樹形結構去繪製和渲染,所以我們以當前的 View 到系統的根元素之間的所有關鍵點(UIViewController、UIView、UIView容器(UITableView、UICollectionView等)、UIButton...)串聯起來這樣就唯一定位了控制元件元素。

為了精確定位元素節點,參看下圖

假設一個 UIView 中有三個子 view,先後順序是:label、button1、button2,那麼深度依次為: 0、1、2。假如使用者做了某些操作將 label1 從父 view 中被移除了。此時 UIView 只有 2 個子view:button1、button2,而且深度變為了:0、1。

view層級

可以看出僅僅由於其中某個子 view 的改變,卻導致其它子 view 的深度都發生了變化。因此,在設計的時候需要注意,在新增/移除某一 view 時,儘量減少對已有 view 的深度的影響,調整了對節點的深度的計算方式:採用當前 view 位於其父 view 中的所有 與當前 view 同類型 子view 中的索引值。

我們再看一下上面的這個例子,最初 label、button1、button2 的深度依次是:0、0、1。在 label 被移除後,button1、button2 的深度依次為:0、1。可以看出,在這個例子中,label 的移除並未對 button1、button2 的深度造成影響,這種調整後的計算方式在一定程度上增強了 xpath 的抗干擾性。

另外,調整後的深度的計算方式是依賴於各節點的型別的,因此,此時必須要將各節點的名稱放到viewPath中,而不再是僅僅為了增加可讀性。

在標識控制元件元素的層級時,需要知道「當前 view 位於其父 view 中的所有 與當前 view 同類型 子view 中的索引值」。參看上圖,如果不是同類型的話,則唯一性得不到保證。

5. 同類型的 view 的唯一定位問題

有個問題,比如我們點選的元素是 UITableViewCell,那麼它雖然可以定位到類似於這個標示 xxApp.GoodsViewController.GoodsTableView.GoodsCell,同類型的 Cell 有多個,所以單憑藉這個字串是沒有辦法定位具體的那個 Cell 被點選了。

當然有解決方案啦。

  • 找出當前元素在父層同類型元素中的索引。根據當前的元素遍歷當前元素的父級元素的子元素,如果出現相同的元素,則需要判斷當前元素是所在層級的第幾個元素

    對當前的控制元件元素的父檢視的全部子檢視進行遍歷,如果存在和當前的控制元件元素同類型的控制元件,那麼需要判斷當前控制元件元素在同類型控制元件元素中的所處的位置,那麼則可以唯一定位。舉例: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但是要區分加減 需要帶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根節點深度
        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根節點深度
        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,但是資料來源不一樣,那麼當資料來源長度大於1的時候會輪播,下面會展示 UIPageControl。如果資料來源是1個,那麼就不會輪播和展示 UIPageControl)。情況6是同一種類型的 View,但是根據展示的內容不一樣,點選的意義也不一樣。也就是運營需要去知道使用者到底點選的是哪一個。如下圖所示,「立即搶購」和「分享賺佣金」是同一種類型的 View,但是點選意義不一樣,需要我們需要唯一標識出來。之前的方法通過 “viewPath 配合同類型的 view 去加索引值“ 的方式還是沒有辦法唯一標識出來。所以想到一個方案,給 NSObject 新增一個分類,在分類裡面新增一個協議。讓需要複用但需要唯一標識的 view 去實現協議方法,因為是給 NSObject 分類新增的協議,所以 view 不需要去指定遵循。

關鍵步驟:

  • 新增 NSObject 的 Category。在分類裡面宣告唯一標識的協議

  • 在生成 viewPath 的地方去拿出當前 view 的唯一標識(view 呼叫協議方法)。然後拼接之前拿出的 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但是要區分加減 需要帶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:@"%@,str];
        }
//    }
    return self.xq_identifier_ka;
}
複製程式碼

改進版view唯一標識:立即搶購

改進版view唯一標識:分享賺佣金

7. 資料如何處理

A. 如何處理業務資料

利用系統提供的 accessibilityIdentifier 官方給出的解釋是標識使用者介面元素的字串

/*

A string that identifies the user interface element.

default == nil

*/

@property(nullable,nonatomic,copy) NSString *accessibilityIdentifier NS_AVAILABLE_IOS(5_0);

服務端下發唯一標識

介面獲取的資料,裡面有當前元素的唯一標識。比如在 UITableView 的介面去請求介面拿到資料,那麼在在獲取到的資料來源裡面會有一個欄位,專門用來儲存動態化的經常變動的業務資料。

cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categories[indexPath.section] children][indexPath.row].spmContent yy_modelToJSONString];
複製程式碼

B. 基礎資料

設計上分為2個 pod 庫,一個是 TriggerKit(專門用來 hook 機會需要的所有事件,頁面停留時間、頁面標識、view標識),另一個是 Appmonitor(專門用來提供基礎資料、埋點資料的維護、上傳機制)。所以在 Appmonitor 裡面有個類叫做 UserTrackDataCenter 的類,專門提供一些基礎資料(系統版本、作業系統、地理位置、網路等資訊)。

對外暴露出一些方法,用來將埋點資料交給 Appmonitor 去維護埋點資料,達到合適的“機制”再去上傳埋點資料到服務端。

+ (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. 資料的上報

資料通過上面的辦法收集完了,那麼如何及時、高效的上傳到後端,給運營分析、處理呢?

App 執行期間使用者會點選非常多的資料,如果實時上傳的話對於網路的利用率較低,所以需要考慮一個機制去控制使用者產生的埋點資料的上傳。

思路是這樣的。對外部暴露出一個介面,用來將產生的資料往資料中心儲存。使用者產生的資料會先儲存到 AppMonitor 的記憶體中去,設定一個臨界值(memoryEventMax = 50),如果儲存的值達到設定的臨界值 memoryEventMax,那麼將記憶體中的資料寫入檔案系統,以 zip 的形式儲存下來,然後上傳到埋點系統。如果沒有達到臨界值但是存在一些 App 狀態切換的情況,這時候需要及時儲存資料到持久化。當下次開啟 App 就去從本地持久化的地方讀取是否有未上傳的資料,如果有就上傳日誌資訊,成功後刪除本地的日誌壓縮包。

App 應用狀態的切換策略如下:

  • didFinishLaunchWithOptions:記憶體日誌資訊寫入硬碟
  • didBecomeActive:上傳
  • willTerimate:記憶體日誌資訊寫入硬碟
  • didEnterBackground:記憶體日誌資訊寫入硬碟

下面的程式碼是 App 埋點資料的儲存與上傳

// 將App日誌資訊寫入到記憶體中。當記憶體中的數量到達一定規模(超過設定的記憶體中儲存的數量)的時候就將記憶體中的日誌儲存到檔案資訊中
- (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];
            }];
        }
    }
}

// 外界呼叫的資料傳遞入口(App埋點統計)
- (void)traceEvent:(AMStatisticEvent *)event
{
    // 執行緒鎖,防止多處呼叫產生併發問題
    @synchronized (self) {
        if (event && event.userInfo) {
            [self joinEvent:event.userInfo];
        }
    }
}

// 將記憶體中的資料寫入到檔案中,持久化儲存
- (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埋點統計壓縮包資料夾中的每個壓縮包檔案上傳服務端,成功後就刪除本地的日誌壓縮包
- (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;
        }
        // 呼叫介面上傳埋點資料
        [self uploadZipFileWithPath:zipedPath completion:^(NSString *completionResult) {
            if ([completionResult isEqual:@"OK"]) {
                [AppMonotior clearCacheFile:zipedPath];
            }
        }];
    }];
}
複製程式碼

使用的時候就是在 hook 系統事件的時候,去呼叫統計頁面上傳資料

//UIViewController
[UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer];	// 頁面出現
[UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]];	//頁面消失
複製程式碼

繫結頁面唯一標識與功能描述的對應關係

總結下來關鍵步驟:

  1. hook 系統的各種事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 應用程式、控制器生命週期。在做本來的邏輯之前新增額外的監控程式碼
  2. 對於點選的元素按照檢視樹生成對應的唯一標識(addCartButton.GoodsView.GoodsViewController) 的 md5 值
  3. 在業務開發完畢,進入埋點的編輯模式,將 md5 和關鍵的頁面的關鍵事件(運營、產品想統計的關鍵模組:App層級、業務模組、關鍵頁面、關鍵操作)給繫結起來。比如 addCartButton.GoodsView.GoodsViewController.tbApp 對應了 tbApp-商城模組-商品詳情頁-加入購物車功能。
  4. 將所需要的資料儲存下來
  5. 設計機制等到合適的時機去上傳資料

舉例說明一個完整的埋點上報流程

埋伏模組分為2個pod元件庫,TriggerKit 負責攔截系統事件,拿到埋點資料。Appmonitor 負責收集埋點資料,本地持久化或記憶體儲存,等到合適時機去上傳埋點資料。

  1. 通過介面獲取資料,給對應的 view 的 accessibilityIdentifier 屬性繫結埋點資料

    介面拿到的資料

    繫結埋點資料到view

  2. hook 系統事件,點選拿到 view,獲取 accessibilityIdentifier 屬性值

    hook系統事件獲取accessibilityIdentifier

  3. 將資料向的資料中心傳送,資料中心處理資料(埋點資料結合App基礎資訊,圖上 UserTrackDataCenter 物件)。根據情況將資料儲存到記憶體或者本地,等到合適的時機去上傳

    攔截系統事件後將資料交給資料中心處理