iOS埋点之无痕埋点实践

1、背景

稀里哗啦一大段

2、主要功能划分

从整个流程来说,我把他划分为下面几个主要的功能,事件拦截
viewPath获取数据上报圈选功能,并在文章中会对每个功能进行比较详细的解析和代码粘贴。

3、事件拦截

3.0、runtime核心功能

这里用到runtime的添加方法交换方法

+(void)swizzingForClass:(Class)cls originalSel:(SEL)originalSelector swizzingSel:(SEL)swizzingSelector{
    //添加交换实例方法
    Class class = cls;
    //添加交换类方法
    //Class class = objc_getMetaClass(object_getClassName(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);
    }
}

注意:添加实例方法和添加类方法有少许的区别,在使用是需要更具具体的场景进行处理。具体原理可点击这里查看。


3.1、页面拦截

创建UIViewController的Category,在此对生命周期的方法进行交换。

+(void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{        
        SEL originalDidLoadSelector = @selector(viewDidLoad);
        SEL swizzingDidLoadSelector = @selector(user_viewDidLoad);
        [WMMethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSelector swizzingSel:swizzingDidLoadSelector];
    });
}

-(void)user_viewDidLoad
{
    [self user_viewDidLoad];
   //TODO:数据上传代码
}

小插曲:原本想对控制器的dealloc方法也行统一处理,但是在完成后发现和某个第三方有问题,在双击输入框是出现crash,所以先不对这个进行拦截。

3.2、按钮拦截

对于系统的按钮可直接对创建UIControl的Category分类,并对sendAction:to:forEvent:方法进行拦截。

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

- (void)wm_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event
{
    [self wm_sendAction:action to:target forEvent:event];
    //TODO:数据上传(GIO过滤)
}

注意:由于原先项目中集成了GIO统计一直还在用着,点击方法中会拦截到GIO的growingHookTouch_xxxx方法,导致数据的多次上传,所以在这边对GIO的方法进行过滤掉。

if ([NSStringFromSelector(action) hasPrefix:@"growingHookTouch"])return;
3.3、手势拦截

确实在项目中使用点击手势的地方远比直接使用按钮的地方多,由于这次埋点只对点击事件处理所以也只UITapGestureRecognizer创建Category

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [WMMethodSwizzingTool swizzingForClass:[self class] originalSel:@selector(initWithTarget:action:) swizzingSel:@selector(vi_initWithTarget:action:)];
    });
}

- (instancetype)vi_initWithTarget:(nullable id)target action:(nullable SEL)action
{
    UITapGestureRecognizer *selfGestureRecognizer = [self vi_initWithTarget:target action:action];
    if (!target && !action) {
        return selfGestureRecognizer;
    }    
    if ([target isKindOfClass:[UIScrollView class]]) {
        return selfGestureRecognizer;
    }
            
    Class class = [target class];
    
    SEL sel = action;
    
    //创建一个新的方法 方法名为 sel_name
    NSString * sel_name = [NSString stringWithFormat:@"wm_%s_%@", class_getName([target class]),NSStringFromSelector(action)];
    SEL sel_ =  NSSelectorFromString(sel_name);
    
    //添加一个方法  参数:相应手势的类,添加的方法名,实现方法的函数 responseUser_gesture
    BOOL isAddMethod = class_addMethod(class,
                                       sel_,
                                       method_getImplementation(class_getInstanceMethod([self class], @selector(responseUser_gesture:))),
                                       nil);

    self.methodName = NSStringFromSelector(action);
    
    //方法添加成功,原先的方法实现 action -> 新的方法实现 responseUser_gesture。
    if (isAddMethod) {
        Method selMethod = class_getInstanceMethod(class, sel);
        Method sel_Method = class_getInstanceMethod(class, sel_);
        method_exchangeImplementations(selMethod, sel_Method);
    }
    
    return selfGestureRecognizer;
}

