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)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

你可能感兴趣的:(iOS埋点之无痕埋点实践)