無埋點核心技術:iOS Hook 在字節的實踐經驗

做者:字節移動技術——段文斌前端

前言

衆所周知,字節跳動的推薦在業內處於領先水平,而精確的推薦離不開大量埋點,常見的埋點採集方案是在響應用戶行爲操做的路徑上進行埋點。可是因爲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爲例,UICollectionViewdelegate,有個方法聲明,collectionView:didSelectItemAtIndexPath:,實現這個方法咱們就能夠給列表元素添加點擊事件。objective-c

咱們的目標是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裏面進行測試驗證,發現無法辦法驗證經過。swift

主要緣由今日頭條App是一個龐大的項目,其中引入了很是多的三方庫,好比IGListKit等,這些三方庫一般對UICollectionView的使用都進行了封裝,而這些封裝,偏偏致使咱們不能使用常規的Method Swizzle來Hook這個delegate。直接的緣由總結有如下兩點:markdown

  1. setDelegate傳入的對象不是實現UICollectionViewDelegate協議的那個對象

img

如圖示,setDelegate傳入的是一個代理對象proxy,proxy引用了實際的實現UICollectionViewDelegate協議的delegate,proxy實際上並無實現UICollectionViewDelegate的任何一個方法,它把全部方法都轉發給實際的delegate。這種狀況下,咱們不能直接對proxy進行Method Swizzleide

  1. 屢次setDelegate

img

在上述圖例中,使用方存在連續調用兩次setDelegate的狀況,第一次是真實delegate,第二次是proxy,咱們須要區別對待。

代理模式和NSProxy介紹

使用proxy對原對象進行代理,在處理完額外操做以後再調用原對象,這種模式稱爲代理模式。而Objective-C中要實現代理模式,使用NSProxy會比較高效。詳細內容參考下列文章。

這裏面UICollectionViewsetDelegate傳入的是一個proxy是很是常見的操做,好比IGListKit,同時App基於自身需求,也有可能會作這一層封裝。

UICollectionViewsetDelegate的時候,把delegate包裹在proxy中,而後把proxy設置給UICollectionView,使用proxydelegate進行消息轉發。

img

方案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

img

狀況二

若是使用方也對delegate進行代理,咱們使用代理模式

  • 咱們只須要保證咱們的DelegateProxy處於代理鏈中的一環便可

img

從這裏咱們能夠看出,代理模式有很好的擴展性,它容許代理鏈不斷嵌套,只要咱們都遵循代理模式的原則便可。

到這裏,咱們的方案已經在今日頭條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

img

這裏須要注意,UIWebView的delegate指向DelegateProxy是客戶給設置上的,且這個屬性assign而非weak,這個assign很關鍵,assigin在對象釋放以後不會自動變爲nil。

  1. 第二次調用,MySDK Hook的時候會用新的DelegateProxy包裝住delegate也就是WebViewDelegate,這個時候MySDK的邏輯是把新的DelegateProxy給強引用中,老的DelegateProxy就失去了強引用所以釋放了。

img

此時的狀態若是不作任何處理,當前狀態就如圖示:

  • delegate指向已經釋放的DelegateProxy,野指針
  • UIWebview觸發回調就致使crash

修復方法

若是補上那一句,setJSBridgeDelegate:(id)delegate在判斷了delegate不是bridge以後,把UIWebView的delegate設置爲bridge就能夠完成了。

註釋中 fix with this下一行代碼

修復後模型以下圖

img

總結

使用Proxy的方式雖然也能夠解決必定的問題,可是也須要使用方遵循必定的規範,要意識到第三方SDK也可能setDelegate進行Hook,也可能使用Proxy

第二次踩坑

先補充一些參考資料

RxCocoa也使用了代理模式,對delegate進行了代理,按道理應該沒有問題。可是RxCocoa的實現有點出入。

RxCocoa

img

若是單獨只使用了RxCocoa的方案,和方案是一致,也就不會有任何問題。

RxCocoa+MySDK

img

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,觸發標紅斷言

img

這個斷言就很霸道,至關於RxCocoa認爲就只有它可以去使用Proxy包裝delegate,其餘人不能這樣作,只要作了,就斷言。

進一步分析

  • 當前狀態

img

  • 再次進入Rx的方法
    • currentDelegate是UICollectionView指向的DelegateProxy(MySDK的包裝)
    • delegateProxy指向仍是RxDelegateProxy
    • 觸發Rx的if判斷,Rx會把其指向真實的delegate改向UICollectionView指向的DelegateProxy
    • 致使循環指向,引用鏈中真實的Delegate丟失了

img

問題2

上面提到屢次調用致使了循環指向,而循環指向致使了在實際的方法轉發的時候變成了死循環。

img

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是否已經在引用鏈中,而不去主動改變這個引用鏈。

img

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

img

  • 狀態2
    • setDelegate的時候,先進MySDK的Hook方法,後進Rx的方法?
    • 傳給Rx的就是DelegateProxy

img

其實若是是狀態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相關回調

回顧消息轉發機制

img

咱們能夠在forwardingTargetForSelector這一步進行處理,這樣能夠避開與Rx相關的衝突,處理完再直接跳過。

  • forwardingTargetForSelector中針對delegate的回調,target返回一個SDK處理的類,比DelegateProxy
  • DelegateProxy上報完成以後,直接調用跳到RxDelegateProxy的forwardInvocation方法

這個解決方案其實也不完美,只能暫時規避與Rx的衝突。若是後續有其餘SDK也來在這個階段處理Hook衝突,也容易出現問題。

總結

確實如Rx團隊描述的那樣,Hook不是很優雅的方式,任何Hook都有可能存在兼容性問題。

  1. 謹慎使用Hook
  2. Hook系統接口必定要遵循必定的規範,不能假想只有你在Hook這個接口
  3. 不要假想其餘人會怎麼處理,直接把多種方案集成到一塊兒,構建多種場景,測試兼容性

文章列舉的方案可能不全或者不完善,若是有更好的方案,歡迎討論。

參考文檔

字節跳動移動平臺團隊(Client Infrastructure)是大前端基礎技術行業領軍者,負責整個字節跳動的中國區大前端基礎設施建設,提高公司全產品線的性能、穩定性和工程效率。

如今客戶端/前端/服務端/端智能算法/測試開發 面向全球範圍招聘!一塊兒來用技術改變世界,感興趣能夠聯繫郵箱 chenxuwei.cxw@bytedance.com,郵件主題 簡歷-姓名-求職意向-指望城市-電話

關於字節跳動終端技術團隊

字節跳動終端技術團隊在移動端、Web、Desktop 等各終端都有深刻研究。支持的產品包括抖音、今日頭條、西瓜視頻、火山小視頻等 App。根據實踐結晶,現推出 一站式移動開發平臺 veMARS,致力於幫助企業打造優質 App ,提供移動開發解決方案,歡迎開發者體驗。

相關文章
相關標籤/搜索