前言

本文是继 《神策分析 iOS SDK 全埋点解析之启动与退出》之后,全埋点解析系列博客的第二篇,主要介绍元素点击与页面浏览的全埋点采集方案。在介绍具体的方案之前,我们需要先了解下相关的背景知识。

背景知识

Target-Action

Target-Action,也叫 “目标 – 动作” 模式,即当某个事件发生的时候,调用特定对象的特定方法。“特定对象” 就是 Target,“特定方法” 就是 Action。例如:在 LoginViewController 页面上有一个按钮,点击按钮时,会调用 LoginViewController 里的 – loginBtnOnClick 方法,则 Target 是 LoginViewController, Action 是 – loginBtnOnClick 方法。

Target-Action 设计模式主要包含两个部分:

  • Target(对象):接收消息的对象;
  • Action(方法):用于表示需要调用的方法。

Target 可以是任意类型的对象。但是在 iOS 应用程序中,通常情况下会是一个控制器,而触发事件的对象和接收消息的对象(Target)一样,也可以是任意类型的对象。例如:手势识别器 UIGestureRecognizer 就可以在识别到手势后,将消息发送给另一个对象。关于 Target-Action 模式,最常见的应用场景是在控件中。iOS 中的控件都是 UIControl 类或者其子类,当用户操作这些控件时,控件会将消息发送到指定的 Target,而对应的 Action 必须符合以下几种形式之一 :

- (void)doSomething;
- (void)doSomething:(id)sender;
- (void)doSomething:(id)sender forEvent:(UIEvent *)event;
- (IBAction)doSomething;
- (IBAction)doSomething:(id)sender;
- (IBAction)doSomething:(id)sender forEvent:(UIEvent *)event。

其中,以 IBAction 作为返回值类型,是为了让 Action 能在 Interface Builder 中被看到;参数 sender 就是触发事件的控件本身;参数 event 是 UIEvent 的 Target,封装了触发事件的相关信息。

我们可以通过代码或者 Interface Builder 为一个控件添加一个 Target 以及相应的 Action。

若想使用代码方式添加 Target-Action(Target-Action 可用来表示一个 Target 以及相对应的 Action),我们可以直接调用控件对象的如下方法:

