iOS 最優(yōu)無痕埋點方案

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 的改變,卻導致其它子 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 不需要去指定遵循。

image

關鍵步驟:

  • 添加 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;
}
復制代碼
改進版view唯一標識:立即搶購
改進版view唯一標識:分享賺傭金

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]];    //頁面消失
復制代碼
綁定頁面唯一標識與功能描述的對應關系

總結下來關鍵步驟:

  1. hook 系統(tǒng)的各種事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 應用程序、控制器生命周期。在做本來的邏輯之前添加額外的監(jiān)控代碼
  2. 對于點擊的元素按照視圖樹生成對應的唯一標識(addCartButton.GoodsView.GoodsViewController) 的 md5 值
  3. 在業(yè)務開發(fā)完畢,進入埋點的編輯模式,將 md5 和關鍵的頁面的關鍵事件(運營、產品想統(tǒng)計的關鍵模塊:App層級、業(yè)務模塊、關鍵頁面、關鍵操作)給綁定起來。比如 addCartButton.GoodsView.GoodsViewController.tbApp 對應了 tbApp-商城模塊-商品詳情頁-加入購物車功能。
  4. 將所需要的數(shù)據(jù)存儲下來
  5. 設計機制等到合適的時機去上傳數(shù)據(jù)

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

埋點模塊分為2個pod組件庫,TriggerKit 負責攔截系統(tǒng)事件,拿到埋點數(shù)據(jù)。Appmonitor 負責收集埋點數(shù)據(jù),本地持久化或內存儲存,等到合適時機去上傳埋點數(shù)據(jù)。

  1. 通過接口獲取數(shù)據(jù),給對應的 view 的 accessibilityIdentifier 屬性綁定埋點數(shù)據(jù)

    接口拿到的數(shù)據(jù)
    綁定埋點數(shù)據(jù)到view
  2. hook 系統(tǒng)事件,點擊拿到 view,獲取 accessibilityIdentifier 屬性值

    hook系統(tǒng)事件獲取accessibilityIdentifier
  3. 將數(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

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容