iOS 无侵入埋点组件总结

收录作者:Perry_6

埋点方案


1. 代码埋点

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

2. 可视化埋点

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

3. 无埋点

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

方案选择


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

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

实现可视化埋点核心问题

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

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

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

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

配置唯一标识问题

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

唯一标识(viewPath)的获取:

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

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

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

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

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

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

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

埋点上报

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

实现部分

SDK架构

技术原理

一、Method-Swizzling

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

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

二、Target-Action

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

分析及实现

一、 需要添加埋点统计的地方:

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

二、分析

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

三、实现原理

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

实现原理图:
具体实现方法:

创建一个运行时方法替换类 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到指定的新方法去执行。

四、埋点分类实现

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) 来区别,即类名加方法名的格式作为唯一标识。

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

五、埋点配置文件

埋点配置文件通过唯一标识锁定事件,可以使用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":""
            }
        }
    }
}

结交人脉

最后推荐个我的iOS交流群:[891 488 181]
'有一个共同的圈子很重要,结识人脉!里面都是iOS开发,全栈发展,欢迎入驻,共同进步!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)

总结

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

附Demo :

LZDataTrackerDemo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,529评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,015评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,409评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,385评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,387评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,466评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,880评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,528评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,727评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,528评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,602评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,302评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,873评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,890评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,132评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,777评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,310评论 2 342