史上最全的無痕埋點

在移動互聯網時代,對於每一個公司、企業來講,用戶的行爲數據很是重要。重要到什麼程度,用戶在這個頁面停留多久、點擊了什麼按鈕、瀏覽了什麼內容、什麼手機、什麼網絡環境、App什麼版本等都須要清清楚楚。一些大廠的蠻多業務成果都是基於用戶操做行爲進行推薦後二次轉換。另外一方面是以日誌的做用幫助開發者分析線上問題的一種輔助手段。前端

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

0x01. 埋點手段

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

  • 代碼手動埋點:根據業務需求(運營、產品、開發多個角度出發)在須要埋點地方手動調用埋點接口,上傳埋點數據。
  • 可視化埋點:經過可視化配置工具完成採集節點,在前端自動解析配置並上報埋點數據,從而實現可視化「無痕埋點」
  • 無痕埋點:經過技術手段,完成對用戶行爲數據無差異的統計上傳的工做。後期數據分析處理的時候經過技術手段篩選出合適的數據進行統計分析。

0x02. 技術選型

1. 代碼手動埋點

該方案狀況下,若是須要埋點,則須要在工程代碼中,寫埋點相關代碼。由於侵入了業務代碼,對業務代碼產生了污染,顯而易見的缺點是埋點的成本較高、且違背了單一原則objective-c

例1:假如你須要知道用戶在點擊「購買按鈕」時的相關信息(手機型號、App版本、頁面路徑、停留時間、動做等等),那麼就須要在按鈕的點擊事件裏面去寫埋點統計的代碼。這樣明顯的弊端就是在以前業務邏輯的代碼上面又多出了埋點的代碼。因爲埋點代碼分散、埋點的工做量很大、代碼維護成本較高、後期重構很頭痛。數據庫

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

優勢:產品、運營工做量少,對照業務映射表就能夠還原出相關業務場景、數據精細無須大量的加工和處理json

缺點:開發工做量大、前期須要和運營、產品指定的好業務標識,以便產品和運營進行數據統計分析後端

2. 可視化埋點

可視化埋點的出現,是爲解決代碼埋點流程複雜、成本高、新開發的頁面(H五、或者服務端下發的 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, 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沒有實現IMP的狀況
     BOOL didAddMethod = class_addMethod(class,originalSelector,
     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、button一、button2,那麼深度依次爲: 0、一、2。假如用戶作了某些操做將 label1 從父 view 中被移除了。此時 UIView 只有 2 個子view:button一、button2,並且深度變爲了:0、1。

view層級

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

咱們再看一下上面的這個例子,最初 label、button一、button2 的深度依次是:0、0、1。在 label 被移除後,button一、button2 的深度依次爲:0、1。能夠看出,在這個例子中,label 的移除並未對 button一、button2 的深度形成影響,這種調整後的計算方式在必定程度上加強了 xpath 的抗干擾性。

另外,調整後的深度的計算方式是依賴於各節點的類型的,所以,此時必需要將各節點的名稱放到viewPath中,而再也不是僅僅爲了增長可讀性。

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

5. 同類型的 view 的惟必定位問題

有個問題,好比咱們點擊的元素是 UITableViewCell,那麼它雖然能夠定位到相似於這個標示 xxApp.GoodsViewController.GoodsTableView.GoodsCell,同類型的 Cell 有多個,因此單憑藉這個字符串是沒有辦法定位具體的那個 Cell 被點擊了。

固然有解決方案啦。

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

    對當前的控件元素的父視圖的所有子視圖進行遍歷,若是存在和當前的控件元素同類型的控件,那麼須要判斷當前控件元素在同類型控件元素中的所處的位置,那麼則能夠惟必定位。舉例:GoodsCell-3.GoodsTableView.GoodsViewController.xxApp

    //UIResponder分類

{
// 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];

}

![頁面惟一標識示意圖](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-09-UserTrack.png)



### 6.  同類型的view,可是點擊的意義卻不同。如何惟一標識?

問題5說明的是在一個界面上有多個不一樣的 view,他們的類型是同一種(CycleBannerView,可是數據源不同,那麼當數據源長度大於1的時候會輪播,下面會展現 UIPageControl。若是數據源是1個,那麼就不會輪播和展現 UIPageControl)。狀況6是同一種類型的 View,可是根據展現的內容不同,點擊的意義也不同。也就是運營須要去知道用戶到底點擊的是哪個。以下圖所示,「當即搶購」和「分享賺佣金」是同一種類型的 View,可是點擊意義不同,須要咱們須要惟一標識出來。以前的方法經過 **「viewPath 配合同類型的 view 去加索引值「** 的方式仍是沒有辦法惟一標識出來。因此想到一個方案,給 NSObject 添加一個分類,在分類裏面添加一個協議。讓須要複用但須要惟一標識的 view 去實現協議方法,由於是給 NSObject 分類添加的協議,因此 view 不須要去指定遵循。

!["當即搶購"、"分享賺佣金"同類型view,但點擊意義不同](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-11-UserTrack01.png)

關鍵步驟:

- 添加 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:@"%@,", 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;

}

![改進版view惟一標識:當即搶購](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-11-UserTrack3.png)

![改進版view惟一標識:分享賺佣金](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-11-UserTrack2.png)





### 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]]; //頁面消失

![綁定頁面惟一標識與功能描述的對應關係](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-03-07-UserTracet1.PNG)

總結下來關鍵步驟:

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 屬性綁定埋點數據 

   ![接口拿到的數據](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack1.png)

   

   ![綁定埋點數據到view](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack2.png)

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

   ![hook系統事件獲取accessibilityIdentifier](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack3.png)

   

3. 將數據向的數據中心發送,數據中心處理數據(埋點數據結合App基礎信息,圖上 UserTrackDataCenter 對象)。根據狀況將數據存儲到內存或者本地,等到合適的時機去上傳

   

   ![攔截系統事件後將數據交給數據中心處理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack4.png)
相關文章
相關標籤/搜索