iOS 全埋点点击事件采集白皮书

背景

数据分析的流程一般为:数据采集 → 数据传输 → 数据建模 → 数据统计 / 分析 / 挖掘 → 数据可视化 / 反馈,因此, 数据采集是基本,是源头。 用户在 App 内的操作过程是数据采集中非常重要的一类数据,而在这个过程中,点击操作又是最频繁且也是最关键的行为。所以点击事件采集就显得尤为重要了。 点击事件采集一般可分为两种:

  • 代码埋点
  • 全埋点

代码埋点可以精准控制埋点的位置,灵活的自定义事件和属性,但代码埋点成本较高且无法应对需求和设计的变化。全埋点很好的解决了这两类问题,不仅埋点成本低而且在需求或设计变化时,不需要调整代码去适应这类变化。本白皮书主要以全埋点点击事件采集为核心进行介绍。

现状

在 iOS 中,一般有三种方式响应用户的点击行为:

  • UIControl 控件 - UIKit 框架中提供的可响应交互的控件
  • Cell 单元格 - 滑动列表中响应用户操作的元素
  • Gesture 手势 - 针对一些原本不可响应点击,但实际需要响应点击的控件,可通过添加手势实现

这三种方式满足了大部分场景下的用户交互响应,但这三种方式在原理和使用方便都大不相同。对于这三种方式,下面我们将详细介绍如何进行点击事件采集。

UIControl 控件

原理概述

Target-Action

在 UIKit 框架下,是使用 Target-Action 设计模式进行响应 UIControl 控件的点击行为即当某个事件发生 的时候,调用特定对象的特定方法。 Target-Action 设计模式主要包含两个部分:

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

我们可以通过调用如下方法给 UIControl 类或其子类的实例添加一个 Target-Action:

erlang 复制代码
- (void)addTarget:(nullable id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;

当然也可以通过 Interface Builder 以拖拽的方式为一个控件添加 一个 Target 对象以及相对应的 Action 方法。 当我们为一个控件添加 Target-Action 后,控件又是如何找到 Target 对象并执行对应的 Action 方法的呢? 在 UIControl 类中有一个方法:

objectivec 复制代码
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;

如果控件被用户操作(比如点击),首先会调用这个方法, 并将事件转发给应用程序的 UIApplication 对象。 同时,在 UIApplication 类中也有一个类似的实例方法:

objectivec 复制代码
- (BOOL)sendAction:(SEL)action to:(nullable id)target from:(nullable id)sender forEvent:(nullable UIEvent *)event;

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

Method Swizzling

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

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

arduino 复制代码
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 也是一个结构体,它的定义如下:

arduino 复制代码
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 字段,它的定义如下:

ini 复制代码
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)。如下图所示:

采集方案

方案概述

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

方案实现

在 SDK 初始化时交换 - sendAction:to:from:forEvent: 方法:

php 复制代码
- (void)enableAppClickAutoTrack {
    // Actions & Events
    NSError *error = NULL;
    [UIApplication sa_swizzleMethod:@selector(sendAction:to:from:forEvent:)
                         withMethod:@selector(sa_sendAction:to:from:forEvent:)
                              error:&error];
    ...
}

在交换的方法里采集点击事件并调回原方法:

vbnet 复制代码
// action: 需要调用的方法
// to: 接收消息的对象
// from: 需要传递动作消息的参数对象
- (BOOL)sensorsdata_sendAction:(SEL)action to:(id)to from:(id)from forEvent:(UIEvent *)event {
    // 采集点击事件并调回原方法
    return [self sa_sendAction:action to:to from:from forEvent:event];
}

方案优化

页面信息

我们在采集点击事件的同时还会采集控件的属性及控件所属的页面信息。如果用户在控件的点击中进行了页面跳转,那么我们就需要考虑如何保证采集页面信息的正确性。

对于大部分 UIControl 控件,它所归属的页面都是当前页面,因此我们可以先采集点击事件,再执行原方法进行页面跳转。但针对于 UITabBar 来说却并非如此,如下图所示

部 TabBar 上的 TestVC 控件虽然和 DemoVC 显示在同一个页面上,但在业务上其应该归属于第二个页面,所以针对 UITabBar 控件,我们需要先执行原方法进行页面跳转再进行点击事件采集以保证页面属性采集的是准确的。

调整后的代码如下:

vbnet 复制代码
 (BOOL)sa_sendAction:(SEL)action to:(id)to from:(id)from forEvent:(UIEvent *)event {

    BOOL ret = YES;

    // 针对 tab 切换,采集切换后的页面信息,先执行系统 sendAction 完成页面切换
    BOOL isTabBar = [to isKindOfClass:UITabBar.class] || [to isKindOfClass:UITabBarController.class];

    if (isTabBar) {
        ret = [self sa_sendAction:action to:to from:from forEvent:event];
    }

    [self sa_track:action to:to from:from forEvent:event];

    if (!isTabBar) {
        ret = [self sa_sendAction:action to:to from:from forEvent:event];
    }

    return ret;
}
重复采集

如果我们为同一个 UIControl 控件添加多个 Target-Action,在用户进行一次点击时,会调用多次 - sendAction:to:from:forEvent: 方法,这回导致多次采集点击事件。 这种场景下,用户点击后,系统会立即且连续调用已添加的 Target-Action,这个中间间隔时间非常短,因此我们可以针对 UIControl 控件点击事件采集添加一个节流功能:同一控件,在一定时间内,多次调用 - sendAction:to:from:forEvent: 方法,我们只采集一次点击事件。 通过 Associated Object 为 UIView 添加一个 sensorsdata_timeIntervalForLastAppClick 属性:

objectivec 复制代码
- (void)setSensorsdata_timeIntervalForLastAppClick:(NSTimeInterval)sensorsdata_timeIntervalForLastAppClick {
    objc_setAssociatedObject(self, kSALastAppClickIntervalPropertyName, [NSNumber numberWithDouble:sensorsdata_timeIntervalForLastAppClick], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSTimeInterval)sensorsdata_timeIntervalForLastAppClick {
    return [objc_getAssociatedObject(self, kSALastAppClickIntervalPropertyName) doubleValue];
}

在采集点击事件时先判断是否在时间间隔内,如果不在则进行点击事件采集并记录当前的系统开机时间:

objectivec 复制代码
- (void)autoTrackEventWithView:(UIView *)view {
    // 判断时间间隔
    if (![SAAutoTrackUtils isValidAppClickForObject:view]) {
        return;
    }

    ...

    // 保存当前触发时间
    view.sensorsdata_timeIntervalForLastAppClick = [[NSProcessInfo processInfo] systemUptime];
    ....
}

Cell 单元格

原理概述

Cell 点击响应和 UIControl 控件有很大不同,cell 的点击是通过协议中的方法实现的,因此我们对 UITableView 的协议方法 - tableView:didSelectRowAtIndexPath: 和 UICollectionView 的协议方法 - collectionView:didSelectItemAtIndexPath: 进行 hook 即可达到采集的目的。 虽然我们也可以通过上节提到的 Method Swizzling 交换方法的 IMP 来实现采集,但这种方式无法完全适应 cell 点击事件采集,缺陷如下:

  • Method Swizzling 的代码需要确保只执行一次,但代理对象可能会被设置多次
  • 代理对象存在子类继承时,需要区分子类是否重写了要交换的方法
  • 诸如 RxSwift、Texture 等三方库使用消息转发时,则无法进行方法交换

正是因为存在上述缺陷,我们不得不寻找其他 hook 方案。 Method Swizzling 交换方法是对整个类及其子类都生效的,那么是否存在一种 hook 方案只作用于当前的代理对象呢?答案是肯定的。 我们的采集方案是在获取代理对象后,基于该代理对象的类,创建一个子类,该子类继承自原来的类。在子类中对 - tableView:didSelectRowAtIndexPath: 和 - collectionView:didSelectItemAtIndexPath: 方法进行重写,然后将代理对象的 isa 指针指向新建的子类,最后只需要在该代理对象释放的同时释放新建的子类即可。 这样就能够对 cell 点击事件进行采集,并且没有对点击方法进行交换,也就不存在 Method Swizzling 的相关问题。 hook 原理如图 2-2 所示,在我们更改了代理对象的 isa 指针后,当用户点击 cell 时系统会优先调用我们子类重写的 - tableView:didSelectRowAtIndexPath: 或 - collectionView:didSelectItemAtIndexPath: 方法。此时可以进行事件采集,然后调用父类中的方法,完成消息的转发。

采集方案

方案概述

首先通过 Method Swizzling 获取到代理对象,然后根据代理对象动态创建一个子类,在子类中重写 - tableView:didSelectRowAtIndexPath: 和 - collectionView:didSelectItemAtIndexPath: 两个方法,最后设置代理对象的 isa 为动态创建的子类即可。

方案实现

获取代理对象

由于获取代理对象仅需要 hook UITableView 和 UICollectionView 的 - setDelegate: 方法,要 hook 的类是已知的,因此我们可以使用 Method Swizzling:

ini 复制代码
SEL selector = NSSelectorFromString(@"sensorsdata_setDelegate:");
[UITableView sa_swizzleMethod:@selector(setDelegate:) withMethod:selector error:NULL];
[UICollectionView sa_swizzleMethod:@selector(setDelegate:) withMethod:selector error:NULL];

在 - sensorsdata_setDelegate: 方法中即可获取代理对象:

csharp 复制代码
- (void)sensorsdata_setDelegate:(id <UITableViewDelegate>)delegate {
    [self sensorsdata_setDelegate:delegate];
 
    if (delegate == nil) {
        return;
    }
    // 使用委托类去 hook 点击事件方法
    [SADelegateProxy proxyWithDelegate:delegate];
}
创建子类

动态创建子类,需要使用 runtime 的 objc_allocateClassPair 接口,定义如下:

arduino 复制代码
OBJC_EXPORT Class _Nullable
objc_allocateClassPair(Class _Nullable superclass, const char * _Nonnull name,
                       size_t extraBytes)
    OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);
  • superclass:新建子类所要继承的类
  • name:新建子类的类名
  • extraBytes:额外为 ivars 分配的字节数,通常为 0

我们将其封装在一个工具类 SAClassHelper 中:

ini 复制代码
 (Class _Nullable)allocateClassWithObject:(id)object className:(NSString *)className {
    if (!object || className.length <= 0) {
        return nil;
    }
    Class originalClass = object_getClass(object);
    Class subclass = NSClassFromString(className);
    if (subclass) {
        return nil;
    }
    subclass = objc_allocateClassPair(originalClass, className.UTF8String, 0);
    if (class_getInstanceSize(originalClass) != class_getInstanceSize(subclass)) {
        return nil;
    }
    return subclass;
}

意:我们没有使用 NSObject 的 - class 方法获取代理对象的 isa 指针,而是通过 runtime 的 object_getClass 接口获取,这是因为一个类可能会重写 - class 方法。 重写方法 重写方法是为新建的子类添加方法,添加方法使用了 runtime 的 class_addMethod 接口,定义如下:

scss 复制代码
OBJC_EXPORT BOOL
class_addMethod(Class _Nullable cls, SEL _Nonnull name, IMP _Nonnull imp,
                const char * _Nullable types)
    OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);
  • cls:方法要添加到哪个类上
  • name:方法名称
  • imp:方法实现
  • types:方法参数和返回值类型

同样,我们将其封装在一个工具类 SAMethodHelper 中:

scss 复制代码
+ (void)addInstanceMethodWithDestinationSelector:(SEL)destinationSelector sourceSelector:(SEL)sourceSelector fromClass:(Class)fromClass toClass:(Class)toClass {
    Method method = class_getInstanceMethod(fromClass, sourceSelector);
    IMP methodIMP = method_getImplementation(method);
    const char *types = method_getTypeEncoding(method);
    if (!class_addMethod(toClass, destinationSelector, methodIMP, types)) {
        class_replaceMethod(toClass, destinationSelector, methodIMP, types);
    }
}

由于我们需要采集 cell 的点击事件,因此需要重写 - tableView:didSelectRowAtIndexPath: 和 - collectionView:didSelectItemAtIndexPath: 两个方法:

ini 复制代码
[SAMethodHelper addInstanceMethodWithSelector:tablViewSelector fromClass:proxyClass toClass:dynamicClass];
[SAMethodHelper addInstanceMethodWithSelector:collectionViewSelector fromClass:proxyClass toClass:dynamicClass];

点击方法的实现,涉及到消息发送,会在下文详细讲解。 由于我们动态更改了代理对象的 isa 指针,但是我们希望对原始代码而言隐藏该类,因此我们需要重写 - class 方法,让其返回原始类:

ini 复制代码
[SAMethodHelper addInstanceMethodWithSelector:@selector(class) fromClass:proxyClass toClass:dynamicClass];

对于获取原始类需要在新建子类时记录下原始类名,因此我们将原始类名信息通过关联属性的方式绑定在代理对象身上:

objectivec 复制代码
static void *const kSADelegateProxyClassName = (void *)&kSADelegateProxyClassName;
 
@interface NSObject (SACellClick)
 
/// 用于记录创建子类时的原始父类名称
@property (nonatomic, copy, nullable) NSString *sensorsdata_className;
 
@end
 
@implementation NSObject (SACellClick)
 
- (NSString *)sensorsdata_className {
    return objc_getAssociatedObject(self, kSADelegateProxyClassName);
}
 
- (void)setSensorsdata_className:(NSString *)sensorsdata_className {
    objc_setAssociatedObject(self, kSADelegateProxyClassName, sensorsdata_className, OBJC_ASSOCIATION_COPY);
}
 
@end

-class 方法实现:

objectivec 复制代码
- (Class)class {
    if (self.sensorsdata_className) {
        return NSClassFromString(self.sensorsdata_className);
    }
    return [super class];
}
注册子类

通过 objc_allocateClassPair 接口创建的子类需要使用 objc_registerClassPair 注册:

scss 复制代码
OBJC_EXPORT void
objc_registerClassPair(Class _Nonnull cls)
    OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

其中,cls 为待注册的类。

设置 isa

上述关于子类的操作处理完成后,我们需要将代理对象的 isa 指针指向新建的子类,即把代理对象所归属的类设置为新建的子类,这需要使用 runtime 的 object_setClass 接口:

scss 复制代码
OBJC_EXPORT Class _Nullable
object_setClass(id _Nullable obj, Class _Nonnull cls) 
    OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);
  • obj:需要修改的对象
  • cls:对象 isa 指针所指向的类
释放子类

由于在程序运行过程中我们会为每一个代理对象创建子类,如果不进行释放,则会造成内存泄漏。 释放类需要使用 runtime 的 objc_disposeClassPair 接口:

scss 复制代码
OBJC_EXPORT void
objc_disposeClassPair(Class _Nonnull cls) 
    OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

其中,cls 为待释放的类。 在上文中已经提到,我们为每个代理对象的类都创建了唯一的子类,这样在代理对象释放后,我们新建的子类也没有用处了,这时可释放子类。 通过 runtime 源码 我们能够发现在对象释放过程中,一个对象的关联对象释放的时机比较靠后:

scss 复制代码
void *objc_destructInstance(id obj) 
{
    if (obj) {
        // Read all of the flags at once for performance.
        bool cxx = obj->hasCxxDtor();
        bool assoc = obj->hasAssociatedObjects();

        // This order is important.
        if (cxx) object_cxxDestruct(obj);
        if (assoc) _object_remove_assocations(obj);
        obj->clearDeallocating();
    }

    return obj;
}

因此,我们可以通过给对象添加一个关联对象,在关联对象释放时触发一个回调,用来释放新建的子类。 声明一个 class,名为 SADelegateProxyParasite,持有一个 deallocBlock 的属性,在 dealloc 时调用该 block:

less 复制代码
@interface SADelegateProxyParasite : NSObject

@property (nonatomic, copy) void(^deallocBlock)(void);

@end

@implementation SADelegateProxyParasite

- (void)dealloc {
    !self.deallocBlock ?: self.deallocBlock();
}

@end

为 NSObject 扩展一个用来监听对象释放的方法,并在内部持有一个 SADelegateProxyParasite 实例对象:

objectivec 复制代码
static void *const kSADelegateProxyParasiteName = (void *)&kSADelegateProxyParasiteName;

@interface NSObject (SACellClick)

@property (nonatomic, strong) SADelegateProxyParasite *sensorsdata_parasite;

@end

@implementation NSObject (SACellClick)

- (SADelegateProxyParasite *)sensorsdata_parasite {
    return objc_getAssociatedObject(self, kSADelegateProxyParasiteName);
}

- (void)setSensorsdata_parasite:(SADelegateProxyParasite *)parasite {
    objc_setAssociatedObject(self, kSADelegateProxyParasiteName, parasite, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (void)sensorsdata_registerDeallocBlock:(void (^)(void))deallocBlock {
    if (!self.sensorsdata_parasite) {
        self.sensorsdata_parasite = [[SADelegateProxyParasite alloc] init];
        self.sensorsdata_parasite.deallocBlock = deallocBlock;
    }
}

@end

在代理对象的 isa 指针设置完成后,注册监听,用来释放子类:

ini 复制代码
if ([SAClassHelper setObject:delegate toClass:dynamicClass]) {
    [delegate sensorsdata_registerDeallocBlock:^{
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            [SAClassHelper disposeClass:dynamicClass];
        });
    }];
}
消息发送

通过上述步骤,我们已经完成了对代理对象的 hook 操作,接下来就需要处理方法响应时的消息发送。 由于 UITableView 和 UICollectionView 类似,以下内容以 UITableView 为例进行说明。 当用户点击了 UITableViewCell,系统便会调用 UITableView 代理对象中的 - tableView:didSelectRowAtIndexPath: 方法。由于我们重写了该方法,此时会调用到我们的方法中,我们再向父类发送该消息; 由于 - tableView:didSelectRowAtIndexPath: 方法是定义在 UITableViewDelegate 协议中的,无法直接通过父类调用,因此我们通过调用父类的 IMP 实现消息的发送:

scss 复制代码
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    SEL methodSelector = @selector(tableView:didSelectRowAtIndexPath:);
    [SADelegateProxy invokeWithScrollView:tableView selector:methodSelector selectedAtIndexPath:indexPath];
}

+ (void)invokeWithScrollView:(UIScrollView *)scrollView selector:(SEL)selector selectedAtIndexPath:(NSIndexPath *)indexPath {
    NSObject *delegate = (NSObject *)scrollView.delegate;
    Class originalClass = NSClassFromString(delegate.sensorsdata_className) ?: delegate.class;
    IMP originalImplementation = [SAMethodHelper implementationOfMethodSelector:selector fromClass:originalClass];
    if (originalImplementation) {
        ((SensorsDidSelectImplementation)originalImplementation)(delegate, selector, scrollView, indexPath);
    } else if ([SADelegateProxy isRxDelegateProxyClass:originalClass]) {
        ((SensorsDidSelectImplementation)_objc_msgForward)(delegate, selector, scrollView, indexPath);
    }
	// 事件采集
	// ...
}

