iOS 无侵入埋点组件总结

2021/08/22 17:32
阅读数 57

1. 埋点方案

  1. 代码埋点

由开发人员在触发事件的具体方法里,添加多行代码把需要上传的参数上报至服务端。

  1. 可视化埋点

根据标识来识别每一个事件, 针对指定的事件进行取参埋点。而事件的标识与参数信息都写在配置表中,通过动态下发配置表来实现埋点统计。

  1. 无埋点

无埋点并不是不需要埋点,更准确的说应该是“全埋”, 前端的任意一个事件都被绑定一个标识,所有的事件都别记录下来。通过定期上传记录文件,配合文件解析,解析出来我们想要的数据, 并生成可视化报告 , 因此实现“无埋点”统计。

2. 方案选择

通常业务都需要加埋点统计事件,但在每个业务类里埋点会导致每个页面内耦合了大量的无关业务的埋点代码使得代码不够整洁,所以放弃了代码埋点。

考虑到无埋点成本较高,后期解析也复杂,选择了可视化埋点,即通过配置事件唯一标识,设置需要埋点分析的业务。

2.1 实现可视化埋点核心问题

  1. 封装埋点组件,降低耦合
  2. 如何实现后台配置唯一标识
  3. 埋点上报

2.2 针对第一个问题想到的方案如下:

  1. 每个业务页面添加一个埋点类,单独将埋点的方法提取到这个类中。
  2. 利用  Runtime 在底层进行方法拦截,从而添加埋点代码。

结合AOP的核心思想:将应用程序中的业务逻辑同对其提供支持的通用服务进行分离,最后采用了第2种方案。

2.3 配置唯一标识问题

唯一标识的组成方式主要是又 target + action 来确定, 即任何一个事件都存在一个 target 与 action。在此引入 AOP 编程,AOP(Aspect-Oriented-Programming) 即面向切面编程的思想,基于 Runtime 的 Method Swizzling 能力,来 hook 相应的方法,从而在 hook 方法中进行统一的埋点处理。例如所有的按钮被点击时,都会触发 UIApplication 的 sendAction 方法,我们 hook 这个方法,即可拦截所有按钮的点击事件。

2.3.1 唯一标识(viewPath)的获取:

整个 APP 的视图结构可以看成是一颗树(viewTree),树的根节点就是 UIWindow,树的枝干由 UIViewController 及 UIView 组成,树的叶节点都是由 UIView 组成。

那么在 viewTree 中用什么信息来表示其中任意一个 view 的位置呢?很容易想到的就是使用目标 view到根之间的每个节点的深度(层次)组成一个路径,而节点的深度(层次)是指此节点在父节点中的 index。这样确实能够唯一的表示此 view 了,但是有一个缺点:它的可读性很差。因此在此基础上又增加了每个节点的名称,节点的名称由当前节点的 view 的类名来表示。同时在开头都添加了一个页面名称作为标识。

因此,在 viewTree 中,由一个 view 到根节点之间的每个节点的名称与深度(层次)共同组成的信息构成了此 view 的 viewPath。另外,由于在做 view 的统计分析时,都是以页面为单位的,因此 SDK 在生成 viewPath 时,只到 view 所在的 UIViewController 级别,而非根部的 UIWindow。这样做也在一定程度上减少了 viewPath 的长度。

UITableView 和 UICollectionView 的树级关系没有到每个具体的 cell,避免产生很多无用的 id,而是将 indexpath 作为描述信息传入。实现逻辑如下图:

2.3.4 唯一标识的作用主要分为两个部分

  • 事件的锁定

事件的锁定主要是靠 “事件唯一标识符”来锁定,而事件的唯一标识是由我们写入配置表中的。

  • 埋点数据的上报。

埋点数据的数据又分为两种类型: 固定数据可变的业务数据, 而固定数据我们可以直接写到配置表中, 通过唯一标识来获取。而对于业务数据,数据是有持有者的, 例如我们 Controller 的一个属性值, 或者数据在 Model 的某一个层级。就可以通过 KVC 的的方式来递归获取该属性的值来取到业务数据。

2.4 埋点上报

自定义埋点上报数据类型,上报到 elastic,后台进行数据分析

3. 实现部分

3.1 SDK 架构

3.2 技术原理

3.2.1 Method-Swizzling

OC 中的方法调用其实是向一个对象发送消息 ,利用 OC 的动态性可以实现方法的交换。

  1. 用  method_exchangeImplementations 方法来交换两个方法中的IMP
  2. 用  class_replaceMethod 方法来替换类的方法,
  3. 用  method_setImplementation 方法来直接设置某个方法的  IMP

3.2.2 Target-Action

按钮的点击事件,UIControl 会调用 sendAction:to:forEvent: 来将行为消息转发到 UIApplication,再由 UIApplication 调用其 sendAction:to:fromSender:forEvent: 方法来将消息分发到指定的 target 上。

3.3 分析及实现

3.3.1 需要添加埋点统计的地方

  1. button 相关的点击事件
  2. 页面进入、页面推出
  3. tableView 的点击
  4. collectionView 的点击
  5. 手势相关事件

