天天看點

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

作者:位元組移動技術——段文斌

衆所周知,位元組跳動的推薦在業内處于領先水準,而精确的推薦離不開大量埋點,常見的埋點采集方案是在響應使用者行為操作的路徑上進行埋點。但是由于App通常會有比較多界面和操作路徑,主動埋點的維護成本就會非常大。是以行業的做法是無埋點,而無埋點實作需要AOP程式設計。

一個常見的場景,比如想在<code>UIViewController</code>出現和消失的時刻分别記錄時間戳用于統計頁面展現的時長。要達到這個目标有很多種方法,但是AOP無疑是最簡單有效的方法。Objective-C的Hook其實也有很多種方式,這裡以Method Swizzle給個示例。

接下來我們探讨一個具體場景:

<code>UICollectionView</code>或者<code>UITableView</code>是iOS中非常常用的清單UI元件,其中清單元素的點選事件回調是通過<code>delegate</code>完成的。這裡以<code>UICollectionView</code>為例,<code>UICollectionView</code>的<code>delegate</code>,有個方法聲明,<code>collectionView:didSelectItemAtIndexPath:</code>,實作這個方法我們就可以給清單元素添加點選事件。

我們的目标是Hook這個delegate的方法,在點選回調的時候進行額外的埋點操作。

通常情況下,Method Swizzle可以滿足絕大部分的AOP程式設計需求。是以首次疊代,我們直接使用Method Swizzle來進行Hook。

我們把這個方案內建到今日頭條App裡面進行測試驗證,發現沒法辦法驗證通過。

主要原因今日頭條App是一個龐大的項目,其中引入了非常多的三方庫,比如IGListKit等,這些三方庫通常對<code>UICollectionView</code>的使用都進行了封裝,而這些封裝,恰恰導緻我們不能使用正常的Method Swizzle來Hook這個delegate。直接的原因總結有以下兩點:

<code>setDelegate</code>傳入的對象不是實作<code>UICollectionViewDelegate</code>協定的那個對象

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

如圖示,<code>setDelegate</code>傳入的是一個代理對象proxy,proxy引用了實際的實作<code>UICollectionViewDelegate</code>協定的<code>delegate</code>,proxy實際上并沒有實作<code>UICollectionViewDelegate</code>的任何一個方法,它把所有方法都轉發給實際的<code>delegate</code>。這種情況下,我們不能直接對proxy進行Method Swizzle

多次<code>setDelegate</code>

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

在上述圖例中,使用方存在連續調用兩次<code>setDelegate</code>的情況,第一次是真實<code>delegate</code>,第二次是<code>proxy</code>,我們需要差別對待。

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

代理模式

NSProxy使用

這裡面<code>UICollectionView</code>的<code>setDelegate</code>傳入的是一個<code>proxy</code>是非常常見的操作,比如IGListKit,同時App基于自身需求,也有可能會做這一層封裝。

在<code>UICollectionView</code>的<code>setDelegate</code>的時候,把<code>delegate</code>包裹在<code>proxy</code>中,然後把proxy設定給<code>UICollectionView</code>,使用<code>proxy</code>對<code>delegate</code>進行消息轉發。

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

方案1已經無法滿足我們的需求了,我們考慮到既然對<code>delegate</code>進行代理是一種正常操作,我們何不也使用代理模式,對<code>proxy</code>再次代理。

先Hook <code>UICollectionView</code>的<code>setDelegate</code>方法

代理<code>delegate</code>

簡單的代碼示意如下

下圖實線表示強引用,虛線表示弱引用。

如果使用方沒有對<code>delegate</code>進行代理,而我們使用代理模式

<code>UICollectionView</code>,其<code>delegate</code>指針指向DelegateProxy

DelegateProxy,被UICollectionView用runtime的方式強引用,其target弱引用真實Delegate

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

如果使用方也對<code>delegate</code>進行代理,我們使用代理模式

我們隻需要保證我們的DelegateProxy處于代理鍊中的一環即可

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