- (void)addTarget:(nullable id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;

我们也可以多次调用 – addTarget:action:forControlEvents: 方法给控件添加多个 Target-Action,即使多次调用 – addTarget:action:forControlEvents:  添加相同的 Target 且不同的 Action,也不会出现相互覆盖的问题。另外,在添加 Target-Action 时,Target 也可以为 nil(默认先在 self 里查找 Action)。

当我们为一个控件添加 Target-Action 后,控件又是如何找到 Target 对象并执行对应的 Action 呢?

在 UIControl 类中有一个方法:

- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;

用户操作控件(例如点击)时,首先会调用这个方法,并将事件转发给应用程序的 UIApplication 对象。

同时,在 UIApplication 类中也有一个类似的实例方法:

- (BOOL)sendAction:(SEL)action to:(nullable id)target from:(nullable id)sender forEvent:(nullable UIEvent *)event;

如果 Target 对象不为 nil,应用程序会让该对象调用对应的方法响应事件;如果 Target 为 nil,应用程序会在响应者链中搜索定义了该方法的对象,然后执行该方法。

基于 Target-Action 设计模式,我们可以实现 $AppClick 事件的全埋点。

Method Swizzling

Method Swizzling,顾名思义,就是交换两个方法的实现。简单来说,就是利用 Objective-C runtime 的动态绑定特性,将一个方法的实现与另一个方法的实现进行交换。

在 Objective-C 的 runtime 中,一个类是用一个名为 objc_class 的结构体表示的,它的定义如下:

struct objc_class {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;

#if !__OBJC2__
    Class _Nullable super_class                              OBJC2_UNAVAILABLE;
    const char * _Nonnull name                               OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list * _Nullable ivars                  OBJC2_UNAVAILABLE;
    struct objc_method_list * _Nullable * _Nullable methodLists                    OBJC2_UNAVAILABLE;
    struct objc_cache * _Nonnull cache                       OBJC2_UNAVAILABLE;
    struct objc_protocol_list * _Nullable protocols          OBJC2_UNAVAILABLE;
#endif

} OBJC2_UNAVAILABLE;

在上面的结构体中,虽然有很多字段在 OBJC2 中已经废弃了(OBJC2_UNAVAILABLE),但是了解这个结构体有助于我们理解 Method Swizzling 的底层原理。从上述结构体中可以发现,有一个 objc_method_list 指针,它保存着当前类的所有方法列表。同时,objc_method_list 也是一个结构体,它的定义如下:

struct objc_method_list {
    struct objc_method_list * _Nullable obsolete             OBJC2_UNAVAILABLE;

    int method_count                                         OBJC2_UNAVAILABLE;
#ifdef __LP64__
    int space                                                OBJC2_UNAVAILABLE;
#endif
    /* variable length structure */
    struct objc_method method_list[1]                        OBJC2_UNAVAILABLE;
}

在上面的结构体中,有一个 objc_method 字段,它的定义如下:

struct objc_method {
    SEL _Nonnull method_name                                 OBJC2_UNAVAILABLE;
    char * _Nullable method_types                            OBJC2_UNAVAILABLE;
    IMP _Nonnull method_imp                                  OBJC2_UNAVAILABLE;
}

从上面的结构体中可以看出,一个方法由下面三个部分组成:

  • method_name:方法名;
  • method_types:方法类型;
  • method_imp:方法实现。

使用 Method Swizzling 交换方法,其实就是修改了 objc_method 结构体中的 method_imp,也即改变了 method_name 和 method_imp 的映射关系:原有的 SEL(A)-IMP(A)、SEL(B)-IMP(B) 对应关系变成 SEL(A)-IMP(B)、SEL(B)-IMP(A)。如图 2-1 所示:

图 2-1 Method Swizzling 前后的映射关系

响应者链

众所周知,UIResponder 类是 iOS 应用程序中专门用来响应用户操作事件的,例如:

  • Touch Events:即触摸事件;
  • Motion Events:即运动事件;
  • Remote Control Events:即远程控制事件。

因为 UIApplication、UIViewController、UIView 类都是 UIResponder 的子类,所以它们都具有响应以上事件的能力。另外,自定义的 UIView 和自定义视图控制器也都可以响应以上事件。在 iOS 应用程序中,UIApplication、UIViewController、UIView 类的对象也都是一个个响应者,这些响应者会形成一个响应者链。一个完整的响应者链传递规则(顺序)如下:UIView → UIViewController → RootViewController → Window → UIApplication → UIApplicationDelegate,如图 2-2 所示:

2-2  事件响应者链(图片来源于 Apple 开发者文档

点击事件

元素点击

方案简介

通过 Target-Action 执行模式可知,在执行 Action 方法之前,会先后通过控件和 UIApplication 对象发送事件相关的信息。因此,我们可以通过 Method Swizzling 交换 UIApplication 的 – sendAction:to:from:forEvent: 方法,然后在交换后的方法中触发 $AppClick 事件,并根据 target 和 sender 采集相关的属性,即可实现 $AppClick 事件的全埋点 。

对于 UIApplication 类中的 – sendAction:to:from:forEvent: 方法,我们以给 UIButton 设置 action 为例介绍如下:

[button addTarget:person action:@selector(btnAction) forControlEvents:UIControlEventTouchUpInside];

参数:

  • action:Action 方法对应的 selector,即示例中的 btnAction;
  • target:Target 对象,即示例中的 person。如果 Target 为 nil,应用程序会将消息发送给第一个响应者,并从第一个响应者沿着响应链向上发送消息,直到消息被处理为止;
  • sender:被用户点击或拖动的控件(发送 Action 消息的对象),即示例中的 button;
  • event:UIEvent 对象,它封装了触发事件的相关信息。

返回值:

如果有 responder 对象处理了此消息,返回 YES,否则返回 NO。

具体实现

下面我们详细介绍如何通过 Method Swizzling 交换 UIApplication 的 – sendAction:to:from:forEvent: 方法来实现 $AppClick 事件的全埋点。

1. 在 SDK 初始化时交换 – sendAction:to:from:forEvent: 方法:

- (void)_enableAutoTrack {
...
    NSError *error = NULL;
    //$AppClick
    // Actions & Events
    [UIApplication sensorsdata_swizzleMethod:@selector(sendAction:to:from:forEvent:)
                                  withMethod:@selector(sensorsdata_sendAction:to:from:forEvent:)
                                       error:&error];
...
}

2. 在交换的方法里做埋点操作:

// action: 需要调用的方法
// to: 接收消息的对象
// from: 需要传递动作消息的参数对象
- (BOOL)sensorsdata_sendAction:(SEL)action to:(id)to from:(id)from forEvent:(UIEvent *)event {
    // 触发点击事件
    [self sa_sendAction:action to:to from:from forEvent:event];
    return YES;
}

至此,一个简单的 $AppClick 事件的全埋点就完成了

方案优化

通过 Method Swizzling 我们实现了简单的 $AppClick 事件的全埋点。但是,仅仅采集一个点击动作并不能满足实际的业务需求,还需要采集与控件相关的信息。

一般情况下,对于一个控件的点击事件,我们至少还需要采集如下信息(属性):

  • 控件类型($element_type);
  • 控件上显示的文本($element_content);
  • 控件所属页面,即 UIViewController($screen_name)。

基于目前的方案,我们来看如何实现采集上述三个属性。

1. 获取控件类型。获取控件类型相对比较简单,我们可以直接使用控件的 class 名称来代表当前控件的类型。获取控件的 class 名称可用如下方式:

NSString *elementType = NSStringFromClass([sender class]);

2. 获取控件上显示的文本。由于一般实现点击的控件都继承于 UIView,因此我们创建一个 UIView 的分类并且实现获取显示内容的方法:

- (NSString *)sensorsdata_elementContent {
    ...
    NSMutableArray<NSString *> *elementContentArray = [NSMutableArray array];
    for (UIView *subview in self.subviews) {
        // 忽略隐藏控件
        if (subview.isHidden || subview.sensorsAnalyticsIgnoreView) {
            continue;
        }
        NSString *temp = subview.sensorsdata_elementContent;
        if (temp.length > 0) {
            [elementContentArray addObject:temp];
        }
    }
    if (elementContentArray.count > 0) {
        [elementContent appendString:[elementContentArray componentsJoinedByString:@"-"]];
    }
    return elementContent.length == 0 ? nil : [elementContent copy];
}

我们知道,可点击的控件有各种类型。因此,我们需要先判断控件的类型,再根据类型来获取对应的显示内容。下面以 UIButton 为例,获取显示内容:

- (NSString *)sensorsdata_elementContent {
    NSString *text = self.titleLabel.text;
    if (!text) {
        text = super.sensorsdata_elementContent;
    }
    return text;
}

3. 获取控件所属页面。如何知道一个 UIView 属于哪个 UIViewController ?这就需要借助 UIResponder 了。通过响应者链可以知道,对于任意一个视图来说,都能通过响应者链找到它所在的视图控制器,也就是其所属的页面,从而达到获取所属页面信息的目的:

- (UIViewController *)sensorsdata_viewController {
...
    UIResponder *response = self;
    while ((response = [response nextResponder])) {
        if ([response isKindOfClass:[UIViewController class]]) {
            return (UIViewController *)response;
        }
    }
    return nil;
...
}

至此,$AppClick 事件的全埋点方案已经可以支持获取控件相关的信息了。

UITableView 和 UICollectionView 点击

方案简介

上一节中我们介绍了通过 Target-Action 方式采集 $AppClick 事件的全埋点,不过还存在两个比较特殊的控件:UITableView 和 UICollectionView

这两个控件的点击一般是指采集 UITableViewCell 和 UICollectionViewCell 的点击事件,而 UITableViewCell 和 UICollectionViewCell 都是直接继承自 UIView 类,而不是 UIControl 类。因此,上节提到的 $AppClick 事件的全埋点方案并不适用。

我们知道,UITableView 和 UICollectionView 实现点击的代理方法分别为 – tableView:didSelectRowAtIndexPath: 和 – collectionView:didSelectItemAtIndexPath:。因此,可以通过 Method Swizzling 的方式来交换方法的实现,从而实现 UITableView 和 UICollectionView 控件的 $AppClick 事件的全埋点采集。

具体实现

由于 UITableView 和 UICollectionView 实现 $AppClick 事件的全埋点方案类似,这里以 UITableView 为例,来介绍如何实现 $AppClick 事件的全埋点。

1. 交换 UITableVIew 的 – setDelegate: 方法:

+ load {
...
    [UITableVIew sensorsdata_swizzleMethod:@selector(setDelegate:)
                                withMethod:@selector(sensorsdata_setDelegate:)];
...
}

2. 在 – sensorsdata_setDelegate: 方法中,我们可以通过参数 delegate 来获取实现 UITableViewDelegate 协议的对象。然后,交换该对象中的 – tableVIew:didSelectRowAtIndexPath: 方法即可:

static void sensorsdata_tablViewDidSelectRowAtIndexPath(id self, SEL _cmd, id tableView, id indexPath) {
    SEL selector = NSSelectorFromString(@"sensorsdata_tableView:didSelectRowAtIndexPath:");
    // 调用原始的 -tableVIew:didSelectRowAtIndexPath: 方法实现
    ((void(*)(id, SEL, id, id))objc_msgSend)(self, selector, tableView, indexPath);
    // 采集 $AppClick 事件
}

3. 由于 UITableView 的 delegate 对象是在运行时动态设置的,该对象具有不确定性。因此,我们需要动态地对 delegate 对象添加需要交换的方法,才能与 – tableView:didSelectRowAtIndexPath:  方法进行交换:

static void sensorsdata_setDelegate(id obj , SEL sel, id delegate) {
 ...
 Class class = [delegate class];
 SEL swizSel = NSSelectorFromString(@"sensorsdata_tableView:didSelectRowAtIndexPath:");
 Method originalMethod = class_getInstanceMethod(class, @selector(tableView:didSelectRowAtIndexPath:));
 Method swizzledMethod = class_getInstanceMethod(class, swizSel);
 method_exchangeImplementations(originalMethod, swizzledMethod);
...
}

至此,我们已经实现了 UITableView 控件的 $AppClick 事件的全埋点采集。UICollectionView 的 $AppClick 事件的全埋点采集整体上与 UITableView 类似,不同之处是交换的方法需要换成 UICollectionView 对应的代理方法 – collectionView:didSelectItemAtIndexPath: ,这里不再赘述了。

方案优化

目前为止我们已经实现了 UITableView 和 UICollectionView 的 $AppClick 事件的全埋点采集。同样的,仅仅采集一个点击动作并不能满足实际的业务需求,还需要采集与控件相关的信息。

对于 UITableView 和 UICollectionView 而言,通常需要采集被点击 cell 的显示内容和所在位置。下面以 UITableView 为例,介绍如何采集 cell 的显示内容和所在位置。

1. 获取点击的 UITableViewCell 对象:

UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];

2. 由于 UITableViewCell 是一个复杂的控件,可能是由众多的元素组合而成。因此,我们需要遍历 UITableViewCell 上的所有元素,然后把获取的元素内容按照一定规则进行拼接:

- (NSString *)sensorsdata_elementContent {
    ...
    NSMutableArray<NSString *> *elementContentArray = [NSMutableArray array];
        for (UIView *subview in self.subviews) {
            // 忽略隐藏控件
            if (subview.isHidden || subview.sensorsensorsdatanalyticsIgnoreView) {
                continue;
            }
            NSString *temp = subview.sensorsdata_elementContent;
            if (temp.length > 0) {
                [elementContentArray addObject:temp];
            }
        }
        if (elementContentArray.count > 0) {
            [elementContent appendString:[elementContentArray componentsJoinedByString:@"-"]];
        }
    }
   return elementContent.length == 0 ? nil : [elementContent copy];
}