3.3.2 分析

  1. 对于用户交互的操作,我们使用  runtime 对应的方法  hook 下  sendAction:to:forEvent: 便可以得到进行的交互操作。这个方法对  UIControl 及继承  UIControl 的子类对象有效,如: UIButtonUISlider 等。
  2. 对于  UIViewControllerhook 下  ViewDidAppear: 这个方法知道哪个页面显示了就足够了。
  3. 对于  tableview 及  collectionview,我们  hook下setDelegate: 方法。检测其有没有实现对应的点击代理,因为  tableView:didSelectRowAtIndexPath: 及  collectionView:didSelectItemAtIndexPath: 是  option 的不是必须要实现的。
  4. 对于手势,我们在创建的时候进行  hook,方法为  initWithTarget:action:

3.3.3 实现原理

用运行时方法替换方法实现无侵入的埋点方法。

实现原理图:

具体实现方法:

创建一个运行时方法替换类 HGMethodSwizzingTool,实现替换的方法 `swizzingForClass: originalSel: swizzingSel:``

#import "LZMethodSwizzingTool.h"
#import <objc/runtime.h>

@implementation LZMethodSwizzingTool

+ (void)swizzingForClass:(Class)cls originalSel:(SEL)originalSelector swizzingSel:(SEL)swizzingSelector {
    Class class = cls;
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzingMethod = class_getInstanceMethod(class, swizzingSelector);
    BOOL addMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzingMethod), method_getTypeEncoding(swizzingMethod));
    if (addMethod) {
        class_replaceMethod(class, swizzingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
    } else {
        method_exchangeImplementations(originalMethod, swizzingMethod);
    }
}
@end

这个方法利用运行时 method_exchangeImplementations 进行交换,当原方法被调用时,就会 hook 到指定的新方法去执行。

3.3.4 埋点分类实现

1. UIViewController+Track(页面进入、页面推出)
@implementation UIViewController (Track)

+ (void)initialize {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL originalWillAppearSelector = @selector(viewWillAppear:);
        SEL swizzingWillAppearSelector = @selector(hg_viewWillAppear:);
        [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillAppearSelector swizzingSel:swizzingWillAppearSelector];

        SEL originalWillDisappearSel = @selector(viewWillDisappear:);
        SEL swizzingWillDisappearSel = @selector(hg_viewWillDisappear:);
        [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillDisappearSel swizzingSel:swizzingWillDisappearSel];

        SEL originalDidLoadSel = @selector(viewDidLoad);
        SEL swizzingDidLoadSel = @selector(hg_viewDidLoad);
        [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSel swizzingSel:swizzingDidLoadSel];
    });
}

- (void)hg_viewWillAppear:(BOOL)animated {
    [self hg_viewWillAppear:animated];

    //埋点实现区域
    [self dataTrack:@"viewWillAppear"];
}

- (void)hg_viewWillDisappear:(BOOL)animated {
    [self hg_viewWillDisappear:animated];

    //埋点实现区域
    [self dataTrack:@"viewWillDisappear"];
}

- (void)hg_viewDidLoad {
    [self hg_viewDidLoad];

    //埋点实现区域
    [self dataTrack:@"viewDidLoad"];
}

- (void)dataTrack:(NSString *)methodName {
    NSString *identifier = [NSString stringWithFormat:@"%@/%@",[[LZFindVCManager currentViewController] class],methodName];
    NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"ViewController"] objectForKey:identifier];
    if (eventDict) {
        NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
        //预留参数配置,以后拓展
        NSDictionary *param = [eventDict objectForKey:@"eventParam"];
        __block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
        [param enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
            //在此处进行属性获取
            id value = [LZCaptureTool captureVarforInstance:self varName:key];
            if (key && value) {
                [eventParam setObject:value forKey:key];
            }
        }];
        if (eventParam.count) {
            NSLog(@"identifier:%@-------useDefind:%@----eventParam:%@",identifier,useDefind,eventParam);
        }
    }
}
@end

Category 在 +openTrackSelector() 方法里使用了 HGMethodSwizzingTool 进行方法替换,在替换的方法里执行需要埋点的方法 - (void)dataTrack:(NSString *)methodName 实现埋点。这样每个 UIViewController 生命周期到了 ViewWillAppear 都会执行埋点的方法。

在这里,我们是通过类名 NSStringFromClass([self class]) 来区分不同的控制器的。

2. UIControl+Track(button相关的点击事件)
@implementation UIControl (Track)

+ (void)initialize {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL originalSelector = @selector(sendAction:to:forEvent:);
        SEL swizzingSelector = @selector(hg_sendAction:to:forEvent:);
        [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector];
    });
}

- (void)hg_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    [self hg_sendAction:action to:target forEvent:event];

    //埋点实现区域====
    //页面/方法名/tag用来区分不同的点击事件
    NSString *identifier = [NSString stringWithFormat:@"%@/%@/%@", [target class], NSStringFromSelector(action),@(self.tag)];
    if ([target isKindOfClass:[UIView class]]) {
        UIView *view = (id)[target superview];
        while (view.nextResponder) {
            identifier =[NSString stringWithFormat:@"%@/%@",NSStringFromClass(view.class),identifier];
            if ([view.class isSubclassOfClass:[UIViewController class]]) {
                break;
            }
            view = (id)view.nextResponder;
        }
    }

    NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"Action"] objectForKey:identifier];
    if (eventDict) {
        NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
        //预留参数配置,以后拓展
        NSDictionary *param = [eventDict objectForKey:@"eventParam"];
        __block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
        [param enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
            //在此处进行属性获取
            id value = [LZCaptureTool captureVarforInstance:target varName:key];
            if (key && value) {
                [eventParam setObject:value forKey:key];
            }
        }];

        NSLog(@"useDefind:%@----eventParam:%@",useDefind,eventParam);
    }
}

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

}
@end

找到点击事件的方法 sendAction:to:forEvent:,然后在 +openTrackSelector() 方法里使用 HGMethodSwizzingTool 替换新的方法。

和 UIViewController 生命周期埋点不同的是,一个类中可能有许多不同的 UIButton 子类,相同的 UIButton 子类在不同的视图中的埋点也要区分出来,所以我们通过 NSStringFromClass([target class]) + NSStringFromSelector(action) 来区别,即类名加方法名的格式作为唯一标识。

tableViewcollectionView、手势的点击事件与上述实现方法类似。

3.3.5 埋点配置文件

埋点配置文件通过唯一标识锁定事件,可以使用 json 文件或 plist 文件,Demo 里就随便写了一些测试数据,LZDataTrack.json 是直接放在了项目资源里,实际项目是通过 API 从服务器下载的配置文件,以实现实时更新埋点配置。

测试 json 文件:

{
    "Gesture":{
        "RootViewController/gestureclicked:":{
            "userDefined": {
                "action""click",
                "pageid""1234",
                "pageName""首页",
                "eventName":"点击手势"
            },
            "eventParam":{
                "spm":"a-b-c-spm",
                "pageName":"",
                "tips":""
            }
        }
    },
    "ViewController":{
        "RootViewController/viewWillAppear":{
            "userDefined": {
                "action""show",
                "pageid""1234",
                "pageName""首页",
                "eventName":"首页展示"
            },
            "eventParam":{
                "spm":"",
                "pageName":"",
                "tips":""
            }
        },
        "SecondViewController/viewWillAppear":{
            "userDefined": {
                "action""show",
                "pageid""1235",
                "pageName""灵感页",
                "eventName":"灵感页展示"
            },
            "eventParam":{
                "spm":"",
                "pageName":"",
                "tips":""
            }
        }
    },
    "CollectionView":{
        "ThirdViewController/0":{
            "viewcontroller":true,
            "userDefined": {
                "action""click",
                "pageid""12345",
                "pageName""灵感页",
                "eventName":"点击collectionview"
            },
            "eventParam":{
                "spm":"a-b-c-spm",
                "pageName":"",
                "tips":""
            }
        }
    },
    "TableView":{
        "SecondViewController/0":{
            "viewcontroller":true,
            "userDefined": {
                "action""click",
                "pageid""12345",
                "pageName""灵感页",
                "eventName":"点击tableview"
            },
            "eventParam":{
                "spm":"a-b-c-spm",
                "pageName":"",
                "tips":""
            }
        }
    },
    "Action":{
        "RootViewController/testButtonClick:/0":{
            "userDefined": {
                "action""click",
                "pageid""1234",
                "pageName""首页",
                "eventName":"点击测试按钮"
            },
            "eventParam":{
                "spm":"a-b-c-spm",
                "pageName":"",
                "tips":""
            }
        },
        "SecondViewController/UIView/UITableView/TableViewCell/testButtonClick:/0":{
            "userDefined": {
                "action""click",
                "pageid""1234",
                "pageName""灵感",
                "eventName":"cell里的点击测试按钮"
            },
            "eventParam":{
                "spm":"a-b-c-spm",
                "pageName":"",
                "tips":""
            }
        }
    }
}

总结

使用运行时方法的替换实现了无侵入埋点,但仍存在很多问题,比如唯一标识难以维护、准确性有待验证。目前的方式只能实现页面进、出以及点击事件的埋点统计,涉及到具体业务的埋点统计,比如开机启动、需要上报参数信息等类型的埋点还是要依赖代码埋点。所以无侵入埋点方案还有很大优化空间。

附 Demo :

项目源码通过公众号"网罗开发"后台回复「20210810」获取

-End-


推荐阅读
分享超详细 WKWebView 开发和使用经验
实现 iOS 无感知上拉加载更多
Mac 高效率 iOS 开发工具

领取面试题

进入上方二维码内 回复「面试题」


在看点这里好文分享给更多人↓↓

本文分享自微信公众号 - 小明菜市场(fileGeek)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
0 收藏
0
分享
返回顶部
顶部