作者:字节挪动技术——段文斌
前言
家喻户晓,字节跳动的举荐在业内处于领先水平,而准确的举荐离不开大量埋点,常见的埋点采集计划是在响应用户行为操作的门路上进行埋点。然而因为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会比拟高效。具体内容参考下列文章。
- 代理模式
- 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 + 281 UIKit 0x000000018be05b4c -[UIWebView webView:decidePolicyForNavigationAction:request:frame:decisionListener:] + 2002 CoreFoundation 0x0000000182731cd0 __invoking___ + 1443 CoreFoundation 0x000000018261056c -[NSInvocation invoke] + 2924 CoreFoundation 0x000000018261501c -[NSInvocation invokeWithTarget:] + 605 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源码参考 https://github.com/ReactiveX/...
- rxcocoa学习-DelegateProxy
RxCocoa也应用了代理模式,对delegate进行了代理,按情理应该没有问题。然而RxCocoa的实现有点出入。
RxCocoa
如果独自只应用了RxCocoa的计划,和计划是统一,也就不会有任何问题。
RxCocoa+MySDK
RxCocoa+MySDK之后,变成这样子。UICollectionView的delegate间接指向谁在于谁调用的setDelegate
办法后调。
实践也应该没有问题,就是援用链多一个poxy包装而已。然而实际上有两个问题。
问题1
RxCocoa的delegate的get办法命中assert
// UIScrollView+Rx.swiftextension 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这个接口
- 不要假想其他人会怎么解决,间接把多种计划集成到一起,构建多种场景,测试兼容性
文章列举的计划可能不全或者不欠缺,如果有更好的计划,欢送探讨。
参考文档
- NSProxy应用
- 代理模式
- rxcocoa学习-DelegateProxy
- https://github.com/ReactiveX/...
字节跳动挪动平台团队(Client Infrastructure)是大前端根底技术行业领军者,负责整个字节跳动的中国区大前端基础设施建设,晋升公司全产品线的性能、稳定性和工程效率。
当初客户端/前端/服务端/端智能算法/测试开发 面向寰球范畴招聘!一起来用技术扭转世界,感兴趣能够分割邮箱 chenxuwei.cxw@bytedance.com,邮件主题 简历-姓名-求职意向-冀望城市-电话。
对于字节跳动终端技术团队
字节跳动终端技术团队在挪动端、Web、Desktop 等各终端都有深入研究。反对的产品包含抖音、今日头条、西瓜视频、火山小视频等 App。依据实际结晶,现推出 一站式挪动开发平台 veMARS,致力于帮忙企业打造优质 App ,提供挪动开发解决方案,欢送开发者体验。