
作者:字节移动技术——段文斌
前言
众所周知,字节跳动的推荐在业内处于领先水平,而精确的推荐离不开大量埋点,常见的埋点采集方案是在响应用户行为操作的路径上进行埋点。但是由于App通常会有比较多界面和操作路径,主动埋点的维护成本就会非常大。所以行业的做法是无埋点,而无埋点实现需要AOP编程。
一个常见的场景,比如想在UIViewController出现和消失的时刻分别记录时间戳用于统计页面展现的时长。要达到这个目标有很多种方法,但是AOP无疑是最简单有效的方法。Objective-C的Hook其实也有很多种方式,这里以Method Swizzle给个示例。
@interface UIViewController (MyHook)
@end
@implementation UIViewController (MyHook)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        /// 常规的 Method Swizzle封装
        swizzleMethods(self, @selector(viewDidAppear:), @selector(my_viewDidAppear:));
        /// 更多Hook
    });
}
- (void)my_viewDidAppear:(BOOL)animated {
  /// 一些Hook需要的逻辑
  
  /// 这里调用Hook后的方法,其实现其实已经是原方法了。
  [self my_viewDidAppear: animated];
}
@end
复制代码接下来我们探讨一个具体场景:
UICollectionView或者UITableView是iOS中非常常用的列表UI组件,其中列表元素的点击事件回调是通过delegate完成的。这里以UICollectionView为例,UICollectionView的delegate,有个方法声明,collectionView:didSelectItemAtIndexPath:,实现这个方法我们就可以给列表元素添加点击事件。
我们的目标是Hook这个delegate的方法,在点击回调的时候进行额外的埋点操作。
方案迭代
方案1 Method Swizzle
通常情况下,Method Swizzle可以满足绝大部分的AOP编程需求。因此首次迭代,我们直接使用Method Swizzle来进行Hook。
@interface UICollectionView (MyHook)
@end
@implementation UICollectionView (MyHook)
// Hook, setMyDelegate:和setDelegate:交换过
- (void)setMyDelegate:(id)delegate {
    if (delegate != nil) {
        /// 常规Method Swizzle
        swizzleMethodsXXX(delegate, @selector(collectionView:didSelectItemAtIndexPath:), self, @selector(my_collectionView:didSelectItemAtIndexPath:));
    }
    [self setMyDelegate:nil];
}
- (void)my_collectionView:(UICollectionView *)ccollectionView didSelectItemAtIndexPath:(NSIndexPath *)index {
  /// 一些Hook需要的逻辑
  /// 这里调用Hook后的方法,其实现其实已经是原方法了。
  [self my_collectionView:ccollectionView didSelectItemAtIndexPath:index];
}
@end
复制代码我们把这个方案集成到今日头条App里面进行测试验证,发现没法办法验证通过。
主要原因今日头条App是一个庞大的项目,其中引入了非常多的三方库,比如IGListKit等,这些三方库通常对UICollectionView的使用都进行了封装,而这些封装,恰恰导致我们不能使用常规的Method Swizzle来Hook这个delegate。直接的原因总结有以下两点:
- setDelegate传入的对象不是实现- UICollectionViewDelegate协议的那个对象

如图示,setDelegate传入的是一个代理对象proxy,proxy引用了实际的实现UICollectionViewDelegate协议的delegate,proxy实际上并没有实现UICollectionViewDelegate的任何一个方法,它把所有方法都转发给实际的delegate。这种情况下,我们不能直接对proxy进行Method Swizzle
- 多次setDelegate

在上述图例中,使用方存在连续调用两次setDelegate的情况,第一次是真实delegate,第二次是proxy,我们需要区别对待。
代理模式和NSProxy介绍
使用proxy对原对象进行代理,在处理完额外操作之后再调用原对象,这种模式称为代理模式。而Objective-C中要实现代理模式,使用NSProxy会比较高效。详细内容参考下列文章。
这里面UICollectionView的setDelegate传入的是一个proxy是非常常见的操作,比如IGListKit,同时App基于自身需求,也有可能会做这一层封装。
在UICollectionView的setDelegate的时候,把delegate包裹在proxy中,然后把proxy设置给UICollectionView,使用proxy对delegate进行消息转发。

方案2 使用代理模式
方案1已经无法满足我们的需求了,我们考虑到既然对delegate进行代理是一种常规操作,我们何不也使用代理模式,对proxy再次代理。
代码实现
- 先Hook UICollectionView的setDelegate方法
- 代理delegate
简单的代码示意如下
/// 完整封装了一些常规的消息转发方法
@interface DelegateProxy : NSProxy
@property (nonatomic, weak, readonly) id target;
@end
/// 为 CollectionView delegate转发消息的proxy
@interface BDCollectionViewDelegateProxy : DelegateProxy
@end
@implementation BDCollectionViewDelegateProxy <UICollectionViewDelegate>
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    //track event here
    if ([self.target respondsToSelector:@selector(collectionView:didSelectItemAtIndexPath:)]) {
        [self.target collectionView:collectionView didSelectItemAtIndexPath:indexPath];
    }
}
- (BOOL)bd_isCollectionViewTrackerDecorator {
    return YES;
}
// 还有其他的消息转发的代码 先忽略
- (BOOL)respondsToSelector:(SEL)aSelector {
    if (aSelector == @selector(bd_isCollectionViewTrackerDecorator)) {
        return YES;
    }
    return [self.target respondsToSelector:aSelector];
}
@end
@interface UICollectionView (MyHook)
@end
@implementation UICollectionView (MyHook)
- (void) setDd_TrackerProxy:(BDCollectionViewDelegateProxy *)object {
    objc_setAssociatedObject(self, @selector(bd_TrackerProxy), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (BDCollectionViewDelegateProxy *) bd_TrackerProxy {
    BDCollectionViewDelegateProxy *bridge = objc_getAssociatedObject(self, @selector(bd_TrackerProxy));
    return bridge;
}
// Hook, setMyDelegate:和setDelegate:交换过了
- (void)setMyDelegate:(id)delegate {
    if (delegate == nil) {
        [self setMyDelegate:delegate];
        return
    }
    // 不会释放,不重复设置
    if ([delegate respondsToSelector:@selector(bd_isCollectionViewTrackerDecorator)]) {
         [self setMyDelegate:delegate]; 
         return;
    }
    BDCollectionViewDelegateProxy *proxy = [[BDCollectionViewDelegateProxy alloc] initWithTarget:delegate];
    [self setMyDelegate:proxy];
    self.bd_TrackerProxy = proxy;
}
@end
复制代码模型
下图实线表示强引用,虚线表示弱引用。
情况一
如果使用方没有对delegate进行代理,而我们使用代理模式
- UICollectionView,其- delegate指针指向DelegateProxy
- DelegateProxy,被UICollectionView用runtime的方式强引用,其target弱引用真实Delegate

情况二
如果使用方也对delegate进行代理,我们使用代理模式
- 我们只需要保证我们的DelegateProxy处于代理链中的一环即可

从这里我们可以看出,代理模式有很好的扩展性,它允许代理链不断嵌套,只要我们都遵循代理模式的原则即可。
到这里,我们的方案已经在今日头条App上测试通过了。但是事情远还没有结束。
踩坑之旅
目前的还算比较可以,但是也不能完全避免问题。这里其实不仅仅是UICollectionView的delegate,包括:
- UIWebView
- WKWebView
- UITableView
- UICollectionView
- UIScrollView
- UIActionSheet
- UIAlertView
我们都采用相同的方法来进行Hook。同时我们将方案封装一个SDK对外提供,以下统称为MySDK。
第一次踩坑
某客户接入我们的方案之后,在集成过程中反馈有必现Crash,下面详细介绍一下这一次踩坑的经历。
堆栈信息
重点信息是[UIWebView webView:decidePolicyForNavigationAction:request:frame:decisionListener:]。
Thread 0 Crashed:
0   libobjc.A.dylib   0x000000018198443c objc_msgSend + 28
1   UIKit             0x000000018be05b4c -[UIWebView webView:decidePolicyForNavigationAction:request:frame:decisionListener:] + 200
2   CoreFoundation    0x0000000182731cd0 __invoking___ + 144
3   CoreFoundation    0x000000018261056c -[NSInvocation invoke] + 292
4   CoreFoundation    0x000000018261501c -[NSInvocation invokeWithTarget:] + 60
5   WebKitLegacy      0x000000018b86d654 -[_WebSafeForwarder forwardInvocation:] + 156
复制代码从堆栈信息不难判断出crash原因是UIWebView的delegate野指针,那为啥出现野指针呢?
这里先说明一下crash的直接原因,然后再来具体分析为什么就出现了问题。
- MySDK对setDelegate进行了Hook
- 客户也对setDelegate进行了Hook
- 先执行MySDK的Hook逻辑调用,然后执行客户的Hook逻辑调用
客户Hook的代码
@interface UIWebView (JSBridge)
@end
@implementation UIWebView (JSBridge)
- (void)setJsBridge:(id)object {
    objc_setAssociatedObject(self, @selector(jsBridge), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (WebViewJavascriptBridge *)jsBridge {
    WebViewJavascriptBridge *bridge = objc_getAssociatedObject(self, @selector(jsBridge));
    return bridge;
}
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        swizzleMethods(self, @selector(setDelegate:), @selector(setJSBridgeDelegate:));
        swizzleMethods(self, @selector(initWithFrame:), @selector(initJSWithFrame:));
    });
}
- (instancetype)initJSWithFrame:(CGRect)frame {
    self = [self initJSWithFrame:frame];
    if (self) {
        WebViewJavascriptBridge *bridge = [WebViewJavascriptBridge bridgeForWebView:self];
        [self setJsBridge:bridge];
    }
    return self;
}
/// webview.delegate = xxx 会被调用多次且传入的对象不一样
- (void)setJSBridgeDelegate:(id)delegate {
    WebViewJavascriptBridge *bridge = self.jsBridge;
    if (delegate == nil || bridge == nil) {
        [self setJSBridgeDelegate:delegate];
    } else if (bridge == delegate) {
        [self setJSBridgeDelegate:delegate];
    } else {
        /// 第一次进入这里传入 bridge
        /// 第二次进入这里传入一个delegate
        if (![delegate isKindOfClass:[WebViewJavascriptBridge class]]) {
            [bridge setWebViewDelegate:delegate];
            /// 下面这一行代码是客户缺少的
            /// fix with this
            [self setJSBridgeDelegate:bridge];
        } else {
            [self setJSBridgeDelegate:delegate];
        }
    }
}
@end
复制代码MySDK Hook代码
@interface UIWebView (MyHook)
@end
@implementation UIWebView (MyHook)
// Hook, setWebViewDelegate:和setDelegate:交换过
- (void)setWebViewDelegate:(id)delegate {
    if (delegate == nil) {
        [self setWebViewDelegate:delegate];
    }
    BDWebViewDelegateProxy *proxy = [[BDWebViewDelegateProxy alloc] initWithTarget:delegate];
    self.bd_TrackerDecorator = proxy;
    [self setWebViewDelegate:proxy];
}
@end
复制代码野指针原因
UIWebView有两次调用setDelegate方法,第一次是传的WebViewJavascriptBridge,第二次传的另一个实际的WebViewDelegate。暂且称第一次传了bridge第二次传了实际上的delegate。
- 第一次调用,MySDK Hook的时候会用DelegateProxy包装住bridge,所有方法通过DelegateProxy转发到bridge,这里传给 setJSBridgeDelegate:(id)delegate的delegate实际上是DelegateProxy而非bridge。

这里需要注意,UIWebView的delegate指向DelegateProxy是客户给设置上的,且这个属性assign而非weak,这个assign很关键,assigin在对象释放之后不会自动变为nil。
- 第二次调用,MySDK Hook的时候会用新的DelegateProxy包装住delegate也就是WebViewDelegate,这个时候MySDK的逻辑是把新的DelegateProxy给强引用中,老的DelegateProxy就失去了强引用因此释放了。

此时的状态如果不做任何处理,当前状态就如图示:
- delegate指向已经释放的DelegateProxy,野指针
- UIWebview触发回调就导致crash
修复方法
如果补上那一句,setJSBridgeDelegate:(id)delegate在判断了delegate不是bridge之后,把UIWebView的delegate设置为bridge就可以完成了。
注释中 fix with this下一行代码
修复后模型如下图

总结
使用Proxy的方式虽然也可以解决一定的问题,但是也需要使用方遵循一定的规范,要意识到第三方SDK也可能setDelegate进行Hook,也可能使用Proxy
第二次踩坑
先补充一些参考资料
- RxCocoa源码参考 github.com/ReactiveX/R…
- rxcocoa学习-DelegateProxy
RxCocoa也使用了代理模式,对delegate进行了代理,按道理应该没有问题。但是RxCocoa的实现有点出入。
RxCocoa

如果单独只使用了RxCocoa的方案,和方案是一致,也就不会有任何问题。
RxCocoa+MySDK

RxCocoa+MySDK之后,变成这样子。UICollectionView的delegate直接指向谁在于谁调用的setDelegate方法后调。
理论也应该没有问题,就是引用链多一个poxy包装而已。但是实际上有两个问题。
问题1
RxCocoa的delegate的get方法命中assert
//  UIScrollView+Rx.swift
extension Reactive where Base: UIScrollView {
    public var delegate: DelegateProxy<UIScrollView, UIScrollViewDelegate> {
        return RxScrollViewDelegateProxy.proxy(for: base)
        // base可以理解为一个UIScrollView 实例
    }
}
open class RxScrollViewDelegateProxy {
    public static func proxy(for object: ParentObject) -> Self {
        let maybeProxy = self.assignedProxy(for: object)
        let proxy: AnyObject
        if let existingProxy = maybeProxy {
            proxy = existingProxy
        } else {
            proxy = castOrFatalError(self.createProxy(for: object))
            self.assignProxy(proxy, toObject: object)
            assert(self.assignedProxy(for: object) === proxy)
        }
        let currentDelegate = self._currentDelegate(for: object)
        let delegateProxy: Self = castOrFatalError(proxy)
        if currentDelegate !== delegateProxy {
            delegateProxy._setForwardToDelegate(currentDelegate, retainDelegate: false)
            assert(delegateProxy._forwardToDelegate() === currentDelegate)
            self._setCurrentDelegate(proxy, to: object)
          	/// 命中下面这一行assert
            assert(self._currentDelegate(for: object) === proxy)
            assert(delegateProxy._forwardToDelegate() === currentDelegate)
        }
        return delegateProxy
    }
}
复制代码重点逻辑
- delegateProxy即使RxDelegateProxy
- currentDelegate为RxDelegateProxy指向的对象
- RxDelegateProxy._setForwardToDelegate把RxDelegateProxy指向真实的Delegate
- 标红的前面一句执行的时候,是调用setDelegate方法,把RxDelegateProxy的proxy设置给UIScrollView(其实是一个UICollectionView实例)
- 然后进入了MySDK的Hook方法,把RxDelegateProxy给包了一层
- 最终结果如下图
- 然后导致self._currentDelegate(for: object) 是DelegateProxy而非RxDelegateProxy,触发标红断言

这个断言就很霸道,相当于RxCocoa认为就只有它能够去使用Proxy包装delegate,其他人不能这样做,只要做了,就断言。
进一步分析
- 当前状态

- 再次进入Rx的方法
- currentDelegate是UICollectionView指向的DelegateProxy(MySDK的包装)
- delegateProxy指向还是RxDelegateProxy
- 触发Rx的if判断,Rx会把其指向真实的delegate改向UICollectionView指向的DelegateProxy
- 导致循环指向,引用链中真实的Delegate丢失了
 

问题2
上面提到多次调用导致了循环指向,而循环指向导致了在实际的方法转发的时候变成了死循环。

responds代码
open class RxScrollViewDelegateProxy {
    override open func responds(to aSelector: Selector!) -> Bool {
        return super.responds(to: aSelector)
            || (self._forwardToDelegate?.responds(to: aSelector) ?? false)
            || (self.voidDelegateMethodsContain(aSelector) && self.hasObservers(selector: aSelector))
        }
}
复制代码@implementation BDCollectionViewDelegateProxy
- (BOOL)respondsToSelector:(SEL)aSelector {
    if (aSelector == @selector(bd_isCollectionViewTrackerDecorator)) {
        return YES;
    }
    return [super respondsToSelector:aSelector];
}
@end
复制代码似乎只要不多次调用就没有问题了?
关键在于Rx的setDelegate方法也调用了get方法,导致一次get就触发第二次调用。也就是多次调用是无法避免。
解决方案
问题的原因比较明显,如果改造RxCocoa的代码,把第三方可能的Hook考虑进来,完全可以解决问题。
解决方案1
参考MySDK的proxy方案,在proxy中加入一个特殊方法,来判断RxDelegateProxy是否已经在引用链中,而不去主动改变这个引用链。

open class RxScrollViewDelegateProxy {
    public static func proxy(for object: ParentObject) -> Self {
        ...
        let currentDelegate = self._currentDelegate(for: object)
        let delegateProxy: Self = castOrFatalError(proxy)
        //if currentDelegate !== delegateProxy
        if !currentDelegate.responds(to: xxxMethod) {
            delegateProxy._setForwardToDelegate(currentDelegate, retainDelegate: false)
            assert(delegateProxy._forwardToDelegate() === currentDelegate)
            self._setCurrentDelegate(proxy, to: object)
            assert(self._currentDelegate(for: object) === proxy)
            assert(delegateProxy._forwardToDelegate() === currentDelegate)
        } else {
            return currentDelegate
        }
        return delegateProxy
    }
}
复制代码类似这样的改造,就可以解决问题。我们与Rx团队进行了沟通,也提了PR,可惜最终被拒绝合入了。Rx给出的说明是,Hook是不优雅的方式,不推荐Hook系统的任何方法,也不想兼容任何第三方的Hook。
解决方案2
有没有可能,RxCocoa不改代码,MySDK来兼容?
刚才提到,有可能是两种状态。
- 状态1
- setDelegate的时候,先进Rx的方法,后进MySDK的Hook方法,
- 传给Rx的就是delegate
- 传给MySDK的是RxDelegateProxy
- Delegate的get调用就触发bug
 

- 状态2
- setDelegate的时候,先进MySDK的Hook方法,后进Rx的方法?
- 传给Rx的就是DelegateProxy
 

其实如果是状态2,似乎Rxcocoa的bug是不会复现的。
但是仔细查看Rxcocoa的setDelegate代码
extension Reactive where Base: UIScrollView {
    public func setDelegate(_ delegate: UIScrollViewDelegate)
    -> Disposable {
        return RxScrollViewDelegateProxy.installForwardDelegate(delegate, retainDelegate: false, onProxyForObject: self.base)
    }
}
open class RxScrollViewDelegateProxy {
    public static func installForwardDelegate(_ forwardDelegate: Delegate, retainDelegate: Bool, onProxyForObject object: ParentObject) -> Disposable {
        weak var weakForwardDelegate: AnyObject? = forwardDelegate as AnyObject
        let proxy = self.proxy(for: object)
        assert(proxy._forwardToDelegate() === nil, "")
        proxy.setForwardToDelegate(forwardDelegate, retainDelegate: retainDelegate)
        return Disposables.create {
            ...
        }
    }
}
复制代码emmm?Rx里面,UICollectionView的setDelegate和Delegate的get方法不是Hook…
collectionView.rx.setDelegate(delegate)
let delegate = collectionView.rx.delegate
复制代码最终流程就只能是
- setDelegate的时候,先进Rx的方法,传给Rx真实的delegate
- 后进MySDK的Hook方法
- 传给MySDK的是RxDelegateProxy
- Rx里面获取CollectionView的delegate触发判断
- Delegate的get调用就触发bug
如果MySDK还是采用当前的Hook方案,就没法在MySDK解决了。
解决方案3
仔细看了一下,发现Rx里面是通过重写RxDelegateProxy的forwardInvocation来达到方法转发的目的,即
- RxDelegateProxy没有实现UICollectionViewDelegate的任何方法
- forwardInvocation中处理UICollectionViewDelegate相关回调
回顾消息转发机制

我们可以在forwardingTargetForSelector这一步进行处理,这样可以避开与Rx相关的冲突,处理完再直接跳过。
- forwardingTargetForSelector中针对delegate的回调,target返回一个SDK处理的类,比DelegateProxy
- DelegateProxy上报完成之后,直接调用跳到RxDelegateProxy的forwardInvocation方法
这个解决方案其实也不完美,只能暂时规避与Rx的冲突。如果后续有其他SDK也来在这个阶段处理Hook冲突,也容易出现问题。
总结
确实如Rx团队描述的那样,Hook不是很优雅的方式,任何Hook都有可能存在兼容性问题。
- 谨慎使用Hook
- Hook系统接口一定要遵循一定的规范,不能假想只有你在Hook这个接口
- 不要假想其他人会怎么处理,直接把多种方案集成到一起,构建多种场景,测试兼容性
文章列举的方案可能不全或者不完善,如果有更好的方案,欢迎讨论。
参考文档
字节跳动移动平台团队(Client Infrastructure)是大前端基础技术行业领军者,负责整个字节跳动的中国区大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率。
现在客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一起来用技术改变世界,感兴趣可以联系邮箱 chenxuwei.cxw@bytedance.com,邮件主题 简历-姓名-求职意向-期望城市-电话。
关于字节跳动终端技术团队
字节跳动终端技术团队在移动端、Web、Desktop 等各终端都有深入研究。支持的产品包括抖音、今日头条、西瓜视频、火山小视频等 App。根据实践结晶,现推出 一站式移动开发平台 veMARS,致力于帮助企业打造优质 App ,提供移动开发解决方案,欢迎开发者体验。
























![[桜井宁宁]COS和泉纱雾超可爱写真福利集-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/4d3cf227a85d7e79f5d6b4efb6bde3e8.jpg)

![[桜井宁宁] 爆乳奶牛少女cos写真-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/d40483e126fcf567894e89c65eaca655.jpg)