一共分为如下几个步骤:

  • 从父类获取该 selector 的 IMP 然后执行
  • 若从父类中获取的 IMP 为空,则父类可能是 NSProxy 相关的类,此时我们使用 _objc_msgForward 进行消息转发(这里只对 RxSwift 进行了兼容,下篇文章中会对该逻辑进行优化)
  • 事件采集

至此,我们已经完成了 cell 点击事件的采集,并对其生命周期进行了管理。但这仅仅满足了基本场景下的采集,在真实的使用场景中,我们会遇到各种各样意想不到的问题,在下节中我们将详细讨论。

方案优化

如何兼容 KVO 场景?

当我们对一个对象进行 KVO 属性监听时,系统也会为该对象的类新建一个 NSKVONotifying_ 开头的临时类,关于 KVO 的实现可参考苹果官网文档[1]; 当我们和系统都为代理对象的类新建子类时,情况就会变得非常复杂。

场景一

先设置代理对象,然后对代理对象进行 KVO 属性监听,如下图所示:

这种场景下会存在下述问题: 系统在新建 NSKVONotifying_Delegate 类时,也会重写 - class 方法,用于隐藏这个临时类。在这个场景中 NSKVONotifying_Delegate 继承自 SensorsDelegate,因此 - class 方法的返回值为我们新创建的子类信息,并不是原始类信息。 解决方案: 我们可以在新建子类后,对 - addObserver:forKeyPath:options:context: 方法进行监听。如果代理对象在我们新建子类后又进行了 KVO 属性监听,我们就需要在系统重写 - class 方法后,再次进行重写,并返回原始类:

objectivec 复制代码
[SAMethodHelper addInstanceMethodWithSelector:@selector(addObserver:forKeyPath:options:context:) fromClass:proxyClass toClass:realClass];

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context {
    [super addObserver:observer forKeyPath:keyPath options:options context:context];
    if (self.sensorsdata_className) {
        // 由于添加了 KVO 属性监听, KVO 会创建子类并重写 Class 方法,返回原始类; 此时的原始类为神策添加的子类,因此需要重写 class 方法
        [SAMethodHelper replaceInstanceMethodWithDestinationSelector:@selector(class) sourceSelector:@selector(class) fromClass:SADelegateProxy.class toClass:[SAClassHelper realClassWithObject:self]];
    }
}

场景二

先设置代理对象,然后进行 KVO 属性监听,最后移除 KVO 属性监听,如下图所示: 种场景下没有问题。

场景三

先对代理对象进行 KVO 属性监听,再进行代理对象的设置,如下图所示:

这种场景下会存在下述问题: 在该场景中 SensorsDelegate 继承自 NSKVONotifying_Delegate,这会对系统的 KVO 特性有所影响,在进行属性赋值时会引发崩溃。 解决方案: 如果代理对象的 isa 指针指向的是一个 NSKVONotifying_ 的类,那我们便不再新建子类,而是直接重写 NSKVONotifying_ 类中的 - tableView:didSelectRowAtIndexPath: 方法:

ini 复制代码
if ([SADelegateProxy isKVOClass:realClass]) {
    [SAMethodHelper addInstanceMethodWithSelector:tablViewSelector fromClass:proxyClass toClass:realClass];
    [SAMethodHelper addInstanceMethodWithSelector:collectionViewSelector fromClass:proxyClass toClass:realClass];
    return;
}

场景四

先对代理对象进行 KVO 属性监听,再进行代理对象的设置,最后移除 KVO 属性监听,如下图所示:

这种场景下会存在下述问题:

在移除 KVO 时,系统会将代理对象的 isa 指针直接指回原始类,这时便无法进行点击事件采集了。

解决方案:

在 NSKVONotifying_ 的类中重写 - tableView:didSelectRowAtIndexPath: 方法的同时,对 - removeObserver:forKeyPath: 方法进行监听,在移除 KVO 属性监听时对代理对象再次执行新建子类的操作:

ini 复制代码
if ([SADelegateProxy isKVOClass:realClass]) {
    [SAMethodHelper addInstanceMethodWithSelector:@selector(removeObserver:forKeyPath:) fromClass:proxyClass toClass:realClass];
    return;
}

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
    // remove 前代理对象是否归属于 KVO 创建的类
    BOOL oldClassIsKVO = [SADelegateProxy isKVOClass:[SAClassHelper realClassWithObject:self]];
    [super removeObserver:observer forKeyPath:keyPath];
    // remove 后代理对象是否归属于 KVO 创建的类
    BOOL newClassIsKVO = [SADelegateProxy isKVOClass:[SAClassHelper realClassWithObject:self]];
    
    // 有多个属性监听时, 在最后一个监听被移除后, 对象的 isa 发生变化, 需要重新为代理对象添加子类
    if (oldClassIsKVO && !newClassIsKVO) {
        // 清空已经记录的原始类
        self.sensorsdata_className = nil;
        [SADelegateProxy proxyWithDelegate:self];
    }
}

最终流程

最终处理流程如下图所示:

如何兼容 RxSwift ?

在上节中,我们对 RxSwift 场景下进行了消息转发,此时忽略了一个重要点: 如果使用系统方式设置了 UITableView 的 delegate,这时 RxSwift 会在内部使用 _forwardToDelegate 持有该 delegate,然后在消息转发阶段,对该代理对象发送一次消息,用于保证业务逻辑正常触发。 但是此时我们已经为 delegate 创建了子类,重写了 - tableView:didSelectRowAtIndexPath: 方法。因此在 RxSwift 对代理对象发送的消息会被我们接收,最终导致方法递归调用引发崩溃。 消息发送如下图所示:

参考 _RXDelegateProxy 的源码,- forwardInvocation: 的实现如下所示:

ini 复制代码
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    BOOL isVoid = RX_is_method_signature_void(anInvocation.methodSignature);
    NSArray *arguments = nil;
    if (isVoid) {
        arguments = RX_extract_arguments(anInvocation);
        [self _sentMessage:anInvocation.selector withArguments:arguments];
    }
    
    if (self._forwardToDelegate && [self._forwardToDelegate respondsToSelector:anInvocation.selector]) {
        [anInvocation invokeWithTarget:self._forwardToDelegate];
    }

    if (isVoid) {
        [self _methodInvoked:anInvocation.selector withArguments:arguments];
    }
}

