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

前言

在 iOS 全埋点采集中,cell 点击事件采集通常是指对 UITableViewCell 和 UICollectionViewCell 的用户点击行为进行采集。
cell 的点击是通过协议中的方法实现的,因此我们对 UITableView 的协议方法 - tableView:didSelectRowAtIndexPath: 和 UICollectionView 的协议方法 - collectionView:didSelectItemAtIndexPath: 进行 hook 即可达到采集的目的。
在 iOS 中对方法进行 hook,最简单的方式就是通过 Method Swizzling[1] 交换方法的 IMP,但这种方式无法完全适应 cell 点击事件采集,缺陷如下:
Method Swizzling 的代码需要确保只执行一次,但代理对象可能会被设置多次;
代理对象存在子类继承时,需要区分子类是否重写了要交换的方法;
诸如 RxSwift、Texture 等三方库使用消息转发时,则无法进行方法交换。
正是因为存在上述缺陷,我们不得不寻找其他 hook 方案。

方案概述

Method Swizzling 交换方法是对整个类及其子类都生效的,那么是否存在一种 hook 方案只作用于当前的代理对象呢?答案是肯定的。
我们的采集方案是在获取代理对象后,基于该代理对象的类,创建一个独一无二的子类,该子类继承自原来的类。在子类中对 - tableView:didSelectRowAtIndexPath: 和 - collectionView:didSelectItemAtIndexPath: 方法进行重写,然后将代理对象的 isa 指针指向新建的子类,最后只需要在该代理对象释放的同时释放新建的子类即可。
这样就能够对 cell 点击事件进行采集,并且没有对点击方法进行交换,也就不存在 Method Swizzling 的相关问题。

原理

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

七步实现列表点击事件的采集_第1张图片
图 2-1 代理对象的 isa 指针变化
实现

获取代理

由于获取代理对象仅需要 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];}

创建子类

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

OBJC_EXPORT Class _Nullableobjc_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 BOOLclass_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];}

注册子类

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

OBJC_EXPORT voidobjc_registerClassPair(Class _Nonnull cls) OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

其中,cls 为待注册的类。
设置 isa

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

OBJC_EXPORT Class _Nullableobject_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 接口:

OBJC_EXPORT voidobjc_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]; }); }];}

消息发送

通过上述步骤,我们已经完成了对代理对象的 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-...
[2]https://developer.apple.com/d...
[3]https://opensource.apple.com/...
[4]https://developer.apple.com/l...

文章来源:公众号神策技术社区

你可能感兴趣的:(前端后端数据库)