3. 至此,我们已经找到了 UITableViewCell 上所有非隐藏元素的显示内容。接下来,我们需要获取 UITableViewCell 的位置。因为已经获取到 indexPath 参数,所以直接按照规则拼接即可:

- (NSString *)sensorsdata_elementPositionWithIndexPath:(NSIndexPath *)indexPath {
    return [NSString stringWithFormat: @"%ld:%ld", (long)indexPath.section, (long)indexPath.row];
}

至此,UITableView 的 $AppClick 事件的全埋点方案已经可以支持获取 cell 的相关信息了。UICollectionView 也可以采用同样的方案获取 cell 的相关信息,这里不再赘述了。

手势采集

在平时的开发过程中,系统提供的可点击控件往往不能满足我们复杂的需求,因此经常需要对 UIView 添加手势来实现视图的点击。

苹果公司为了降低开发者在手势事件处理方面的开发难度,定义了一个抽象类 UIGestureRecognizer 来协助开发者。UIGestureRecognizer 是具体手势识别器的抽象基类,它定义了一组手势识别器常见行为,还支持通过设置委托(即实现了 UIGestureRecognizerDelegate 协议的对象),对某些行为进行更细粒度的定制。

手势识别器必须被添加在一个特定的视图上(例如:UILabel、UIImageView 等控件),这需要通过调用 UIView 类中的 – addGestureRecognizer: 方法进行添加。手势识别器也是用了 Target-Action 设计模式。当我们为一个手势识别器添加一个或者多个 Target-Action 后,在视图上进行触摸操作时,一旦系统识别了该手势,就会向所有的 Target(对象)发送消息,并执行 Action(方法)。虽然手势识别器和 UIControl 类一样,都是使用了 Target-Action 设计模式,但是手势识别器并不会将消息交由 UIApplication 对象来进行发送。因此,我们无法使用与 UIControl 控件相同的处理方式,即无法通过响应者链的方式来实现对手势操作的全埋点。