從這裡我們可以看出,代理模式有很好的擴充性,它允許代理鍊不斷嵌套,隻要我們都遵循代理模式的原則即可。

到這裡,我們的方案已經在今日頭條App上測試通過了。但是事情遠還沒有結束。

目前的還算比較可以,但是也不能完全避免問題。這裡其實不僅僅是UICollectionView的delegate,包括:

UIWebView

WKWebView

UITableView

UICollectionView

UIScrollView

UIActionSheet

UIAlertView

我們都采用相同的方法來進行Hook。同時我們将方案封裝一個SDK對外提供,以下統稱為MySDK。

某客戶接入我們的方案之後,在內建過程中回報有必現Crash,下面詳細介紹一下這一次踩坑的經曆。

重點資訊是<code>[UIWebView webView:decidePolicyForNavigationAction:request:frame:decisionListener:]</code>。

從堆棧資訊不難判斷出crash原因是UIWebView的delegate野指針,那為啥出現野指針呢?

這裡先說明一下crash的直接原因,然後再來具體分析為什麼就出現了問題。

MySDK對setDelegate進行了Hook

客戶也對setDelegate進行了Hook

先執行MySDK的Hook邏輯調用,然後執行客戶的Hook邏輯調用

UIWebView有兩次調用setDelegate方法,第一次是傳的WebViewJavascriptBridge,第二次傳的另一個實際的WebViewDelegate。暫且稱第一次傳了bridge第二次傳了實際上的delegate。

第一次調用,MySDK Hook的時候會用DelegateProxy包裝住bridge,所有方法通過DelegateProxy轉發到bridge,這裡傳給 <code>setJSBridgeDelegate:(id)delegate</code>的delegate實際上是DelegateProxy而非bridge。

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

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

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

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

此時的狀态如果不做任何處理,目前狀态就如圖示:

delegate指向已經釋放的DelegateProxy,野指針

UIWebview觸發回調就導緻crash

如果補上那一句,<code>setJSBridgeDelegate:(id)delegate</code>在判斷了delegate不是bridge之後,把UIWebView的delegate設定為bridge就可以完成了。

注釋中 fix with this下一行代碼

修複後模型如下圖

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

使用Proxy的方式雖然也可以解決一定的問題,但是也需要使用方遵循一定的規範,要意識到第三方SDK也可能<code>setDelegate</code>進行Hook,也可能使用Proxy

先補充一些參考資料

RxCocoa源碼參考 https://github.com/ReactiveX/RxSwift

rxcocoa學習-DelegateProxy

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

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

如果單獨隻使用了RxCocoa的方案,和方案是一緻,也就不會有任何問題。

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

RxCocoa+MySDK之後,變成這樣子。UICollectionView的delegate直接指向誰在于誰調用的<code>setDelegate</code>方法後調。

理論也應該沒有問題,就是引用鍊多一個poxy包裝而已。但是實際上有兩個問題。

RxCocoa的delegate的get方法命中assert

重點邏輯

delegateProxy即使RxDelegateProxy

currentDelegate為RxDelegateProxy指向的對象

RxDelegateProxy._setForwardToDelegate把RxDelegateProxy指向真實的Delegate

标紅的前面一句執行的時候,是調用setDelegate方法,把RxDelegateProxy的proxy設定給UIScrollView(其實是一個UICollectionView執行個體)

然後進入了MySDK的Hook方法,把RxDelegateProxy給包了一層

最終結果如下圖

然後導緻self._currentDelegate(for: object) 是DelegateProxy而非RxDelegateProxy,觸發标紅斷言

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

這個斷言就很霸道,相當于RxCocoa認為就隻有它能夠去使用Proxy包裝delegate,其他人不能這樣做,隻要做了,就斷言。

進一步分析

目前狀态

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

再次進入Rx的方法

currentDelegate是UICollectionView指向的DelegateProxy(MySDK的包裝)

delegateProxy指向還是RxDelegateProxy

