无痕埋点的设计与实现

在移动互联网时代,对于每个公司、企业来说,用户的行为数据非常重要。重要到什么程度,用户在这个页面停留多久、点击了什么按钮、浏览了什么内容、什么手机、什么网络环境、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. 操刀就干

一言以蔽之就是:AOP -> Event Collector -> Event Cache -> Data Upload

  • AOP:通过 runtime hook 的能力做到提供合适的时机去生成点击事件的数据
  • Event Collector:将步骤1产生的数据统一收集(一般是一个内存存储的数据结构)
  • Event Cache:mmap,当内存中的数据达到一定的阀值,或者应用程序的生命周期切换的时候将内存中的数据同步到缓存中(数据库、磁盘、文件)
  • Data Uploader:制定一定的策略,当达到触发条件的时候再去上传数据(App达到阀值,生命周期的切换等);App从前台进入后台的时候去上传数据(后台线程保活策略);数据上传格式的选择(zip压缩文件、protoBuf)

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、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 不需要去指定遵循。

"立即抢购"、"分享赚佣金"同类型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:@"%@,", 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. 疑惑点

根据在同一个 view 上会有多个 subview,那么生成的 xpath 会携带在同类型 views 中的索引,所以一个登录、注册按钮的 xpath 可能为 …btn1、…btn2。那么在版本A上线后运行了一段时间,上传并统计了数据。过了一段时间版本迭代,UI 搞事情,把登录和注册按钮的位置欢乐,变成了注册、登录。按照之前的逻辑生成的 xpath 为 …btn1、…btn2。那么新的 xpath 虽然唯一,但是点击产生的数据会和之前的埋点数据意义不一样。别怕,你忘了还有一步绑定的逻辑。绑定的这一步会把每次开发的功能,通过可视化界面去将 xpath 和功能名称绑定一下。看下面的动图。所以不用担心虽然生成了唯一的 xpath,但是 App 在不同版本之间 UI 控件位置更换造成之前的统计数据在分析的时候不准确的问题。因为在绑定的时候就将新的 xpath 和功能名称进行了绑定,接口携带版本号。所以分析的时候注意版本号就好了。sql 一句话的事情。

绑定页面唯一标识与功能描述的对应关系动图

8. 数据如何处理

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]];
    }
}

9. 曝光时间的统计

曝光的意义是什么?

我们的产品中可能有合作伙伴的广告,我们需要收取服务费。那如何计价?CPM(cost per Mille)每千人成本、CPC(cost per click)每点击成本、CPA(cost per action)每行动成本,根据这些指标来计算价格。或者自己的产品中运营人员在商城中投放了一次新的活动,为了这次活动在某个钻石展位放了设计人员精心设计的炫酷 Banner。这次活动后运营人员想分析在这个图片的作用下有多少人点击了这个活动页。

何为曝光?

一个 view 或者一个组件或者一个资源位在屏幕上可见区域内停留的时间称为一次曝光。那么这个时间怎么统计?有一个点需要注意,那就是当用户在快速滑动的过程中页面上的元素或者组件都会在页面可见区域内快速闪过,那这种算一次曝光吗?当然不算啊,想了想设置了一个时间临界值,大于这个临界值那么算做一次有效的曝光。

A. 有效曝光的判断

显示在屏幕可见区域如何判断?一个 View 显示在屏幕可见区域内,那么它肯定是经过从未初始化到初始化,再到设置 Frame 或者 Bounds 或者 Alpha 或者 Hidden 的。且它的根 view 一定是 UIWindow 对象。所以上面这句话进行分析整理就是下面的条件

  • 自身 frame 的改变或者父视图 bounds 的改变
  • alpha 小于 0.1 或者 hidden 为 YES
  • 根视图为 window

对于上面的三点可以用 AOP 进行判断。 hook 掉相应的方法,然后处理判断是否在可见区域内显示。最后的一个点经过一番查找,看到了一个 api didMoveToWindow ,根据它可以判断 view 是否显示到屏幕中(文档中说明:当它的 window 对象发送改变的时候会调用 view 的 didMoveToWindow 方法)。

Tells the view that its window object changed.

The default implementation of this method does nothing. Subclasses can override it to perform additional actions whenever the window changes.

The window property may be nil by the time that this method is called, indicating that the receiver does not currently reside in any window. This occurs when the receiver has just been removed from its superview or when the receiver has just been added to a superview that is not attached to a window. Overrides of this method may choose to ignore such cases if they are not of interest.
B. 曝光代码的执行效率优化

设想一下,某个复杂的页面可能是一个大的 UIViewController 顶部是店铺的基本信息,下面是 2个 UIViewController:左侧负责展示商品的一级、二级、三级分类,且负责选中和未选中的 UI 效果;右侧负责展示商品信息(顶部有商品的排序查找 ,下面是商品展示的 UICollectionView)。由于页面结构复杂,UI 层级嵌套严重,所以代码层面不注意的话,页面上计算量会比较大,CPU 负荷严重,直接影响着手机的 耗电量。改进的手段是在合适的地方提前 return 掉(比如 hidden 等于 YES 或者 aplha 小于 0.1 的时候)。

另外一个方面就是当用户在滑动页面到感兴趣的模块的时候,开始点击执行某个逻辑,但此时我们的无痕埋点的代码也在偷偷的工作,那么势必会对用户体验造成影响。该方案的改进是监听 RunLoop,等到 RunLoop 空闲的时候判断当前 view 是否是一次有效的曝光。

实际上发现某些 view 的判断会比较特殊,比如当在 UITableView 的 cell 判断的时候,我们发现 cell 的 superview 为 UITableViewWrapperView 时,我们使用 UITableViewWrapperView 的父视图来计算。

iOS 11 以下 UITableViewWrapperView 大小为屏幕中第一个完整的屏幕大小视图,且会随着 contentOffset 的改变而改变。所以当 UITableViewWrapperView 滑出屏幕可见区域的时候,cell 判断父视图是否可见的时候不准确。

整个流程见下面的流程图。

整体流程图

10. 数据的上报

数据通过上面的办法收集完了,那么如何及时、高效的上传到后端,给运营分析、处理呢?

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 对象)。根据情况将数据存储到内存或者本地,等到合适的时机去上传

    拦截系统事件后将数据交给数据中心处理

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android无痕埋点是一种在Android应用程序中进行数据追踪的技术手段,不影响用户正常操作的同时,对用户行为进行统计和分析。 在传统的数据追踪方式中,开发人员通常需要手动在代码中添加埋点代码,这会增加代码的复杂性并且容易出错。而无痕埋点则通过修改应用程序的底层框架来实现自动追踪用户行为,无需手动插入埋点代码。 Android无痕埋点的原理是通过动态代理或Hook技术,拦截和修改应用程序的底层事件,如Activity生命周期、点击事件等,并将这些事件传递给埋点系统进行统计和分析。 该技术的优点在于,无痕埋点不会对用户体验造成影响,用户无感知地进行数据追踪。同时,由于无痕埋点是自动化的,开发人员不需要手动添加埋点代码,大大减少了开发和维护工作量。 然而,无痕埋点也存在一些限制和挑战。首先,为了实现无痕埋点,开发人员需要对Android底层框架有一定的了解。其次,由于对底层事件进行拦截和修改,无痕埋点可能会对应用程序的性能产生一定的影响,特别是在处理大量用户事件时。 总的来说,Android无痕埋点是一种实现数据追踪和分析的有效方法,通过自动化和无感知的方式,提供了更便捷和高效的数据采集方式。但同时也需要权衡好数据采集与用户体验之间的平衡,避免对应用程序性能和用户操作造成不必要的影响。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值