因为 UIGestureRecognizer 是一个抽象基类,所以它并不会处理具体的手势。因此,对于轻拍(UITapGestureRecognizer)、长按(UILongPressGestureRecognizer)等具体的手势触摸事件,需要使用相应的子类(即具体的手势识别器)进行处理。

常见的具体手势识别器有:

  • UITapGestureRecognizer:轻拍手势;
  • UILongPressGestureRecognizer:长按手势;
  • UIPinchGestureRecognizer:捏合(缩放)手势;
  • UIRotationGestureRecognizer:旋转手势;
  • UISwipeGestureRecognizer:轻扫手势;
  • UIPanGestureRecognizer:平移手势;
  • UIScreenEdgePanGestureRecognizer:屏幕边缘平移手势。

方案简介

通过上节的介绍可以知道,常见的具体手势识别器有很多种。不过,给所有的具体手势识别器添加 Target-Action 的方法都是相同的,常见的主要是通过以下的两个方法进行添加:

  • – initWithTarget:action:;
  • - addTarget:action。

因此,我们可以在添加一个新的 Target-Action 的方法时通过 Method Swizzling 来实现手势全埋点采集。

具体实现

在实际的开发过程中,使用比较多的是 UITapGestureRecognizer 和 UILongPressGestureRecognizer 手势识别器,它们分别用来处理轻拍手势和长按手势。下面我们以这两种手势为例,来介绍如何实现手势全埋点采集。