-(void)responseUser_gesture:(UITapGestureRecognizer *)gesture
{

    NSString * identifier = [NSString stringWithFormat:@"wm_%s_%@", class_getName([self class]),gesture.methodName];
    //调用原方法
    SEL sel = NSSelectorFromString(identifier);
    if ([self respondsToSelector:sel]) {
        IMP imp = [self methodForSelector:sel];
        void (*func)(id, SEL,id) = (void *)imp;
        func(self, sel,gesture);
    }
}

//TODO:数据上报

解析:这边做了两部处理,有区别于按钮点击事件,按钮是直接在触发点击事件消息转发方法拦截,直接能到触发的点。而这边手势是在创建手势是,对点击事件要再度处理。

第一步:在初始化方法中拿到实现方法action,并动态创建一个方法和原本的action进行交换。
第二步:在交互的实现中实现原先的action,然后在做数据上报处理。

小插曲:最开始想着对手势的拦截就直接对UITapGestureRecognizer进行处理,在拦截里面过其他的过滤,但后来发现是在太多系统的手势,导致一些手势直接失效,最后改成这样。

3.4、列表拦截

对UITableView和UICollectionView的处理是对delegate进行处理,过程类似于手势。

+(void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL originalAppearSelector = @selector(setDelegate:);
        SEL swizzingAppearSelector = @selector(wm_collection_setDelegate:);
        [WMMethodSwizzingTool swizzingForClass:[self class] originalSel:originalAppearSelector swizzingSel:swizzingAppearSelector];
    });
}
-(void)wm_tableView_setDelegate:(id<UITableViewDelegate>)delegate
{
    [self wm_tableView_setDelegate:delegate];

    SEL sel = @selector(tableView:didSelectRowAtIndexPath:);

    SEL sel_ =  NSSelectorFromString([NSString stringWithFormat:@"%@_%@_%ld", NSStringFromClass([delegate class]), NSStringFromClass([self class]),(long)self.tag]);

    //因为 tableView:didSelectRowAtIndexPath:方法是optional的,所以没有实现的时候直接return
    if (![self isContainSel:sel inClass:[delegate class]]) {
        return;
    }

    BOOL addsuccess = class_addMethod([delegate class],
                                      sel_,
                                      method_getImplementation(class_getInstanceMethod([self class], @selector(user_tableView:didSelectRowAtIndexPath:))),
                                      nil);

    //如果添加成功了就直接交换实现, 如果没有添加成功,说明之前已经添加过并交换过实现了
    if (addsuccess) {
        Method selMethod = class_getInstanceMethod([delegate class], sel);
        Method sel_Method = class_getInstanceMethod([delegate class], sel_);
        method_exchangeImplementations(selMethod, sel_Method);
    }
}

- (void)user_collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath;
{
    SEL sel = NSSelectorFromString([NSString stringWithFormat:@"%@_%@_%ld", NSStringFromClass([self class]),  NSStringFromClass([collectionView class]), (long)collectionView.tag]);
    if ([self respondsToSelector:sel]) {
        IMP imp = [self methodForSelector:sel];
        void (*func)(id, SEL,id,id) = (void *)imp;
        func(self, sel,collectionView,indexPath);
    }

  //TODO:数据上报

}

//判断页面是否实现了某个sel
- (BOOL)isContainSel:(SEL)sel inClass:(Class)class {
    unsigned int count;
    
    Method *methodList = class_copyMethodList(class,&count);
    for (int i = 0; i < count; i++) {
        Method method = methodList[i];
        NSString *tempMethodString = [NSString stringWithUTF8String:sel_getName(method_getName(method))];
        if ([tempMethodString isEqualToString:NSStringFromSelector(sel)]) {
            return YES;
        }
    }
    return NO;
}

解析:实现思路和手势的一样,不过多书写。

3.5、Alert拦截

Alert的拦截是直接对UIAlertAction点击的按钮进行拦截。

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [WMMethodSwizzingTool swizzingClassForClass:[self class] originalSel:@selector(actionWithTitle:style:handler:) swizzingSel:@selector(wm_actionWithTitle:style:handler:)];
    });
}