既然 RxSwift 内部会在消息转发时调用 _forwardToDelegate 的 IMP,那么我们在检测到 _forwardToDelegate 时直接调用 IMP,而不是再次进行消息转发即可解决该问题,实现逻辑如下:

ini 复制代码
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    SEL methodSelector = @selector(tableView:didSelectRowAtIndexPath:);
    [SADelegateProxy invokeWithScrollView:tableView selector:methodSelector selectedAtIndexPath:indexPath];
}

+ (void)invokeWithScrollView:(UIScrollView *)scrollView selector:(SEL)selector selectedAtIndexPath:(NSIndexPath *)indexPath {
    NSObject *delegate = (NSObject *)scrollView.delegate;
    Class originalClass = NSClassFromString(delegate.sensorsdata_className) ?: delegate.class;
    IMP originalIMP = [SAMethodHelper implementationOfMethodSelector:selector fromClass:originalClass];
    if (originalIMP) {
        ((SensorsDidSelectImplementation)originalIMP)(delegate, selector, scrollView, indexPath);
    } else if ([SADelegateProxy isRxDelegateProxyClass:originalClass]) {
        NSObject<UITableViewDelegate> *forwardToDelegate = nil;
        if ([delegate respondsToSelector:NSSelectorFromString(@"_forwardToDelegate")]) {
            // 获取 _forwardToDelegate 属性
            forwardToDelegate = [delegate valueForKey:@"_forwardToDelegate"];
        }
        if (forwardToDelegate) {
            Class forwardOriginalClass = NSClassFromString(forwardToDelegate.sensorsdata_className) ?: forwardToDelegate.class;
            IMP forwardOriginalIMP = [SAMethodHelper implementationOfMethodSelector:selector fromClass:forwardOriginalClass];
            if (forwardOriginalIMP) {
                ((SensorsDidSelectImplementation)forwardOriginalIMP)(forwardToDelegate, selector, scrollView, indexPath);
            }
        } else {
            ((SensorsDidSelectImplementation)_objc_msgForward)(delegate, selector, scrollView, indexPath);
        }
    }
	// 事件采集
	// ...
}

但是这种解决方式又存在另外一个问题:同时使用系统方式设置代理和使用订阅的方式订阅点击回调,那么订阅的方式将会无效,因为我们没有再次进行消息转发。 修改后的消息发送如下图所示:

为了完全兼容 RxSwift,我们需要把 _RXDelegateProxy 的 - forwardInvocation: 逻辑实现一遍,直接调用其内部的方法,具体实现如下:

scss 复制代码
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    SEL methodSelector = @selector(tableView:didSelectRowAtIndexPath:);
    [SADelegateProxy invokeWithScrollView:tableView selector:methodSelector selectedAtIndexPath:indexPath];
}

+ (void)invokeRXProxyMethodWithTarget:(id)target selector:(SEL)selector argument1:(SEL)arg1 argument2:(id)arg2 {
    Class cla = NSClassFromString([target sensorsdata_className]) ?: [target class];
    IMP implementation = [SAMethodHelper implementationOfMethodSelector:selector fromClass:cla];
    if (implementation) {
        void(*imp)(id, SEL, SEL, id) = (void(*)(id, SEL, SEL, id))implementation;
        imp(target, selector, arg1, arg2);
    }
}

/// 执行 RxCocoa 中,点击事件相关的响应方法
/// 这个方法中调用的顺序和 _RXDelegateProxy 中的 - forwardInvocation: 方法执行相同
/// @param scrollView UITableView 或者 UICollectionView 的对象
/// @param selector 需要执行的方法:tableView:didSelectRowAtIndexPath: 或者 collectionView:didSelectItemAtIndexPath:
/// @param indexPath 点击的 NSIndexPath 对象
+ (void)rxInvokeWithScrollView:(UIScrollView *)scrollView selector:(SEL)selector selectedAtIndexPath:(NSIndexPath *)indexPath {
    // 1. 执行 _sentMessage:withArguments: 方法
    [SADelegateProxy invokeRXProxyMethodWithTarget:scrollView.delegate selector:NSSelectorFromString(@"_sentMessage:withArguments:") argument1:selector argument2:@[scrollView, indexPath]];

    // 2. 执行 UIKit 的代理方法
    NSObject<UITableViewDelegate> *forwardToDelegate = nil;
    SEL forwardDelegateSelector = NSSelectorFromString(@"_forwardToDelegate");
    IMP forwardDelegateIMP = [(NSObject *)scrollView.delegate methodForSelector:forwardDelegateSelector];
    if (forwardDelegateIMP) {
        forwardToDelegate = ((NSObject<UITableViewDelegate> *(*)(id, SEL))forwardDelegateIMP)(scrollView.delegate, forwardDelegateSelector);
    }
    if (forwardToDelegate) {
        Class forwardOriginalClass = NSClassFromString(forwardToDelegate.sensorsdata_className) ?: forwardToDelegate.class;
        IMP forwardOriginalIMP = [SAMethodHelper implementationOfMethodSelector:selector fromClass:forwardOriginalClass];
        if (forwardOriginalIMP) {
            ((SensorsDidSelectImplementation)forwardOriginalIMP)(forwardToDelegate, selector, scrollView, indexPath);
        }
    }

    // 3. 执行 _methodInvoked:withArguments: 方法
    [SADelegateProxy invokeRXProxyMethodWithTarget:scrollView.delegate selector:NSSelectorFromString(@"_methodInvoked:withArguments:") argument1:selector argument2:@[scrollView, indexPath]];
}

+ (void)invokeWithScrollView:(UIScrollView *)scrollView selector:(SEL)selector selectedAtIndexPath:(NSIndexPath *)indexPath {
    NSObject *delegate = (NSObject *)scrollView.delegate;
    // 优先获取记录的原始父类, 若获取不到则是 KVO 场景, KVO 场景通过 class 接口获取原始类
    Class originalClass = NSClassFromString(delegate.sensorsdata_className) ?: delegate.class;
    IMP originalIMP = [SAMethodHelper implementationOfMethodSelector:selector fromClass:originalClass];
    if (originalIMP) {
        ((SensorsDidSelectImplementation)originalIMP)(delegate, selector, scrollView, indexPath);
    } else if ([SADelegateProxy isRxDelegateProxyClass:originalClass]) {
        [SADelegateProxy rxInvokeWithScrollView:scrollView selector:selector selectedAtIndexPath:indexPath];
    }
	// 事件采集
	// ...
}
如何实现向父类发送消息?

