美文网首页iOS面试题+基础知识2019知识点汇总
戴铭(iOS开发课)读书笔记:09章节-无侵入埋点

戴铭(iOS开发课)读书笔记:09章节-无侵入埋点

作者: YYYYYY25 | 来源:发表于2019-05-10 13:35 被阅读507次

    原文链接:无侵入的埋点方案如何实现?


    前言:

    原文中介绍了iOS开发常见的埋点方式:代码埋点、可视化埋点和无埋点。其中具体的区别我会整理在此篇文章的最后。
    我们可以把可视化埋点和无埋点归类为无侵入埋点,它的主要实现原理就是通过运行时方法替换进行埋点
    使用无侵入埋点的方式,配合事件唯一标志,可以区分和记录项目中绝大多数的事件。

    正文:

    在iOS开发中,埋点对代码的侵入是非常严重的。而且在早起做埋点的工作时,往往都是采用最原始的方式---手写代码在需要埋点的代码处。记录和分析的工作要借助一些第三方工具(例如我们之前做埋点使用的友盟)。手写代码埋点的过程十分痛苦,版本迭代的时候,旧的埋点代码也很难维护和更新,非常痛苦。

    使用运行时方法替换事件的实现,可以很大程度降低对代码的侵入,下面简单介绍一下生命周期点击事件cell点击事件手势事件的method_exchange方式。
    你也可以直接查看这个 BuryDemo 中的代码。

    首先,先写一个工具类用来方法的 hook:

    + (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {
        Class cls = classObject;
        Method fromMethod = class_getInstanceMethod(cls, fromSelector);
        Method toMethod = class_getInstanceMethod(cls, toSelector);
        
        if (class_addMethod(cls, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
            class_replaceMethod(cls, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
        } else {
            method_exchangeImplementations(fromMethod, toMethod);
        }
    }
    

    执行这个方法会将目标 中的 两个方法 进行交换。

    上面的方法中需要传入三个参数:
    1 fromSelector 和 toSelector,这是两个被交换的方法选择器SEL。
    1 classObject,这是上面两个方法选择器SEL所在的类。

    这个方法的执行流程:
    1 通过方法选择器获取类中的实例方法 class_getInstanceMethod。
    2 判断类中 fromSelector 所对应的方法是否存在,如果不存在就创建一个 class_addMethod。
    3 如果创建成功,调用 class_replaceMethod 方法将 fromSelector 替换成 toSelector。
    4 如果创建失败,调用 method_exchangeImplementations 交换上面两个方法。
    这是一个标准的流程。这段代码可以保存使用。

    1 监听页面创建和销毁、停留时间等

    + (void)load {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            SEL fromSelectorAppear = @selector(viewWillAppear:);
            SEL toSelectorAppear = @selector(yy_viewWillAppear:);
            [YYHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];
            
            SEL fromSelectorDisappear = @selector(viewWillDisappear:);
            SEL toSelectorDisappear = @selector(yy_viewWillDisappear:);
            [YYHook hookClass:self fromSelector:fromSelectorDisappear toSelector:toSelectorDisappear];
        });
    }
    
    - (void)yy_viewWillAppear:(BOOL)animated {
        [self yy_viewWillAppear:animated];
        NSLog(@"%@ 启动", NSStringFromClass([self class]));
    }
    
    - (void)yy_viewWillDisappear:(BOOL)animated {
        [self yy_viewWillDisappear:animated];
        NSLog(@"%@ 销毁", NSStringFromClass([self class]));
    }
    

    监听页面的创建和销毁只要 hook 住控制器的viewWillAppear:viewWillDisappear:方法即可。其中可以记录页面的打开次数和停留时间等等。

    甚至你可以通过查看控制器是否被销毁判断页面是否发生内存泄漏,很多检测内存泄漏的第三方库大概就是这个监听生命周期的原理。

    2 点击事件
    iOS中有很多控件的基类均是UIControl,例如UISwitch开关、UIButton按钮、UISegmentedControl分段控件、UISlider滑块、UITextField文本字段控件、UIPageControl分页控件等等。
    我们可以通过监听 UIControl 中的 sendAction:to:forEvent: 方法做很多事。

    + (void)load {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            SEL fromSelector = @selector(sendAction:to:forEvent:);
            SEL toSelector = @selector(yy_sendAction:to:forEvent:);
            [YYHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
        });
    }
    
    - (void)yy_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
        [self yy_sendAction:action to:target forEvent:event];
        NSLog(@"点击buryTag:%@", self.buryTag);
    }
    

    这里我只对 UIButton 进行了测试,可以成功监听到 UIButton 的点击事件。但是这里也遇到了 唯一标志 的问题。因为一个页面中可能会有很多个 button ,那么你在埋点的时候如何区分到底是点击了哪个 button 呢?

    原文中老师给出的方案是通过 控件的视图树结构 来作为唯一标志。通过视图的 superviewsubviews 的属性。

    但是到此为止只解决了不同页面中的 button 的区分,但是同页面的 button 依旧是同一索引,解决这个问题,我们可以在刚刚的分类中添加一个属性标签:

    @property (nonatomic, copy) NSString *buryTag;
    
    - (NSString *)buryTag {
        return objc_getAssociatedObject(self, @selector(buryTag));
    }
    
    - (void)setBuryTag:(NSString *)buryTag {
        objc_setAssociatedObject(self, @selector(buryTag), buryTag, OBJC_ASSOCIATION_COPY_NONATOMIC);
    }
    

    通过这个字符串属性,你可以标记你想要的信息作为标志符。
    注意:分类中添加属性需要runtime动态关联

    3 cell点击事件
    UITableView 是日常开发中最为常用的控件,其中监听cell的点击事件,我们需要通过 hook setDelegate 方法来实现。

    + (void)load {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            SEL fromSelector = @selector(setDelegate:);
            SEL toSelector = @selector(yy_setDelegate:);
            [YYHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
        });
    }
    
    - (void)yy_setDelegate:(id<UITableViewDelegate>)delegate {
        [self yy_setDelegate:delegate];
        SEL fromSelector = @selector(tableView:didSelectRowAtIndexPath:);
        SEL toSelector = @selector(yy_tableView:didSelectRowAtIndexPath:);
    
        // 检查 Controller 中是否实现了 tableView:didSelectRowAtIndexPath: 代理方法
        if (![self conformSel:fromSelector inClz:[delegate class]]) {
            return;
        }
        
        //        [YYHook hookClass:[delegate class] fromSelector:fromSelector toSelector:toSelector];
        //    Method method = class_getInstanceMethod([self class], toSelector);
        //        class_replaceMethod([delegate class], toSelector, method_getImplementation(method), method_getTypeEncoding(method));
        
        Method method = class_getInstanceMethod([self class], toSelector);
    
        /**
          1 给 Controller 添加替换方法 yy_tableView:didSelectRowAtIndexPath:
          2 把 Controller 中添加的方法实现在此分类中
         */
        if (class_addMethod([delegate class], toSelector, method_getImplementation(method), method_getTypeEncoding(method))) {
            [YYHook hookClass:[delegate class] fromSelector:fromSelector toSelector:toSelector];
        }
    }
    
    - (void)yy_tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
        [self yy_tableView:tableView didSelectRowAtIndexPath:indexPath];
        /**
          这个方法声明在 tableView 所在的 Controller 中!
          所以通过 [self class] 获取的是 controller 名称
         */
        NSString *controller = NSStringFromClass([self class]);
        NSLog(@"在%@,点击第%ld个cell", controller, indexPath.row);
    }
    
    #pragma mark --- tools
    - (BOOL)conformSel:(SEL)sel inClz:(Class)class {
        unsigned int count = 0;
        Method *methods = class_copyMethodList(class, &count);
        for (int i = 0; i < count; i++) {
            Method method = methods[i];
            NSString *selString = [NSString stringWithUTF8String:sel_getName(method_getName(method))];
            if ([selString isEqualToString:NSStringFromSelector(sel)]) {
                return YES;
            }
        }
        return NO;
    }
    

    思路解释:
    监听 cell的点击事件 最终目的肯定是hook didSelectRowAtIndexPath 方法。
    1 首先 hook setDelegate 方法,页面调用这个方法则说明实现了 UITableViewDelegate 代理,接下来我们就可以从中 hook didSelectRowAtIndexPath 方法了。
    2 因为 UITableViewDelegate 中的 didSelectRowAtIndexPath 方法并不是强制要求实现的。所以在 hook 它之前要先判断页面有没有实现这个代理方法。
    3 在页面中添加替换方法 yy_tableView:didSelectRowAtIndexPath:,但是方法实现写在此分类中。
    4 交换两个方法。

    思考:
    1 如果先进行方法交换,再利用 class_replaceMethod 直接替换页面中的 yy_tableView:didSelectRowAtIndexPath: 是否可行?

    4 手势事件
    对于iOS中的手势事件,我们可以 hook initWithTarget:action: 方法来实现无侵入埋点。但是这其中也会有一些需要注意的地方,直接看代码吧。

    + (void)load {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            SEL fromSelector = @selector(initWithTarget:action:);
            SEL toSelector = @selector(yy_initWithTarget:action:);
            [YYHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
        });
    }
    
    - (instancetype)yy_initWithTarget:(id)target action:(SEL)action {
        SEL fromSelector = action;
        SEL toSelector = @selector(yy_action:);
        UIGestureRecognizer *originGesture = [self yy_initWithTarget:target action:action];
     
        // 1 过滤 target 和 action 为null的情况
        if (!target || !action) {
            return originGesture;
        }
        
        // 2 过滤 系统类 调用的 initWithTarget:action: 方法
        NSBundle *mainB = [NSBundle bundleForClass:[target class]];
        if (mainB != [NSBundle mainBundle]) {
            return originGesture;
        }
        
        // 3
        Method method = class_getInstanceMethod([self class], toSelector);
        if (class_addMethod([target class], toSelector, method_getImplementation(method), method_getTypeEncoding(method))) {
            [YYHook hookClass:[target class] fromSelector:fromSelector toSelector:toSelector];
        }
        NSLog(@"---->>>target: %@", [target class]);
        NSLog(@"----<<<action: %@", NSStringFromSelector(action));
        self.clazzName = NSStringFromClass([target class]);
        self.actionName = NSStringFromSelector(action);
        return originGesture;
    }
    
    - (void)yy_action:(UIGestureRecognizer *)gesture {
        [self yy_action:gesture];
        NSLog(@"点击了%@方法,位于:%@", gesture.actionName, gesture.clazzName);
    }
    

    在 hook initWithTarget:action: 的时候需要注意一些问题,因为 initWithTarget:action: 会被很多系统类调用,而且还有很多 target 为 null的情况。如果这里不做过滤会严重影响性能。

    因为手势事件往往会添加给我们自定义的控件,所以我这里直接通过target过滤了所有系统类。
    接下去的操作步骤基本和 hook didSelectRowAtIndexPath 方法的思路一样。
    同样的,你可以在分类中给手势添加两个属性,用来作为唯一标志。

    上面几种情况的事件监控只是给大家提供思路,具体的应用还需要做大量的测试工作。

    你们可以通过 BuryDemo 做一些改进。

    最后:

    本篇开头有提到过主要的代码埋点有三种方式:代码埋点、可视化埋点
    、无埋点。其中可视化埋点和无埋点都属于无侵入埋点的方案。埋点的技术目前还处于初级阶段,怎么安全又全面的统计用户的行为也是一个很大的课题。
    对此,原文中提到使用 Clang AST的接口,在构建时遍历 AST ,通过定义的规则将所需要的埋点代码直接加进去或许也是一种可行的方式。

    最后的最后,希望大家一同进步。加油!~

    相关文章

      网友评论

        本文标题:戴铭(iOS开发课)读书笔记:09章节-无侵入埋点

        本文链接:https://www.haomeiwen.com/subject/ijtgoqtx.html