+ (instancetype)wm_actionWithTitle:(nullable NSString *)title style:(UIAlertActionStyle)style handler:(void (^ __nullable)(UIAlertAction *action))handler{
    
    void (^handlerBlock)(UIAlertAction *action) = ^(UIAlertAction *action){
        if (handler) {
            handler(action);
        }
        //TODO:数据是否上报
     }
    UIAlertAction *alterAction = [UIAlertAction wm_actionWithTitle:title style:style handler:handlerBlock];
}

注意:这边有点不一样

1.这边交换的类方法(上面也写过区别)。
2..这里的点击是block回调,所以创建了一个中间block进行处理。
3..数据上报这块,直接给到取消确认是完全没有意义的,所以给UIAlertAction添加了个属性,记录这个弹框的更多信息,
已定位业务。

UIAlertControllerUIAlertAction添加的属性赋值。(UIAlertAction添加属性方法略)

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [WMMethodSwizzingTool swizzingForClass:[self class] originalSel:@selector(addAction:) swizzingSel:@selector(wm_addAction:)];
    });
}

- (void)wm_addAction:(UIAlertAction *)action {
    [self wm_addAction:action];
    action.alertControllerActionPath = [NSString stringWithFormat:@"%@/%@",self.title,self.message];
}

小总结:
这期拦截代码到这里基本上就没了。其中完成了:
1.进页面有统一的地方得到当前的控制器。
2.点击(按钮,手势)有统一的响应方法的地方。
3.列表点击有统一响应的地方。
4.弹框有统一的响应,并能拿到弹框信息。


4、ViewPath获取

先放出ViewPath格式:

普通路径:
WMMineViewController[0]/UIView[0]/UITableView[0]/UIView[1]/WMMineTopInfoView[0]/UIView[0]
复杂路径:
WMHomePageViewController[0]/UIView[0]/UICollectionView[0]/WMHomePageBannerCell#[1,0]/UIView[0]/SDCycleScrollView[0]/UICollectionView[0]/SDCollectionViewCell#[0,1]

ViewPath是每个组件的唯一路径,大数据通过ViewPath来确定当前点击的是什么(圈选来告诉这个ViewPath是什么),然后进行数据分析。

直接上代码:
4.1、第一步
UIView的Category,获取某个view在同一级别的深度,上面路劲中的[0]

- (NSString *)obtainSameSuperViewSameClassViewTreeIndexPath
{
    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 = -1;//相同类型 深度(默认-1)
    
    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]];
        }
    }
    //所处父view的同类型subviews根节点深度
    for (NSInteger index =0; index < sameClassArr.count; index ++) {
        if (view == sameClassArr[index]) {
            sameViewTreeNodeDepth = index;
            break;
        }
    }
    return [NSString stringWithFormat:@"%ld",sameViewTreeNodeDepth];
    
}

这里是做同类控件的深度。
4.2、第二步
UIResponder的Category,通过响应链获取完整的路径。

- (NSString *)generateViewPath
{
    NSString *spointViewPath;
    if ([self isKindOfClass:[UIView class]]) {
        UIView *view1 = (id)self;
        NSMutableString *str = [NSMutableString string];
        str = [[NSStringFromClass(view1.class) stringByAppendingFormat:@"%@",str] mutableCopy];
        
        //将viewPath放入 accessibilityIdentifier ,如果存在直接返回,优化性能。
        if (view1.accessibilityIdentifier) {
            return view1.accessibilityIdentifier;
        }else{
            [str appendFormat:@"%@",[self getIndexPathForView:view1]];
        }
        
        UIView *view = (id)self;
        while (view.nextResponder) {
            if ([view.class isSubclassOfClass:[UIViewController class]]) {
                break;
            }
            if ([view isMemberOfClass:[MMPopupWindow class]]) {
                spointViewPath = [NSString stringWithFormat:@"%@",[(MMPopupView *)view class]];
                break;
            }
            str = [[@"/" stringByAppendingFormat:@"%@",str] mutableCopy];
            view = (id)view.nextResponder;
            NSString *sameViewTreeNode1 = @"[0]";
            if ([view isKindOfClass:[UIView class]]) {
                sameViewTreeNode1 = [self getIndexPathForView:view];
            }
            str = [[sameViewTreeNode1 stringByAppendingString:str] mutableCopy];
            str = [[NSStringFromClass(view.class) stringByAppendingFormat:@"%@",str] mutableCopy];
        }
        spointViewPath = [NSString stringWithFormat:@"%@",str];
        view1.accessibilityIdentifier = spointViewPath;
    }
    return spointViewPath;
}