上一节中虽然对 RxSwift 进行了适配,但是存在许多未知的三方库是通过消息转发实现 cell 点击响应的,比如 Texture,我们不能逐一适配每个三方库。 我们的采集方案的本质是创建了子类。对于子类来说,如果重写了一个父类中的方法,我们可以通过 super 去调用父类中的方法,而且无需关心父类中的实现逻辑。若父类未实现,应该由系统去做消息转发。 但是 - tableView:didSelectRowAtIndexPath: 方法是定义在 UITableViewDelegate 协议中的,无法使用 super 关键字,那我们是否可以使用 runtime 相关接口实现向父类发送消息呢?答案是肯定的。 runtime 提供了 objc_msgSendSuper 的接口,定义如下:

scss 复制代码
OBJC_EXPORT id _Nullable
objc_msgSendSuper(struct objc_super * _Nonnull super, SEL _Nonnull op, ...)
    OBJC_AVAILABLE(10.0, 2.0, 9.0, 1.0, 2.0);
  • super:objc_super 类型的结构体信息
  • op:要调用的 selector
  • ...:selector 的相关参数

最终的消息处理逻辑如下:

objectivec 复制代码
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    SEL methodSelector = @selector(tableView:didSelectRowAtIndexPath:);
    [SADelegateProxy invokeWithTarget:self selector:methodSelector scrollView:tableView indexPath:indexPath];
}

+ (void)invokeWithTarget:(NSObject *)target selector:(SEL)selector scrollView:(UIScrollView *)scrollView indexPath:(NSIndexPath *)indexPath {
    Class originalClass = NSClassFromString(target.sensorsdata_className) ?: target.superclass;
    struct objc_super targetSuper = {
        .receiver = target,
        .super_class = originalClass
    };
    // 消息发送给原始类
    void (*func)(struct objc_super *, SEL, id, id) = (void *)&objc_msgSendSuper;
    func(&targetSuper, selector, scrollView, indexPath);
    
    // 当 target 和 delegate 不相等时为消息转发, 此时无需重复采集事件
    if (target != scrollView.delegate) {
        return;
    }
	// 事件采集
	// ...
}

Gesture 手势

原理概述

手势事件采集实现思路并不复杂,但是其中难点较多,本章将详细介绍手势事件采集及相关难点解决。 Apple 提供了 UIGestureRecognizer 相关的类用于处理手势操作,常见的手势如下:

  • UITapGestureRecognizer:点击
  • UILongPressGestureRecognizer:长按
  • UIPinchGestureRecognizer:捏合
  • UIRotationGestureRecognizer:旋转

UIGestureRecognizer 类定义了一组公共行为,可以为所有具体的手势识别器配置这些行为。 手势识别器能够对特定视图进行触摸响应,因此需要通过 UIView 的 - addGestureRecognizer: 方法将视图和手势进行关联。 手势的响应也采用了 Target-Action 模式,但这和 UIControl 控件不同,手势在响应过程中不会调用 UIApplication 的 - sendAction:to:from:forEvent: 方法,所以 UIControl 控件点击事件采集的方案不适用手势。 一个手势识别器可以拥有多个 Target-Action,这些 Target-Action 是相互独立的,手势识别后会向每个 Target-Action 对发送消息,结合 Runtime 的 Method Swizzling,我们可以在用户为手势添加 Target-Action 时,再额外添加一个采集事件的 Target-Action。 总体流程如下图所示:

采集方案

方案概述

通过 Method Swizzling 交换 UIGestureRecognizer 的初始化方法,用来监听手势的创建;在手势创建时为其添加一个 Target-Action 采集手势的点击事件。

方案实现

Method Swizzling:

php 复制代码
    // Gesture
    [UIGestureRecognizer sa_swizzleMethod:@selector(initWithTarget:action:)
                               withMethod:@selector(sensorsdata_initWithTarget:action:)
                                    error:NULL];
    [UIGestureRecognizer sa_swizzleMethod:@selector(addTarget:action:)
                               withMethod:@selector(sensorsdata_addTarget:action:)
                                    error:NULL];

添加采集事件的 Target-Action:

ini 复制代码
- (void)sensorsdata_addTarget:(id)target action:(SEL)action {
  self.sensorsdata_gestureTarget = [SAGestureTarget targetWithGesture:self];
  [self sensorsdata_addTarget:self.sensorsdata_gestureTarget action:@selector(trackGestureRecognizerAppClick:)];
  [self sensorsdata_addTarget:target action:action];
}

手势事件采集:

objectivec 复制代码
- (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    // 手势事件采集
    ...
}

通过 Method Swizzling 我们能够如愿采集手势事件,但这存在一个问题:系统的诸多行为也是通过手势进行实现的,同样会被我们采集,但我们的初衷是只采集用户添加的手势。 部分私有手势如下表所示:

私有手势 继承关系
1 UIScrollViewKnobLongPressGestureRecognizer UIScrollViewKnobLongPressGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
2 _UIDragAddItemsGesture _UIDragAddItemsGesture -> UITapGestureRecognizer -> UIGestureRecognizer
3 _UIDragLiftGestureRecognizer _UIDragLiftGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
4 _UIDragLiftPointerGestureRecognizer _UIDragLiftPointerGestureRecognizer -> _UIDragLiftGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
5 UIKBProductivitySingleTapGesture UIKBProductivitySingleTapGesture -> UITapGestureRecognizer -> UIGestureRecognizer
6 UIKBProductivityDoubleTapGesture UIKBProductivityDoubleTapGesture -> UITapGestureRecognizer -> UIGestureRecognizer
7 _UIWebHighlightLongPressGestureRecognizer _UIWebHighlightLongPressGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
8 _UISingleFingerTapExtensionGesture _UISingleFingerTapExtensionGesture -> UITapGestureRecognizer -> UIGestureRecognizer
9 WKSyntheticTapGestureRecognizer WKSyntheticTapGestureRecognizer -> UITapGestureRecognizer -> UIGestureRecognizer
如何不采集系统私有手势事件,成为了亟待解决的问题。
屏蔽系统私有手势

系统私有手势和公开对外的手势并没有本质区别,都继承或间接继承自 UIGestureRecognizer 类。 当手势被添加了 Target-Action 后,我们可以通过 Target 对象归属的类所在的 Bundle 判断当前的手势是否是系统私有手势。 系统库的 bundle 格式如下:

