iOS中无侵入埋点的实现方案

1,463 阅读9分钟

这是我参与8月更文挑战的第19天,活动详情查看: 8月更文挑战

为什么进行埋点

在我们平时的App开发过程中,随着业务需求的不断增加,最终为了分析用户行为,都需要在App中进行埋点,埋点可以解决两大类问题:

  • 了解用户使用App的行为;
  • 降低分析线上问题的难度。

埋点的分类

目前,iOS开发中常见的埋点方式,主要包括代码埋点可视化埋点无埋点这三种。

  • 代码埋点:主要就是通过手写代码的方式来埋点,能很精确的在需要埋点的代码处加上埋点的代码,可以很方便地记录当前环境的变量值,方便调试,并跟踪埋点内容,但存在开发工作量大,并且埋点代码到处都是,后期难以维护等问题。
  • 可视化埋点:将埋点增加和修改的工作可视化了,提升了增加和维护埋点的体验。
  • 无埋点:并不是不需要埋点,而更确切地说是“全埋点”,而且埋点代码不会出现在业务代码中,容易管理和维护。它的缺点在于,埋点成本高,后期的解析也比较复杂,再加上view_path的不确定性。所以,这种方案并不能解决所有的埋点需求,但对于大量通用的埋点需求来说,能够节省大量的开发和维护成本。

在这三种方案中,可视化埋点无埋点都属于是无侵入的埋点方案;因为它们都不需要在工程代码中写入埋点代码。所以,采用这样的无侵入埋点方案,既可以做到埋点被统一维护,又可以实现和工程代码的解耦。

Hook埋点

我们最常见的埋点就是对页面进入次数,页面停留时间,页面点击事件等进行埋点;那么为了实现无侵入埋点,我们可以利用runtime技术;

我们创建一个用来进行方法交换的类PVHook,实现方法+ (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector来进行方法的替换,其代码实现如下:


#import "PVHook.h"
#import <objc/runtime.h>

@implementation PVHook

+ (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {
    Class class = classObject;
    // 得到被替换类的实例方法
    Method fromMethod = class_getInstanceMethod(class, fromSelector);
    // 得到替换类的实例方法
    Method toMethod = class_getInstanceMethod(class, toSelector);
    
    // class_addMethod 返回成功表示被替换的方法没实现,然后会通过 class_addMethod 方法先实现;返回失败则表示被替换方法已存在,可以直接进行 IMP 指针交换 
    if(class_addMethod(class, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
      // 进行方法的替换
        class_replaceMethod(class, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
    } else {
      // 交换 IMP 指针
        method_exchangeImplementations(fromMethod, toMethod);
    }
}

@end

其运用运行时机制,将方法的实现进行了交换,原有方法被调用时将会被hook,从而去执行交换之后的方法;

UIViewController的生命周期埋点

我们以页面进入次数停留时间为例进行埋点说明,要想实现这两个埋点,就需要对UIViewController的生命周期进行埋点

我们可以创建一个UIViewController的分类进行操作,来隔离业务代码:


@implementation UIViewController (Log)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{ // 确定只被交换一次
        // 通过 @selector 获得被替换和替换方法的 SEL,作为 PVHook:hookClass:fromeSelector:toSelector 的参数传入 
        SEL fromSelectorAppear = @selector(viewWillAppear:);
        SEL toSelectorAppear = @selector(hook_viewWillAppear:);
        [PVHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];
        
        SEL fromSelectorDisappear = @selector(viewWillDisappear:);
        SEL toSelectorDisappear = @selector(hook_viewWillDisappear:);
        
        [PVHook hookClass:self fromSelector:fromSelectorDisappear toSelector:toSelectorDisappear];
    });
}

- (void)hook_viewWillAppear:(BOOL)animated {
    // 先执行插入代码,再执行原 viewWillAppear 方法
    [self insertToViewWillAppear];
    [self hook_viewWillAppear:animated];
}
- (void)hook_viewWillDisappear:(BOOL)animated {
    // 执行插入代码,再执行原 viewWillDisappear 方法
    [self insertToViewWillDisappear];
    [self hook_viewWillDisappear:animated];
}

- (void)insertToViewWillAppear {
    // 在ViewWillAppear 时进行日志的埋点
}
- (void)insertToViewWillDisappear {
    // 在ViewWillDisappear 时进行日志的埋点
}
@end
  • 在分类中,我们在load方法中使用PVHook进行方法交换,在交换之后的方法中插入的insertToViewWillAppear方法中进行埋点;这样的话,每一个UIViewController生命周期到viewWillAppear的时候都会执行insertToViewWillAppear方法进行埋点操作;
  • 在埋点时可以通过NSStringFromClass([self class])方法来获取类名,用来区分当前不同的UIViewController

停留时间可以根据两个生命周期方法的埋点时间计算得出;

UIButton的点击事件埋点

对于点击事件,我们依然可以通过此种方式进行无侵入埋点,最重要的就是我们需要找到sendAction:to:forEvent:方法,然后在load方法中进行方法替换,代码如下:


+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 通过 @selector 获得被替换和替换方法的 SEL,作为 PVHook:hookClass:fromeSelector:toSelector 的参数传入
        SEL fromSelector = @selector(sendAction:to:forEvent:);
        SEL toSelector = @selector(hook_sendAction:to:forEvent:);
        [PVHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
    });
}