1. 在初始化 SDK 时交换 – initWithTarget:action: 和 – addTarget:action: 方法:

+ (SensorsAnalyticsSDK *)sharedInstanceWithConfig:(nonnull SAConfigOptions *)configOptions {
...
    [UITapGestureRecognizer sensorsdata_swizzleMethod:@selector(initWithTarget:action:)
                                           withMethod:@selector(sensorsdata_initWithTarget:action:)
                                                error:&error];
    [UITapGestureRecognizer sensorsdata_swizzleMethod:@selector(addTarget:action:)
                                           withMethod:@selector(sensorsdata_addTarget:action:)];
    [UILongPressGestureRecognizer sa_swizzleMethod:@selector(addTarget:action:)
                                        withMethod:@selector(sa_addTarget:action:)
                                             error:&error];
    [UILongPressGestureRecognizer sa_swizzleMethod:@selector(initWithTarget:action:)
                                        withMethod:@selector(sa_initWithTarget:action:)
                                             error:&error];
...
}

2. 在交换后的方法里添加一个新的 Target-Action 即可实现 UITapGestureRecognizer 和 UILongPressGestureRecognizer 手势的采集:

- (instancetype)sensorsdata_initWithTarget:(id)target action:(SEL)action {
    [self sensorsdata_initWithTarget:target action:action];
    // 由于方法已经交换,所以这里是调用 sensorsdata_addTarget:action: 的实现方法
    [self addTarget:target action:action];
    return self;
}
- (void)sensorsdata_addTarget:(id)target action:(SEL)action {
    [self sensorsdata_addTarget:self action:@selector(trackGestureRecognizerAppClick:)];
    [self sensorsdata_addTarget:target action:action];
}