sql 复制代码
/System/Library/PrivateFrameworks/UIKitCore.framework
/System/Library/Frameworks/WebKit.framework

开发者的 bundle 格式如下:

csharp 复制代码
/private/var/containers/Bundle/Application/8264D420-DE23-48AC-9985-A7F1E131A52A/CDDStoreDemo.app

实现如下:

objectivec 复制代码
- (BOOL)isPrivateClassWithObject:(NSObject *)obj {
    if (!obj) {
        return NO;
    }
 
    NSString *bundlePath = [[NSBundle bundleForClass:[obj class]] bundlePath];
    if ([bundlePath hasPrefix:@"/System/Library"]) {
        return YES;
    }
 
    return NO;
}

这里需要注意的是:该方法不适用于模拟器。 该方案能够区分是否是系统私有手势,但当添加的 Target 是 UIGestureRecognizer 实例对象本身时则无法区分是否是需要采集的手势事件,因此该方案不可行。

仅采集点击和长按手势

调试时能够发现,大部分系统私有手势是子类化的,且开发者很少会对手势进行子类化操作,因此我们可以仅实现对 UITapGestureRecognizer、UILongPressGestureRecognizer 手势的采集,子类化的手势不采集。 我们在创建 Target 对象时,对手势校验,满足条件的手势返回一个有效的 Target 对象。

swift 复制代码
+ (SAGestureTarget * _Nullable)targetWithGesture:(UIGestureRecognizer *)gesture {
    NSString *gestureType = NSStringFromClass(gesture.class);
    if ([gesture isMemberOfClass:UITapGestureRecognizer.class] ||
        [gesture isMemberOfClass:UILongPressGestureRecognizer.class]) {
        return [[SAGestureTarget alloc] init];
    }
    return nil;
}

方案优化

到目前为止,似乎可以正常实现点击和长按手势的采集了。但是,事实远非如此,还有一些难点需要解决。 场景一:在开发者添加 Target-Action 后,又移除了; 场景二:在开发者添加 Target-Action 后,Target 在某些场景下被释放了; 场景三:虽然仅采集了 UITapGestureRecognizer、UILongPressGestureRecognizer,但仍存在一些系统私有手势是未子类化的,被错误采集; 场景四:UIAlertController 点击事件采集需要特殊处理; 场景五:对于部分手势状态需要特殊处理。

管理 Target-Action

针对场景一和场景二,SDK 不应当采集手势事件。但是 SDK 已经添加了 Target-Action,因此需要在采集时判断除了 SDK 添加的 Target-Action,是否还存在有效的 Target-Action,如果不存在则不应当采集手势事件。 对于 UIGestureRecognizer 系统并未提供公开的 API 接口获取当前手势所有的 Target-Action。虽然能够通过私有 API '_targets' 获取,但是有可能对客户产生影响。因此我们通过 hook 相关方法,自己记录 Target-Action 的数量。 新建 SAGestureTargetActionModel 类,用于管理 Target 和 Action:

less 复制代码
@interface SAGestureTargetActionModel : NSObject
 
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL action;
@property (nonatomic, assign, readonly) BOOL isValid;
 
- (instancetype)initWithTarget:(id)target action:(SEL)action;
+ (SAGestureTargetActionModel * _Nullable)containsObjectWithTarget:(id)target andAction:(SEL)action fromModels:(NSArray <SAGestureTargetActionModel *>*)models;
 
@end

在 - addTarget:action: 和 - removeTarget:action: 中记录 Target 数量:

ini 复制代码
- (void)enableAutoTrackGesture {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        ...
        [UIGestureRecognizer sa_swizzleMethod:@selector(removeTarget:action:)
                                   withMethod:@selector(sensorsdata_removeTarget:action:)
                                        error:NULL];
    });
}
 
- (void)sensorsdata_addTarget:(id)target action:(SEL)action {
    if (self.sensorsdata_gestureTarget) {
        if (![SAGestureTargetActionModel containsObjectWithTarget:target andAction:action fromModels:self.sensorsdata_targetActionModels]) {
            SAGestureTargetActionModel *resulatModel = [[SAGestureTargetActionModel alloc] initWithTarget:target action:action];
            [self.sensorsdata_targetActionModels addObject:resulatModel];
            [self sensorsdata_addTarget:self.sensorsdata_gestureTarget action:@selector(trackGestureRecognizerAppClick:)];
        }
    }
    [self sensorsdata_addTarget:target action:action];
}
 
- (void)sensorsdata_removeTarget:(id)target action:(SEL)action {
    if (self.sensorsdata_gestureTarget) {
        SAGestureTargetActionModel *existModel = [SAGestureTargetActionModel containsObjectWithTarget:target andAction:action fromModels:self.sensorsdata_targetActionModels];
        if (existModel) {
            [self.sensorsdata_targetActionModels removeObject:existModel];
        }
    }
    [self sensorsdata_removeTarget:target action:action];
}

在事件采集时,校验是否满足采集条件:

objectivec 复制代码
- (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    if ([SAGestureTargetActionModel filterValidModelsFrom:gesture.sensorsdata_targetActionModels].count == 0) {
        return NO;
    }
    // 手势事件采集
    ...
}
黑名单

针对场景三,神策 SDK 增加了黑名单的配置,通过配置 View 类型来屏蔽这些手势的采集。

json 复制代码
{
    "public": [
        "UIPageControl",
        "UITextView",
        "UITabBar",
        "UICollectionView",
        "UISearchBar"
    ],
    "private": [
        "_UIContextMenuContainerView",
        "_UIPreviewPlatterView",
        "UISwitchModernVisualElement",
        "WKContentView",
        "UIWebBrowserView"
    ]
}

在进行类型比较时,我们对公开和私有的类型进行了区分处理:

  • 公开类名使用 - isKindOfClass: 判断
  • 私有类名使用字符串匹配判断
objectivec 复制代码
- (BOOL)isIgnoreWithView:(UIView *)view {
    ...
    // 公开类名使用 - isKindOfClass: 判断
    id publicClasses = info[@"public"];
    if ([publicClasses isKindOfClass:NSArray.class]) {
        for (NSString *publicClass in (NSArray *)publicClasses) {
            if ([view isKindOfClass:NSClassFromString(publicClass)]) {
                return YES;
            }
        }
    }
    // 私有类名使用字符串匹配判断
    id privateClasses = info[@"private"];
    if ([privateClasses isKindOfClass:NSArray.class]) {
        if ([(NSArray *)privateClasses containsObject:NSStringFromClass(view.class)]) {
            return YES;
        }
    }
    return NO;
}
UIAlertController 点击事件采集

