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-5 所示:

图 2-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<UITableViewDelegate> *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<UITableViewDelegate> *forwardToDelegate = nil;
    SEL forwardDelegateSelector = NSSelectorFromString(@"_forwardToDelegate");
    IMP forwardDelegateIMP = [(NSObject *)scrollView.delegate methodForSelector:forwardDelegateSelector];
    if (forwardDelegateIMP) {
        forwardToDelegate = ((NSObject<UITableViewDelegate> *(*)(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 _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;
    }
    // 事件采集
    // ...
}

3. 总结

本文主要对 cell 点击事件采集中所遇到的问题进行了解决,该方案的具体实现可以从神策分析 iOS SDK 源码中找到。如果大家有更好的想法,欢迎加入开源社区一起讨论。

4. 参考文献

[1]https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/KeyValueObserving/Articles/KVOImplementation.html

[2]https://github.com/ReactiveX/RxSwift/blob/0efa6d1482ddaea12d63f4f17567daa4744c7420/RxCocoa/Runtime/_RXDelegateProxy.m#L117