关于ios:无埋点核心技术iOS-Hook-在字节的实践经验

4次阅读

共计 13308 个字符,预计需要花费 34 分钟才能阅读完成。

作者:字节挪动技术——段文斌

前言

家喻户晓,字节跳动的举荐在业内处于领先水平,而准确的举荐离不开大量埋点,常见的埋点采集计划是在响应用户行为操作的门路上进行埋点。然而因为 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 为例,UICollectionViewdelegate,有个办法申明,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。间接的起因总结有以下两点:

  1. setDelegate传入的对象不是实现 UICollectionViewDelegate 协定的那个对象

如图示,setDelegate传入的是一个代理对象 proxy,proxy 援用了理论的实现 UICollectionViewDelegate 协定的 delegate,proxy 实际上并没有实现UICollectionViewDelegate 的任何一个办法,它把所有办法都转发给理论的delegate。这种状况下,咱们不能间接对 proxy 进行 Method Swizzle

  1. 屡次setDelegate

在上述图例中,应用方存在间断调用两次 setDelegate 的状况,第一次是实在delegate,第二次是proxy,咱们须要区别对待。

代理模式和 NSProxy 介绍

应用 proxy 对原对象进行代理,在解决完额定操作之后再调用原对象,这种模式称为代理模式。而 Objective- C 中要实现代理模式,应用 NSProxy 会比拟高效。具体内容参考下列文章。

  • 代理模式
  • NSProxy 应用

这外面 UICollectionViewsetDelegate传入的是一个 proxy 是十分常见的操作,比方 IGListKit,同时 App 基于本身需要,也有可能会做这一层封装。

UICollectionViewsetDelegate的时候,把 delegate 包裹在 proxy 中,而后把 proxy 设置给 UICollectionView, 应用proxydelegate进行音讯转发。

计划 2 应用代理模式

计划 1 曾经无奈满足咱们的需要了,咱们思考到既然对 delegate 进行代理是一种惯例操作,咱们何不也应用代理模式,对 proxy 再次代理。

代码实现

  • 先 Hook UICollectionViewsetDelegate 办法
  • 代理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 的间接起因,而后再来具体分析为什么就呈现了问题。

  1. MySDK 对 setDelegate 进行了 Hook
  2. 客户也对 setDelegate 进行了 Hook
  3. 先执行 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。

  1. 第一次调用,MySDK Hook 的时候会用 DelegateProxy 包装住 bridge,所有办法通过 DelegateProxy 转发到 bridge,这里传给 setJSBridgeDelegate:(id)delegate的 delegate 实际上是 DelegateProxy而非 bridge

这里须要留神,UIWebView 的 delegate 指向 DelegateProxy 是客户给设置上的,且这个属性assign 而非 weak,这个 assign 很要害,assigin 在对象开释之后不会主动变为 nil。

  1. 第二次调用,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.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 都有可能存在兼容性问题。

  1. 审慎应用 Hook
  2. Hook 零碎接口肯定要遵循肯定的标准,不能假想只有你在 Hook 这个接口
  3. 不要假想其他人会怎么解决,间接把多种计划集成到一起,构建多种场景,测试兼容性

文章列举的计划可能不全或者不欠缺,如果有更好的计划,欢送探讨。

参考文档

  • NSProxy 应用
  • 代理模式
  • rxcocoa 学习 -DelegateProxy
  • https://github.com/ReactiveX/…

字节跳动挪动平台团队 (Client Infrastructure) 是大前端根底技术行业领军者,负责整个字节跳动的中国区大前端基础设施建设,晋升公司全产品线的性能、稳定性和工程效率。

当初 客户端/前端/服务端/端智能算法/测试开发 面向寰球范畴招聘! 一起来用技术扭转世界 ,感兴趣能够分割邮箱 chenxuwei.cxw@bytedance.com,邮件主题 简历 - 姓名 - 求职意向 - 冀望城市 - 电话

对于字节跳动终端技术团队

字节跳动终端技术团队在挪动端、Web、Desktop 等各终端都有深入研究。反对的产品包含抖音、今日头条、西瓜视频、火山小视频等 App。依据实际结晶,现推出 一站式挪动开发平台 veMARS,致力于帮忙企业打造优质 App,提供挪动开发解决方案,欢送开发者体验。

正文完
 0