觸發Rx的if判斷,Rx會把其指向真實的delegate改向UICollectionView指向的DelegateProxy

導緻循環指向,引用鍊中真實的Delegate丢失了

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

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

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

responds代碼

似乎隻要不多次調用就沒有問題了?

關鍵在于Rx的setDelegate方法也調用了get方法,導緻一次get就觸發第二次調用。也就是多次調用是無法避免。

問題的原因比較明顯,如果改造RxCocoa的代碼,把第三方可能的Hook考慮進來,完全可以解決問題。

參考MySDK的proxy方案,在proxy中加入一個特殊方法,來判斷RxDelegateProxy是否已經在引用鍊中,而不去主動改變這個引用鍊。

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

類似這樣的改造,就可以解決問題。我們與Rx團隊進行了溝通,也提了PR,可惜最終被拒絕合入了。Rx給出的說明是,Hook是不優雅的方式,不推薦Hook系統的任何方法,也不想相容任何第三方的Hook。

有沒有可能,RxCocoa不改代碼,MySDK來相容?

剛才提到,有可能是兩種狀态。

狀态1

setDelegate的時候,先進Rx的方法,後進MySDK的Hook方法,

傳給Rx的就是delegate

傳給MySDK的是RxDelegateProxy

Delegate的get調用就觸發bug

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

狀态2

setDelegate的時候,先進MySDK的Hook方法,後進Rx的方法?

傳給Rx的就是DelegateProxy

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

其實如果是狀态2,似乎Rxcocoa的bug是不會複現的。

但是仔細檢視Rxcocoa的setDelegate代碼

emmm?Rx裡面,UICollectionView的setDelegate和Delegate的get方法不是Hook...

最終流程就隻能是

setDelegate的時候,先進Rx的方法,傳給Rx真實的delegate

後進MySDK的Hook方法

Rx裡面擷取CollectionView的delegate觸發判斷

如果MySDK還是采用目前的Hook方案,就沒法在MySDK解決了。

仔細看了一下,發現Rx裡面是通過重寫RxDelegateProxy的forwardInvocation來達到方法轉發的目的,即

RxDelegateProxy沒有實作<code>UICollectionViewDelegate</code>的任何方法

forwardInvocation中處理<code>UICollectionViewDelegate</code>相關回調

回顧消息轉發機制

無埋點核心技術:iOS Hook在位元組的實踐經驗前言方案疊代踩坑之旅總結參考文檔

我們可以在forwardingTargetForSelector這一步進行處理,這樣可以避開與Rx相關的沖突,處理完再直接跳過。

forwardingTargetForSelector中針對delegate的回調,target傳回一個SDK處理的類,比DelegateProxy

DelegateProxy上報完成之後,直接調用跳到RxDelegateProxy的forwardInvocation方法

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

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

謹慎使用Hook

Hook系統接口一定要遵循一定的規範,不能假想隻有你在Hook這個接口

不要假想其他人會怎麼處理,直接把多種方案內建到一起,建構多種場景,測試相容性

文章列舉的方案可能不全或者不完善,如果有更好的方案,歡迎讨論。

https://github.com/ReactiveX/RxSwift

位元組跳動終端技術團隊(Client Infrastructure)是大前端基礎技術的全球化研發團隊(分别在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設有研發團隊),負責整個位元組跳動的大前端基礎設施建設,提升公司全産品線的性能、穩定性和工程效率;支援的産品包括但不限于抖音、今日頭條、西瓜視訊、飛書、瓜瓜龍等,在移動端、Web、Desktop等各終端都有深入研究。

火山引擎應用開發套件MARS是位元組跳動終端技術團隊過去九年在抖音、今日頭條、西瓜視訊、飛書、懂車帝等 App 的研發實踐成果,面向移動研發、前端開發、QA、 運維、産品經理、項目經理以及營運角色,提供一站式整體研發解決方案,助力企業研發模式更新,降低企業研發綜合成本。