关于前端:七步实现列表点击事件的采集

10次阅读

共计 8460 个字符,预计需要花费 22 分钟才能阅读完成。

前言

在 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-…
[2]https://developer.apple.com/d…
[3]https://opensource.apple.com/…
[4]https://developer.apple.com/l…

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

正文完
 0