阅读 806

利用isa-swizzling hook UITableViewCell的点击事件

最近在做无痕埋点相关的事情,需要对用户的操作进行插桩进行上报,其他事件都还好说,cell点击事件遇到了点问题,最初的想法是hook UITableViewCell的setSelected(_ selected: Bool, animated: Bool)方法。

但是此方法有2个问题:

  1. 不太好获取cell所在的位置
  2. 即使UITableView的代理方法没实现didSelectRowAtIndexPath方法,也会上报埋点

后来再与同事的讨论中迸发出来一个想法,能否利用KVO中用到的isa-swizzling进行hookUITableViewCell的点击,这个场景和KVO的场景其实差不多,KVO是对某个值观察,当值改变的时候,调用某个固定的方法,而我现在的需求是对UITableViewCell的点击进行观察,当点击的时候,调用我们上报埋点的方法

简单介绍下KVO的原理:

  1. 当某个类的属性被观察时,系统会在运行时动态的创建一个该类的子类。并且把改对象的isa指向这个子类

  2. 假设被观察的属性名是name,若父类里有setName:或这_setName:,那么在子类里重写这2个方法,若2个方法同时存在,则只会重写setName:一个(这里和KVCset时的搜索顺序是一样的)

  3. 若被观察的类型是NSString,那么重写的方法的实现会指向_NSSetObjectValueAndNotify这个函数,若是Bool类型,那么重写的方法的实现会指向_NSSetBoolValueAndNotify这个函数,这个函数里会调用willChangeValueForKey:didChangevlueForKey:,并且会在这2个方法调用之间,调用父类set方法的实现

  4. 系统会在willChangeValueForKey:对observe里的change[old]赋值,取值是用valueForKey:取值的,didChangevlueForKey:对observe里的change[new]赋值,然后调用observe的这个方法- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

  5. 当使用KVC赋值的时候,在NSObject里的setValue:forKey:方法里,若父类不存在setName:或这_setName:这些方法,会调用_NSSetValueAndNotifyForKeyInIvar这个函数,这个函数里同样也会调用willChangeValueForKey:didChangevlueForKey:,若存在则调用

hook Cell的点击事件步骤如下:

注:生成子类类型的名字的规则为当前的类名+"_sub_czb_tableview_delegate_analysis"

  1. hook UITableView的setDelegate方法
  2. 在setDelegate方法中判断要设置delegate是否为nil或者delegate是否没实现了tableView:didSelectRowAtIndexPath:方法
  3. 若是则设置UITableView的delegate并结束,否则进行下一步
  4. 判断当前类的类名是否满足生成子类类型的规则,若是则设置UITableView的delegate并结束,否则进行下一步
  5. 判断需要生成的子类类型是否已经注册过,若没注册过跳到第7不,否则进行下一步
  6. 若注册过,将delegate的isa指向已经注册过的子类类型,然后设置UITableView的delegate,结束
  7. 创建一个delegate类型的子类,并注册
  8. 为此子类添加一个与tableView点击事件代理同名的方法,并在此方法中调用父类此方法的实现
  9. 将delegate的isa指向刚刚创建的子类类型

代码如下:

typealias TableviewDidSelectRow = @convention(c) (NSObject, Selector, UITableView, IndexPath) -> Void

let czb_didSelectRow:@convention(block) (NSObject, UITableView, IndexPath) -> Void = {
    (this, tableView, indexPath) in
    let superClass: AnyClass? = this.superclass
    let sel = NSSelectorFromString("tableView:didSelectRowAtIndexPath:")
    let method = class_getInstanceMethod(superClass, sel)
    if let impl = class_getMethodImplementation(superClass, sel) {
        let fn = unsafeBitCast(impl, to: TableviewDidSelectRow.self)
        fn(this, sel, tableView, indexPath)
    }
}

extension UITableView {
    static func enableAutoAnalysis () {
       let originalSelector = NSSelectorFromString("setDelegate:")
        let swizzledSelector = #selector(czb_setDelegate(_:))
        /// 此方法是对对应的方法进行hook
        swizzlingForClass(UITableView.classForCoder(),originalSelector: originalSelector,swizzledSelector: swizzledSelector)  
    }
    
    @objc func czb_setDelegate(_ delegate: NSObject?) {
        let sel = NSSelectorFromString("tableView:didSelectRowAtIndexPath:")
        guard let delegate = delegate,delegate.responds(to: sel) else {
            czb_setDelegate(nil)
            return
        }
        var className = NSStringFromClass(delegate.classForCoder)
        if className.hasSuffix("_sub_czb_tableview_delegate_analysis") {
            czb_setDelegate(delegate)
            return
        }
        className += "_sub_czb_tableview_delegate_analysis"
        if let analysisClass = NSClassFromString(className) {
            object_setClass(delegate, analysisClass)
            czb_setDelegate(delegate)
            return
        }
        
        if let customClass = objc_allocateClassPair(delegate.classForCoder, className, 0),
            let method = class_getInstanceMethod(delegate.classForCoder, sel) {
            objc_registerClassPair(customClass)
            let type = method_getTypeEncoding(method)
            let imp = imp_implementationWithBlock(unsafeBitCast(czb_didSelectRow, to: AnyObject.self))
            class_addMethod(customClass, sel, imp, type)
            object_setClass(delegate, customClass)
            czb_setDelegate(delegate)
        }else {
            czb_setDelegate(delegate)
        }
    }
}

复制代码

其他收获:

  1. @convention的使用

    • @convention(swift) : 表明这个是一个swift的闭包
    • @convention(block) :表明这个是一个兼容oc的block的闭包
    • @convention(c) : 表明这个是兼容c的函数指针的闭包
  2. 在Swift中如何把IMP转成func以及如何通过一个block创建一个IMP

    • 如何把IMP转成func

      通过typealias和@convention(c)声明一个和IMP相同参数的闭包,例:

      typealias TableviewDidSelectRow = @convention(c) (NSObject, Selector, UITableView, IndexPath) -> Void

      利用unsafeBitCast函数转换,例:let fn = unsafeBitCast(impl, to: TableviewDidSelectRow.self)

    • 如何通过一个block创建一个IMP

      创一个用建@convention(block)修饰的闭包,例:

      let czb_didSelectRow:@convention(block) (NSObject, UITableView, IndexPath) -> Void = {
          (this, tableView, indexPath) in
          ///实现代码
      }
      复制代码

      利用imp_implementationWithBlockunsafeBitCast,例:

      let block = unsafeBitCast(czb_didSelectRow, to: AnyObject.self)
      let imp = imp_implementationWithBlock(block)
      复制代码