- (NSString *)getIndexPathForView:(UIView *)cellView {
    NSString *cellIndexPath = [NSString string];
    if ([cellView.superview isKindOfClass:[UICollectionView class]]&&[self isKindOfClass:[UICollectionViewCell class]]) {        
        UICollectionView *collectionView = (UICollectionView *)cellView.superview;
        NSIndexPath *indexPath = [collectionView indexPathForCell:(UICollectionViewCell *)cellView];
        cellIndexPath = [NSString stringWithFormat:@"#[%ld,%ld]",(long)indexPath.section,(long)indexPath.row];
    }else if ([cellView.superview isKindOfClass:[UITableView class]]&&[self isKindOfClass:[UITableViewCell class]]) {
        UITableView *tableView = (UITableView *)cellView.superview;
        NSIndexPath *indexPath = [tableView indexPathForCell:(UITableViewCell *)cellView];
        cellIndexPath = [NSString stringWithFormat:@"#[%ld,%ld]",(long)indexPath.section,(long)indexPath.row];
    }else{
        cellIndexPath = [NSString stringWithFormat:@"[%@]",[cellView obtainSameSuperViewSameClassViewTreeIndexPath]];
    }
    return cellIndexPath;
}

为了方面后面的圈选统一,在这边直接在cell的后面添加了所在位置,就不必再各个上传数据的地方在拼接上去。

5、圈选功能

圈选代码太多详细内容查看demo
这里只提示一些注意点。

1.圈选得到的路径和上传得到的路径必须一致。
2.圈选根据要求只对能响应事件的控件进行圈选。
3.圈选的内容可能没有事件但能响应事件也能圈选。
4.一些第三方轮播库的index并不确定,需要组件里面页码实现的规则进行特殊计算。
5.出现圈选icon可通过扫scheme二维码实现或项目中隐蔽的入口。

6、数据上传

数据上传这块更具自己服务所需数据处理就好,总结一下几点。

1.网络这块直接通过AFN再次封装,不使用项目中现有的减少依赖。
2.上传的数据模型和服务约定就好。

7、总结

以上能实现基本的实时埋点和实时上传的功能,也是目前公司项目做得第一期所有功能。感谢网络上许多文章,后续有更新再补充,希望对你有帮助,谢谢阅读。

项目完整demo

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

推荐阅读更多精彩内容

  • https://mp.weixin.qq.com/s/u-HmmrSAgtER1N2pKxCm0A 随着公司业务的...
    海浪萌物阅读 3,071评论 1 1
  • 前言 最近跟同事花了点时间来思考可视化埋点,并没有什么突破性的进展,不过市面上很多关于可视化埋点的技术文章都在讲达...
    daixunry阅读 8,012评论 1 38
  • 简单介绍一下 AOP 无痕埋点最重要的技术是将埋点代码从业务代码中剥离,放到独立的模块中的技术。写业务的同学只需按...
    Magic_Unique阅读 7,803评论 16 53
  • 今年真是比较痛苦的一年,股市亏了一整年,对自己的水平认不清,过于幻想,对市场也是过于幻想。只想着一把挣钱出来,却没...
    巴克萌萌哒阅读 242评论 0 0
  • 都市里的人们和平常一样为了生计奔波,在这里有着魔王,也有无数为了打败魔王而努力的勇者。虽然人类在魔王军的进攻下一...
    0大神来也0阅读 186评论 1 2