UIAlertController 内部是通过手势实现用户交互操作,但其手势所在的 View 并不是用户操作的 View,且在不同的系统版本中内部实现略有不同。 我们通过使用不同的处理器来处理这种特殊逻辑。 新建工厂类 SAGestureViewProcessorFactory 来决定使用的处理器:

css 复制代码
@implementation SAGestureViewProcessorFactory
 
+ (SAGeneralGestureViewProcessor *)processorWithGesture:(UIGestureRecognizer *)gesture {
    NSString *viewType = NSStringFromClass(gesture.view.class);
    if ([viewType isEqualToString:@"_UIAlertControllerView"]) {
        return [[SALegacyAlertGestureViewProcessor alloc] initWithGesture:gesture];
    }
    if ([viewType isEqualToString:@"_UIAlertControllerInterfaceActionGroupView"]) {
        return [[SANewAlertGestureViewProcessor alloc] initWithGesture:gesture];
    }
    return [[SAGeneralGestureViewProcessor alloc] initWithGesture:gesture];
}
 
@end

然后在具体的处理器中处理差异:

objectivec 复制代码
#pragma mark - 适配 iOS 10 以前的 Alert
@implementation SALegacyAlertGestureViewProcessor
 
- (BOOL)isTrackable {
    if (![super isTrackable]) {
        return NO;
    }
    // 屏蔽 SAAlertController 的点击事件
    UIViewController *viewController = [SAAutoTrackUtils findNextViewControllerByResponder:self.gesture.view];
    if ([viewController isKindOfClass:UIAlertController.class] && [viewController.nextResponder isKindOfClass:SAAlertController.class]) {
        return NO;
    }
    return YES;
}
 
- (UIView *)trackableView {
    NSArray <UIView *>*visualViews = sensorsdata_searchVisualSubView(@"_UIAlertControllerCollectionViewCell", self.gesture.view);
    CGPoint currentPoint = [self.gesture locationInView:self.gesture.view];
    for (UIView *visualView in visualViews) {
        CGRect rect = [visualView convertRect:visualView.bounds toView:self.gesture.view];
        if (CGRectContainsPoint(rect, currentPoint)) {
            return visualView;
        }
    }
    return nil;
}
 
@end
 
#pragma mark - 适配 iOS 10 及以后的 Alert
@implementation SANewAlertGestureViewProcessor
 
- (BOOL)isTrackable {
    if (![super isTrackable]) {
        return NO;
    }
    // 屏蔽 SAAlertController 的点击事件
    UIViewController *viewController = [SAAutoTrackUtils findNextViewControllerByResponder:self.gesture.view];
    if ([viewController isKindOfClass:UIAlertController.class] && [viewController.nextResponder isKindOfClass:SAAlertController.class]) {
        return NO;
    }
    return YES;
}
 
- (UIView *)trackableView {
    NSArray <UIView *>*visualViews = sensorsdata_searchVisualSubView(@"_UIInterfaceActionCustomViewRepresentationView", self.gesture.view);
    CGPoint currentPoint = [self.gesture locationInView:self.gesture.view];
    for (UIView *visualView in visualViews) {
        CGRect rect = [visualView convertRect:visualView.bounds toView:self.gesture.view];
        if (CGRectContainsPoint(rect, currentPoint)) {
            return visualView;
        }
    }
    return nil;
}
 
@end
处理手势状态

手势识别器是由状态机驱动的,默认状态是 UIGestureRecognizerStatePossible,表示已经准备好开始处理事件。 状态之间的转换如下图所示:

针对全埋点,无论手势状态是 UIGestureRecognizerStateEnded 还是 UIGestureRecognizerStateCancelled 都应当采集手势事件:

objectivec 复制代码
- (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    if (gesture.state != UIGestureRecognizerStateEnded &&
        gesture.state != UIGestureRecognizerStateCancelled) {
        return;
    }
    // 手势事件采集
    ...
}
 
@end

展望未来

至此,我们已经可以通过全埋点采集 UIControl 控件、Cell 单元格、Gesture 手势这些场景的点击事件,这满足了大部分客户的需求,但仍有一些极端场景无法进行自动埋点采集。

UITouch

在 iOS 中,除了上述方式可以响应点击外,系统还提供了一系列的触摸响应方法:

objectivec 复制代码
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
  • 触摸开始
  • 触摸移动
  • 触摸结束
  • 触摸取消

通过重写这些方法可以灵活的自定义各种响应事件,但这对于数据采集来说自动采集就变得异常困难,因为这和客户的具体实现有关。

hitTest

系统还提供了 - hitTest:withEvent: 方法,用于指定可以响应事件的控件:

objectivec 复制代码
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;

客户可以通过重写这个方法,指定任意控件作为可响应的控件。对于这种场景,我们也无法做到自动埋点。 对于这两种场景,目前只能通过代码埋点来弥补。

总结

本文主要系统的讲解了 iOS 全埋点点击实现的相关技术原理以及存在的一些问题,如果对埋点技术感兴趣或者对埋点 SDK 有需要的,请关注我们的公众号(神策埋点 SDK),也可以访问我们的官网

相关推荐
一切顺势而行22 分钟前
Flink cdc 使用总结
大数据·flink
淦暴尼2 小时前
基于spark的二手房数据分析可视化系统
大数据·分布式·数据分析·spark
Ashlee_code3 小时前
裂变时刻:全球关税重构下的券商交易系统跃迁路线图(2025-2027)
java·大数据·数据结构·python·云原生·区块链·perl
Flink_China3 小时前
淘天AB实验分析平台Fluss落地实践:更适合实时OLAP的消息队列
大数据·flink
阿里云大数据AI技术4 小时前
云上AI推理平台全掌握 (4):大模型分发加速
大数据·人工智能·llm
189228048615 小时前
NW972NW974美光固态闪存NW977NW981
大数据·服务器·网络·人工智能·性能优化
黄雪超5 小时前
Kafka——无消息丢失配置怎么实现?
大数据·分布式·kafka
J船长6 小时前
gRPC 与传统 REST API 的区别与实践详解
app
wuyoula6 小时前
iOS V2签名网站系统源码/IPA在线签名/全开源版本/亲测
ios