一、前言
在上篇《七步实现列表点击事件的采集》文章中我们已经详细介绍了如何在运行时创建子类进行 cell 点击事件采集,本篇将继续探讨在真实场景中所遇到的问题,并逐个进行解决。
二、踩过的坑
2.1KVO
当我们对一个对象进行 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-5 所示:
图 2-5 处理流程图
2.2RxSwift
在七步实现列表点击事件的采集文章中已经提到关于 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];
}
// 事件采集
// …
}
消息发送
上一节中虽然对 RxSwift 进行了适配,但是存在许多未知的三方库是通过消息转发实现 cell 点击响应的,比如 Texture[3],我们不能逐一适配每个三方库。
我们的采集方案的本质是创建了子类。对于子类来说,如果重写了一个父类中的方法,我们可以通过 super 去调用父类中的方法,而且无需关心父类中的实现逻辑。若父类未实现,应该由系统去做消息转发。
但是 – tableView:didSelectRowAtIndexPath: 方法是定义在 UITableViewDelegate 协议中的,无法使用 super 关键字,那我们是否可以使用 runtime 相关接口实现向父类发送消息呢?答案是肯定的。
runtime 提供了 objc_msgSendSuper 的接口,定义如下:
OBJC_EXPORT id _Nullable
objc_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 点击事件,并对 KVO 场景进行了兼容,同时也对 NSProxy 的场景进行了支持,并实现了向父类发送消息,该方案的具体实现可以从神策分析 iOS SDK 源码[4]中找到。如果大家有更好的想法,欢迎加入开源社区一起讨论。
参考文献: