做者:字节移动技术——段文斌前端
众所周知,字节跳动的推荐在业内处于领先水平,而精确的推荐离不开大量埋点,常见的埋点采集方案是在响应用户行为操做的路径上进行埋点。可是因为App一般会有比较多界面和操做路径,主动埋点的维护成本就会很是大。因此行业的作法是无埋点,而无埋点实现须要AOP编程。git
一个常见的场景,好比想在UIViewController
出现和消失的时刻分别记录时间戳用于统计页面展示的时长。要达到这个目标有不少种方法,可是AOP无疑是最简单有效的方法。Objective-C的Hook其实也有不少种方式,这里以Method Swizzle给个示例。github
@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
复制代码
接下来咱们探讨一个具体场景:web
UICollectionView
或者UITableView
是iOS中很是经常使用的列表UI组件,其中列表元素的点击事件回调是经过delegate
完成的。这里以UICollectionView
为例,UICollectionView
的delegate
,有个方法声明,collectionView:didSelectItemAtIndexPath:
,实现这个方法咱们就能够给列表元素添加点击事件。objective-c
咱们的目标是Hook这个delegate的方法,在点击回调的时候进行额外的埋点操做。算法
一般状况下,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里面进行测试验证,发现无法办法验证经过。swift
主要缘由今日头条App是一个庞大的项目,其中引入了很是多的三方库,好比IGListKit等,这些三方库一般对UICollectionView
的使用都进行了封装,而这些封装,偏偏致使咱们不能使用常规的Method Swizzle来Hook这个delegate。直接的缘由总结有如下两点:markdown
setDelegate
传入的对象不是实现UICollectionViewDelegate
协议的那个对象如图示,setDelegate
传入的是一个代理对象proxy,proxy引用了实际的实现UICollectionViewDelegate
协议的delegate
,proxy实际上并无实现UICollectionViewDelegate
的任何一个方法,它把全部方法都转发给实际的delegate
。这种状况下,咱们不能直接对proxy进行Method Swizzleide
setDelegate
在上述图例中,使用方存在连续调用两次setDelegate
的状况,第一次是真实delegate
,第二次是proxy
,咱们须要区别对待。
使用proxy对原对象进行代理,在处理完额外操做以后再调用原对象,这种模式称为代理模式。而Objective-C中要实现代理模式,使用NSProxy会比较高效。详细内容参考下列文章。
这里面UICollectionView
的setDelegate
传入的是一个proxy
是很是常见的操做,好比IGListKit,同时App基于自身需求,也有可能会作这一层封装。
在UICollectionView
的setDelegate
的时候,把delegate
包裹在proxy
中,而后把proxy设置给UICollectionView
,使用proxy
对delegate
进行消息转发。
方案1已经没法知足咱们的需求了,咱们考虑到既然对delegate
进行代理是一种常规操做,咱们何不也使用代理模式,对proxy
再次代理。
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若是使用方也对delegate
进行代理,咱们使用代理模式
从这里咱们能够看出,代理模式有很好的扩展性,它容许代理链不断嵌套,只要咱们都遵循代理模式的原则便可。
到这里,咱们的方案已经在今日头条App上测试经过了。可是事情远尚未结束。
目前的还算比较能够,可是也不能彻底避免问题。这里其实不只仅是UICollectionView的delegate,包括:
咱们都采用相同的方法来进行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的直接缘由,而后再来具体分析为何就出现了问题。
@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
复制代码
@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。
setJSBridgeDelegate:(id)delegate
的delegate其实是DelegateProxy而非bridge。这里须要注意,UIWebView的delegate指向DelegateProxy是客户给设置上的,且这个属性assign而非weak,这个assign很关键,assigin在对象释放以后不会自动变为nil。
此时的状态若是不作任何处理,当前状态就如图示:
若是补上那一句,setJSBridgeDelegate:(id)delegate
在判断了delegate不是bridge以后,把UIWebView的delegate设置为bridge就能够完成了。
注释中 fix with this下一行代码
修复后模型以下图
使用Proxy的方式虽然也能够解决必定的问题,可是也须要使用方遵循必定的规范,要意识到第三方SDK也可能setDelegate
进行Hook,也可能使用Proxy
先补充一些参考资料
RxCocoa也使用了代理模式,对delegate进行了代理,按道理应该没有问题。可是RxCocoa的实现有点出入。
若是单独只使用了RxCocoa的方案,和方案是一致,也就不会有任何问题。
RxCocoa+MySDK以后,变成这样子。UICollectionView的delegate直接指向谁在于谁调用的setDelegate
方法后调。
理论也应该没有问题,就是引用链多一个poxy包装而已。可是实际上有两个问题。
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
}
}
复制代码
重点逻辑
这个断言就很霸道,至关于RxCocoa认为就只有它可以去使用Proxy包装delegate,其余人不能这样作,只要作了,就断言。
进一步分析
上面提到屡次调用致使了循环指向,而循环指向致使了在实际的方法转发的时候变成了死循环。
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考虑进来,彻底能够解决问题。
参考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。
有没有可能,RxCocoa不改代码,MySDK来兼容?
刚才提到,有多是两种状态。
其实若是是状态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
复制代码
最终流程就只能是
若是MySDK仍是采用当前的Hook方案,就无法在MySDK解决了。
仔细看了一下,发现Rx里面是经过重写RxDelegateProxy的forwardInvocation来达到方法转发的目的,即
UICollectionViewDelegate
的任何方法UICollectionViewDelegate
相关回调回顾消息转发机制
咱们能够在forwardingTargetForSelector这一步进行处理,这样能够避开与Rx相关的冲突,处理完再直接跳过。
这个解决方案其实也不完美,只能暂时规避与Rx的冲突。若是后续有其余SDK也来在这个阶段处理Hook冲突,也容易出现问题。
确实如Rx团队描述的那样,Hook不是很优雅的方式,任何Hook都有可能存在兼容性问题。
文章列举的方案可能不全或者不完善,若是有更好的方案,欢迎讨论。
字节跳动移动平台团队(Client Infrastructure)是大前端基础技术行业领军者,负责整个字节跳动的中国区大前端基础设施建设,提高公司全产品线的性能、稳定性和工程效率。
如今客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一块儿来用技术改变世界,感兴趣能够联系邮箱 chenxuwei.cxw@bytedance.com,邮件主题 简历-姓名-求职意向-指望城市-电话。
字节跳动终端技术团队在移动端、Web、Desktop 等各终端都有深刻研究。支持的产品包括抖音、今日头条、西瓜视频、火山小视频等 App。根据实践结晶,现推出 一站式移动开发平台 veMARS,致力于帮助企业打造优质 App ,提供移动开发解决方案,欢迎开发者体验。