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

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


前言:

原文中介绍了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 ,通过定义的规则将所需要的埋点代码直接加进去或许也是一种可行的方式。

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

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

推荐阅读更多精彩内容