七步实现列表点击事件的采集

image.png

一、前言

在 iOS 全埋点采集中,cell 点击事件采集通常是指对 UITableViewCell 和 UICollectionViewCell 的用户点击行为进行采集。

cell 的点击是通过协议中的方法实现的,因此我们对 UITableView 的协议方法 – tableView:didSelectRowAtIndexPath: 和 UICollectionView 的协议方法 – collectionView:didSelectItemAtIndexPath: 进行 hook 即可达到采集的目的。

在 iOS 中对方法进行 hook,最简单的方式就是通过 Method Swizzling[1] 交换方法的 IMP,但这种方式无法完全适应 cell 点击事件采集,缺陷如下:

Method Swizzling 的代码需要确保只执行一次,但代理对象可能会被设置多次;
代理对象存在子类继承时,需要区分子类是否重写了要交换的方法;
诸如 RxSwift、Texture 等三方库使用消息转发时,则无法进行方法交换。
正是因为存在上述缺陷,我们不得不寻找其他 hook 方案。

二、方案

2.1 概述

Method Swizzling 交换方法是对整个类及其子类都生效的,那么是否存在一种 hook 方案只作用于当前的代理对象呢?答案是肯定的。

我们的采集方案是在获取代理对象后,基于该代理对象的类,创建一个独一无二的子类,该子类继承自原来的类。在子类中对 – tableView:didSelectRowAtIndexPath: 和 – collectionView:didSelectItemAtIndexPath: 方法进行重写,然后将代理对象的 isa 指针指向新建的子类,最后只需要在该代理对象释放的同时释放新建的子类即可。

这样就能够对 cell 点击事件进行采集,并且没有对点击方法进行交换,也就不存在 Method Swizzling 的相关问题。

2.2原理

hook 原理如图 2-1 所示,在我们更改了代理对象的 isa 指针后,当用户点击 cell 时系统会优先调用我们子类重写的 – tableView:didSelectRowAtIndexPath: 或 – collectionView:didSelectItemAtIndexPath: 方法。此时可以进行事件采集,然后调用父类中的方法,完成消息的转发。

image.png
图 2-1 代理对象的 isa 指针变化

2.3实现

2.3.1. 获取代理

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

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: 方法中即可获取代理对象:

  • (void)sensorsdata_setDelegate:(id )delegate {
    [self sensorsdata_setDelegate:delegate];

    if (delegate == nil) {
    return;
    }
    // 使用委托类去 hook 点击事件方法
    [SADelegateProxy proxyWithDelegate:delegate];

}

2.3.2. 创建子类

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

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 中:

  • (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 接口,定义如下:

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 中:

  • (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: 两个方法:

[SAMethodHelper addInstanceMethodWithSelector:tablViewSelector fromClass:proxyClass toClass:dynamicClass];
[SAMethodHelper addInstanceMethodWithSelector:collectionViewSelector fromClass:proxyClass toClass:dynamicClass];
点击方法的实现,涉及到消息发送,会在下文详细讲解。

由于我们动态更改了代理对象的 isa 指针,但是我们希望对原始代码而言隐藏该类,因此我们需要重写 – class 方法,让其返回原始类:

[SAMethodHelper addInstanceMethodWithSelector:@selector(class) fromClass:proxyClass toClass:dynamicClass];
对于获取原始类需要在新建子类时记录下原始类名,因此我们将原始类名信息通过关联属性的方式绑定在代理对象身上:

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 方法实现:

  • (Class)class {
    if (self.sensorsdata_className) {
    return NSClassFromString(self.sensorsdata_className);
    }
    return [super class];

}

2.3.4. 注册子类

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

OBJC_EXPORT void
objc_registerClassPair(Class _Nonnull cls)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);
其中,cls 为待注册的类。

2.3.5. 设置 isa

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

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 指针所指向的类。
2.3.6. 释放子类
由于在程序运行过程中我们会为每一个代理对象创建子类,如果不进行释放,则会造成内存泄漏。
释放类需要使用 runtime 的 objc_disposeClassPair 接口:
OBJC_EXPORT void
objc_disposeClassPair(Class _Nonnull cls)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);
其中,cls 为待释放的类。

在上文中已经提到,我们为每个代理对象的类都创建了唯一的子类,这样在代理对象释放后,我们新建的子类也没有用处了,这时可释放子类。

通过 runtime 源码[3]我们能够发现在对象释放过程中,一个对象的关联对象释放的时机比较靠后:

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:

@interface SADelegateProxyParasite : NSObject

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

@end

@implementation SADelegateProxyParasite

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

}

@end
为 NSObject 扩展一个用来监听对象释放的方法,并在内部持有一个 SADelegateProxyParasite 实例对象:
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 指针设置完成后,注册监听,用来释放子类:
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];
});
}];
}
2.3.7. 消息发送

通过上述步骤,我们已经完成了对代理对象的 hook 操作,接下来就需要处理方法响应时的消息发送[4]。

由于 UITableView 和 UICollectionView 类似,以下内容以 UITableView 为例进行说明。

当用户点击了 UITableViewCell,系统便会调用 UITableView 代理对象中的 – tableView:didSelectRowAtIndexPath: 方法。由于我们重写了该方法,此时会调用到我们的方法中,我们再向父类发送该消息;

由于 – tableView:didSelectRowAtIndexPath: 方法是定义在 UITableViewDelegate 协议中的,无法直接通过父类调用,因此我们通过调用父类的 IMP 实现消息的发送:

  • (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 场景?
如何兼容消息转发场景?
如何实现向父类发送消息?
参考文献:

[1]https:nshipster.com/method-swiz…

[2]developer.apple.com/documentati…

[3]opensource.apple.com/tarballs/ob…

[4]developer.apple.com/library/arc…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享