3. 在 – trackGestureRecognizerAppClick: 方法中可以实现 $AppClick 事件的采集:

- (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    ...
    UIView *view = gesture.view;
    // 神策暂定只采集 UILable 和 UIImageView
    BOOL isTrackClass = [view isKindOfClass:UILabel.class] || [view isKindOfClass:UIImageView.class];
    if (!isTrackClass) {
        return;
    }
    // 触发 $AppClick 事件
}

方案优化

我们知道,对于任何一个手势,其实都有不同的状态,例如:

  • UIGestureRecognizerStateBegan;
  • UIGestureRecognizerStateChanged;
  • UIGestureRecognizerStateEnded;
  • UIGestureRecognizerStateCancelled。

上述不同的状态均会触发 Action。因此,目前的方案会造成多次采集 $AppClick 事件。如何解决这个问题呢?由于在触发 Action 的时候可以获取到手势的状态,因此只需要在手势的状态为 UIGestureRecognizerStateEnded 时触发 $AppClick 事件即可解决该问题:

- (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    // 手势处于 Ended 状态
    if (gesture.state != UIGestureRecognizerStateEnded) {
        return;
    }
    ...
    // 触发 $AppClick 事件
}

至此,我们就实现了轻拍和长按手势事件的全埋点方案。对于其他手势事件的全埋点,实现思路都是相同的,这里不再赘述了。

页面浏览事件

众所周知,每一个 UIViewController 都管理着一个由多个视图组成的树形结构,其中根视图保存在 UIViewController 的 view 属性中。UIViewController 会懒加载它所管理的视图集,直到第一次访问 view 属性时,才会去加载或者创建 UIViewController 的视图集。

几种常用的加载或者创建 UIViewController 的视图集的方法如下:

  • 使用 Storyboard;
  • 使用 Nib 文件;
  • 使用代码,即重写 – loadView。

以上这些方法最终都会创建出合适的根视图并保存在 UIViewController 的 view 属性中,这是 UIViewController 生命周期的第一步。当 UIViewController 的根视图需要展示在页面上时,会调用 – viewDidLoad 方法。在这个方法中,我们可以做一些对象初始化相关的工作。

需要注意的是:此时视图的 bounds 还没有确定。如果使用代码创建视图,- viewDidLoad 方法会在 – loadView 方法调用结束之后运行;如果使用 Storyboard 或者 Nib 文件创建视图,- viewDidLoad 方法则会在 – awakeFromNib 方法之后调用。

当 UIViewController 的视图在屏幕上的显示状态发生变化时,UIViewController 会自动回调一些方法,确保子类能够响应到这些变化。图 4-1 展示了 UIViewController 在不同的显示状态时回调不同的方法:

图 4-1  UIViewController 不同状态下的方法调用(图片来源于 Apple 开发者文档

在 UIViewController 被销毁之前,还会回调 – dealloc 方法,我们一般通过重写这个方法来主动释放不能被 ARC 自动释放的资源。

方案简介

我们现在对 UIViewController 的整个生命周期有了一些基本了解。那么如何实现页面浏览事件( $AppViewScreen)的全埋点呢?

通过 UIViewController 的整个生命周期可知,当执行到 – viewDidAppear: 方法时,表示视图已经在屏幕上渲染完成,即页面已经显示出来,正等待用户进行下一步操作。因此,执行到 – viewDidAppear: 方法的时间点是触发页面浏览事件的最佳时机。

如果想要实现页面浏览事件的全埋点,可以通过 Method Swizzling 来交换 – viewDidAppear: 方法,然后在交换后的方法里采集 $AppViewScreen 即可。

具体实现

1. 在初始化 SDK 时交换 – viewDidAppear: 方法:

+ (SensorsAnalyticsSDK *)sharedInstanceWithConfig:(nonnull SAConfigOptions *)configOptions {
...
    [UIViewController sensorsdata_swizzleMethod:@selector(viewDidAppear:)
                                     withMethod:@selector(sensorsdata_viewDidAppear:)];
...
}

2. 在 UIViewController 的分类中实现 – sensorsdata_viewDidAppear: 方法:  

- (void)sensorsdata_viewDidAppear:(BOOL)animated {
  [self sensorsdata_viewDidAppear:animated];
  // 触发 $AppViewScreen
}

3. 由于这种方式采集到的页面浏览事件可能存在一些我们添加的 childViewController,通常情况下我们并不需要这些页面的浏览事件。因此,默认情况下禁止采集 childViewController 的页面浏览事件,并提供使用预编译宏的方式打开采集 childViewController 的页面浏览事件:

- (void)sensorsdata_viewDidAppear:(BOOL)animated {
   SensorsAnalyticsSDK *instance = [SensorsAnalyticsSDK sharedInstance];
#ifndef SENSORS_ANALYTICS_ENABLE_AUTOTRACK_CHILD_VIEWSCREEN
   UIViewController *viewController = (UIViewController *)self;
   if (![viewController.parentViewController isKindOfClass:[UIViewController class]] ||
        [viewController.parentViewController isKindOfClass:[UITabBarController class]] ||
        [viewController.parentViewController isKindOfClass:[UINavigationController class]] ||
        [viewController.parentViewController isKindOfClass:[UIPageViewController class]] ||
        [viewController.parentViewController isKindOfClass:[UISplitViewController class]]) {
        // 触发 $AppViewScreen
        [instance autoTrackViewScreen:viewController];
    }
#else
    // 触发 $AppViewScreen
        [instance autoTrackViewScreen:self];
#endif
        }
  [self sensorsdata_viewDidAppear:animated];
 
}

方案优化

由于上述方案交换了所有 UIViewController 的 – viewDidAppear: 方法,因此会采集到很多我们并不需要的系统页面。为了解决这个问题,我们引入了黑名单机制:把需要忽略的页面存放在一个 json 文件里,在触发 $AppViewScreen 之前对黑名单里的 UIViewController 进行过滤:

- (void)trackViewScreen:(UIViewController *)controller properties:(nullable NSDictionary<NSString *, id> *)properties autoTrack:(BOOL)autoTrack {
  // 判断当前 UIViewController 是否在黑名单内
  if ([self isBlackListViewController:controller ofType:SensorsensorsdatanalyticsEventTypeAppViewScreen]) {
        return;
   }
  // 触发 $AppViewScreen
}

在实际测试过程中,我们发现通过手势侧滑返回时会触发多次页面浏览事件。而造成这种现象的原因是:通过手势侧滑返回的过程中会多次触发 – viewDidAppear: 。因此,我们还需要增加判断来解决这个问题:

- (void)sensorsdata_viewDidAppear:(BOOL)animated {
    if (instance.previousTrackViewController != self) {
        // 触发 $AppViewScreen
    }
    if (instance.previousTrackViewController != self && UIApplication.sharedApplication.keyWindow == self.view.window) {
       instance.previousTrackViewController = self;
    }
 [self sensorsdata_viewDidAppear:animated];
}

至此,我们就实现了页面浏览事件($AppViewScreen)的全埋点。

总结

本文主要介绍了神策分析 iOS SDK 的元素点击与页面浏览的全埋点采集方案,详细的实现可以参考 iOS SDK 源码

在全埋点采集方案中大量使用了 Method Swizzling,这种方式的优点如下:

  1. Method Swizzling 属于成熟的技术,相对比较稳定;
  2. 性能相对来说也比较高。

但是,缺点也显而易见:

  1. 对原始代码有入侵,容易造成冲突;
  2. 一旦出现问题,影响的范围较大。

因此,欢迎大家在开源社区一起讨论更好的解决方案。