1. 前言
在上篇七步实现列表点击事件的采集文章中我们已经详细介绍了如何在运行时创建子类进行 cell 点击事件采集,本篇将继续探讨在真实场景中所遇到的问题,并逐个进行解决。
2. 踩过的坑
2.1. KVO
当我们对一个对象进行 KVO 属性监听时,系统也会为该对象的类新建一个 NSKVONotifying_ 开头的临时类,关于 KVO 的实现可参考苹果官网文档[1];
当我们和系统都为代理对象的类新建子类时,情况就会变得非常复杂。
2.1.1. 场景一
先设置代理对象,然后对代理对象进行 KVO 属性监听,如图 2-1 所示:
图 2-1 场景一的 isa 指针变化过程图
这种场景下会存在下述问题:
系统在新建 NSKVONotifying_Delegate 类时,也会重写 - class 方法,用于隐藏这个临时类。在这个场景中 NSKVONotifying_Delegate 继承自 SensorsDelegate,因此 - class 方法的返回值为我们新创建的子类信息,并不是原始类信息。
解决方案:
我们可以在新建子类后,对 - addObserver:forKeyPath:options:context: 方法进行监听。如果代理对象在我们新建子类后又进行了 KVO 属性监听,我们就需要在系统重写 - class 方法后,再次进行重写,并返回原始类:
[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]]; }}
2.1.2. 场景二
先设置代理对象,然后进行 KVO 属性监听,最后移除 KVO 属性监听,如图 2-2 所示:
图 2-2 场景二的 isa 指针变化过程图
这种场景下没有问题。
2.1.3. 场景三
先对代理对象进行 KVO 属性监听,再进行代理对象的设置,如图 2-3 所示:
图 2-3 场景三的 isa 指针变化过程图
这种场景下会存在下述问题:
在该场景中 SensorsDelegate 继承自 NSKVONotifying_Delegate,这会对系统的 KVO 特性有所影响,在进行属性赋值时会引发崩溃。
解决方案:
如果代理对象的 isa 指针指向的是一个 NSKVONotifying_ 的类,那我们便不再新建子类,而是直接重写 NSKVONotifying_ 类中的 - tableView:didSelectRowAtIndexPath: 方法:
if ([SADelegateProxy isKVOClass:realClass]) { [SAMethodHelper addInstanceMethodWithSelector:tablViewSelector fromClass:proxyClass toClass:realClass]; [SAMethodHelper addInstanceMethodWithSelector:collectionViewSelector fromClass:proxyClass toClass:realClass]; return;}
2.1.4. 场景四
先对代理对象进行 KVO 属性监听,再进行代理对象的设置,最后移除 KVO 属性监听,如图 2-4 所示:
图 2-4 场景四的 isa 指针变化过程图
这种场景下会存在下述问题:
在移除 KVO 时,系统会将代理对象的 isa 指针直接指回原始类,这时便无法进行点击事件采集了。
解决方案:
在 NSKVONotifying_ 的类中重写 - tableView:didSelectRowAtIndexPath: 方法的同时,对 - removeObserver:forKeyPath: 方法进行监听,在移除 KVO 属性监听时对代理对象再次执行新建子类的操作:
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]; }}
2.1.5. 最终流程
2.2. RxSwift
在七步实现列表点击事件的采集文章中已经提到关于 cell 点击消息的处理逻辑,对 RxSwift 场景下进行了消息转发,此时忽略了一个重要点:
如果使用系统方式设置了 UITableView 的 delegate,这时 RxSwift 会在内部使用 _forwardToDelegate 持有该 delegate,然后在消息转发阶段,对该代理对象发送一次消息,用于保证业务逻辑正常触发。
但是此时我们已经为 delegate 创建了子类,重写了 - tableView:didSelectRowAtIndexPath: 方法。因此在 RxSwift 对代理对象发送的消息会被我们接收,最终导致方法递归调用引发崩溃。
消息发送如图 2-6 所示:
图 2-6 消息发送过程
参考 _RXDelegateProxy 的源码[2],- forwardInvocation: 的实现如下所示:
- (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,而不是再次进行消息转发即可解决该问题,实现逻辑如下:
- (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
*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); } } // 事件采集 // ...}
但是这种解决方式又存在另外一个问题:同时使用系统方式设置代理和使用订阅的方式订阅点击回调,那么订阅的方式将会无效,因为我们没有再次进行消息转发。
修改后的消息发送如图 2-7 所示:
图 2-7 修改后的消息发送过程
为了完全兼容 RxSwift,我们需要把 _RXDelegateProxy 的 - forwardInvocation: 逻辑实现一遍,直接调用其内部的方法,具体实现如下:
- (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
forwardToDelegate = nil; SEL forwardDelegateSelector = NSSelectorFromString(@"_forwardToDelegate"); IMP forwardDelegateIMP = [(NSObject )scrollView.delegate methodForSelector:forwardDelegateSelector]; if (forwardDelegateIMP) { forwardToDelegate = ((NSObject ()(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]; } // 事件采集 // ...}
2.3. 消息发送
上一节中虽然对 RxSwift 进行了适配,但是存在许多未知的三方库是通过消息转发实现 cell 点击响应的,比如 Texture,我们不能逐一适配每个三方库。
我们的采集方案的本质是创建了子类。对于子类来说,如果重写了一个父类中的方法,我们可以通过 super 去调用父类中的方法,而且无需关心父类中的实现逻辑。若父类未实现,应该由系统去做消息转发。
但是 - tableView:didSelectRowAtIndexPath: 方法是定义在 UITableViewDelegate 协议中的,无法使用 super 关键字,那我们是否可以使用 runtime 相关接口实现向父类发送消息呢?答案是肯定的。
runtime 提供了 objc_msgSendSuper 的接口,定义如下:
OBJC_EXPORT id _Nullableobjc_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 的相关参数。
最终的消息处理逻辑如下:
- (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; } // 事件采集 // ...}
- 总结
本文主要对 cell 点击事件采集中所遇到的问题进行了解决,该方案的具体实现可以从神策分析 iOS SDK 源码中找到。如果大家有更好的想法,欢迎加入开源社区一起讨论。
- 参考文献
[1]https://developer.apple.com/l...
[2]https://github.com/ReactiveX/...
文章来源:公众号-神策技术社区