写点什么

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

发布于: 1 小时前
七步实现列表点击事件的采集

前言

在 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: 方法。此时可以进行事件采集,然后调用父类中的方法,完成消息的转发。


图 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 <UITableViewDelegate>)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-swizzling/


[2]https://developer.apple.com/documentation/objectivec/objective-c_runtime#//apple_ref/c/func/method_getImplementation


[3]https://opensource.apple.com/tarballs/objc4/


[4]https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtHowMessagingWorks.html#//apple_ref/doc/uid/TP40008048-CH104-SW1


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

发布于: 1 小时前阅读数: 2
用户头像

公众号:神策技术社区 2021.03.09 加入

公众号:神策技术社区

评论

发布
暂无评论
七步实现列表点击事件的采集