- (void)hook_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    [self insertToSendAction:action to:target forEvent:event];
    [self hook_sendAction:action to:target forEvent:event];
}
- (void)insertToSendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    // 日志记录
    if ([[[event allTouches] anyObject] phase] == UITouchPhaseEnded) {
        // 获取action,选择器名字
        NSString *actionString = NSStringFromSelector(action);
        // 获取target,视图名字
        NSString *targetName = NSStringFromClass([target class]);
        
        // 记录日志
    }
}
  • UIViewController的生命周期中埋点操作不同的是,我们在UIButton所在的视图中可能创建了多个UIButton,当前视图中可能存在UIButton的不同继承类,这些我们都需要区分开,所以我们需要通过actionStringtargetName共同组合为一个唯一的标识,来进行区分埋点;

除了我们上文介绍过的UIViewControllerUIButton控件之外,Cocoa框架的其他控件大都可以通过这种方式进行无侵入埋点;比如我们经常用到的UITableView控件,我们可以通过hook其代理delegatesetter方法,也就是setDelegate方法,然后通过交换方法来实现埋点;手势事件可以通过hook方法initWithTarget:action来实现埋点;

埋点事件唯一标识

通过运行时交换方法的方式,我们能够hook到几乎所以的Objective-C方法,正常情况下,已经能够满足我们的日常埋点需求;

但是这种方案的精确粒度还是不够高,有些情况下还是无法满足需求;比如一个视图中同一个UIButton的实例button1button2,仅仅通过我们前面介绍的actionStringtargetName这种选择器名字视图类名组成唯一标识的方式还是无法区分的。那么这个时候,我们就需要一个真正的事件唯一标识来区分;

那么如果制定出这个唯一标识么?我们首先可能会想到,通过视图树的层级关系来解决这个问题,因为每个界面都有一个视图树结构,通过视图superViewsubViews等属性,我们能够还原出每一个界面的视图树。

视图树的最顶层为UIWindow,接下里的每一个视图都在这个树的的子节点上,结构参考如下:

一个视图下的子节点可能是同一个视图的不同实例,比如上图中UIView视图节点下的两个UIButton是同一个类的不同实例,所以单单靠视图树的路径还是无法确定出视图的唯一标识,那么在这种情况下,我们又该如何处理呢?

每一个子视图父视图中都会有一个自己的索引,那么是不是我们再加上这个索引的话,就能确定每一个视图的唯一标识了呢?视图层级路径上加上在父视图中的索引来确定的唯一标识,是不是就能够覆盖所有情况了呢?

UITableViewCell的唯一标识

当然无法覆盖所有情况,我们还需要考虑UITableViewCell这种具有可复用机制的视图,Cell会在页面上滚动时不断复用,所以索引的方式在这种情况下,基本宣告无效;

那么分析到这里,这个问题是不是就无解了呢?UITableViewCell需要使用indexPath,这个值里边包含了sectionrow两个值,所以我们可以通过indexPath来确定每一个Cell的唯一性;

UIAlertController的唯一标识

除此之外,UIAlertController也是比较特殊的存在,它的特殊之处在于视图层级的不固定;因为我们可以在任何地方弹出UIAlertController,它可能出现在App中的任何一个界面中。但是UIAlertController的功能往往是通过弹窗的内容来区分的,那么我们就可以通过内容来确定它的唯一标识;

除此之外,还可能有很多其他的特殊情况需要我们处理,但是我们一定可以通过一些其它方法来确定出它们的唯一标识来,大体思路就是:通过找出元素间不同的因素,然后进行组合,最后可以形成一个能够区别于其它元素的标识来;

难以解决的埋点情况

除了上边提到的集中特殊情况,还有一种情况我们需要特别留意;那就是如果视图的层级关系在运行时会被更改,比如在运行时执行insertSubView:atIndex:或者removeFromSuperview等相关方法时,我们就难以获得准确的唯一标识了;

即使运行时不会修改视图的层级关系,但是随着需求的不断迭代,界面会频繁的更新,视图的唯一标识也需要同步的进行更新维护;在这种情况,由于事件唯一标识的准确性很难得到保障,那么也就很难选找出合适的解决方案了;这也是为何通过运行时方法替换进行无侵入埋点的方案在各个公司中难以全面覆盖的原因;虽然无侵入埋点无法解决所有问题,依然面临很多挑战,但是其方案能够解决大部分情况下的买点需求,会在一定程度上节省大量的人力成本;

总结

无侵入埋点的方案由于其事件唯一标识难以维护和准确性难以得到保障,很难被全面采用;大部分情况我们依然还是倾向于在功能和视图稳定的地方进行手动侵入式埋点;但是我们可以通过无侵入埋点与侵入式埋点相结合的方式来大大降低埋点的侵入范围;