作者:位元組移動技術——段文斌
衆所周知,位元組跳動的推薦在業内處于領先水準,而精确的推薦離不開大量埋點,常見的埋點采集方案是在響應使用者行為操作的路徑上進行埋點。但是由于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>協定的那個對象
如圖示,<code>setDelegate</code>傳入的是一個代理對象proxy,proxy引用了實際的實作<code>UICollectionViewDelegate</code>協定的<code>delegate</code>,proxy實際上并沒有實作<code>UICollectionViewDelegate</code>的任何一個方法,它把所有方法都轉發給實際的<code>delegate</code>。這種情況下,我們不能直接對proxy進行Method Swizzle
多次<code>setDelegate</code>
在上述圖例中,使用方存在連續調用兩次<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>進行消息轉發。
方案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
如果使用方也對<code>delegate</code>進行代理,我們使用代理模式
我們隻需要保證我們的DelegateProxy處于代理鍊中的一環即可
從這裡我們可以看出,代理模式有很好的擴充性,它允許代理鍊不斷嵌套,隻要我們都遵循代理模式的原則即可。
到這裡,我們的方案已經在今日頭條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。
這裡需要注意,UIWebView的delegate指向DelegateProxy是客戶給設定上的,且這個屬性assign而非weak,這個assign很關鍵,assigin在對象釋放之後不會自動變為nil。
第二次調用,MySDK Hook的時候會用新的DelegateProxy包裝住delegate也就是WebViewDelegate,這個時候MySDK的邏輯是把新的DelegateProxy給強引用中,老的DelegateProxy就失去了強引用是以釋放了。
此時的狀态如果不做任何處理,目前狀态就如圖示:
delegate指向已經釋放的DelegateProxy,野指針
UIWebview觸發回調就導緻crash
如果補上那一句,<code>setJSBridgeDelegate:(id)delegate</code>在判斷了delegate不是bridge之後,把UIWebView的delegate設定為bridge就可以完成了。
注釋中 fix with this下一行代碼
修複後模型如下圖
使用Proxy的方式雖然也可以解決一定的問題,但是也需要使用方遵循一定的規範,要意識到第三方SDK也可能<code>setDelegate</code>進行Hook,也可能使用Proxy
先補充一些參考資料
RxCocoa源碼參考 https://github.com/ReactiveX/RxSwift
rxcocoa學習-DelegateProxy
RxCocoa也使用了代理模式,對delegate進行了代理,按道理應該沒有問題。但是RxCocoa的實作有點出入。
如果單獨隻使用了RxCocoa的方案,和方案是一緻,也就不會有任何問題。
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,觸發标紅斷言
這個斷言就很霸道,相當于RxCocoa認為就隻有它能夠去使用Proxy包裝delegate,其他人不能這樣做,隻要做了,就斷言。
進一步分析
目前狀态
再次進入Rx的方法
currentDelegate是UICollectionView指向的DelegateProxy(MySDK的包裝)
delegateProxy指向還是RxDelegateProxy
觸發Rx的if判斷,Rx會把其指向真實的delegate改向UICollectionView指向的DelegateProxy
導緻循環指向,引用鍊中真實的Delegate丢失了
上面提到多次調用導緻了循環指向,而循環指向導緻了在實際的方法轉發的時候變成了死循環。
responds代碼
似乎隻要不多次調用就沒有問題了?
關鍵在于Rx的setDelegate方法也調用了get方法,導緻一次get就觸發第二次調用。也就是多次調用是無法避免。
問題的原因比較明顯,如果改造RxCocoa的代碼,把第三方可能的Hook考慮進來,完全可以解決問題。
參考MySDK的proxy方案,在proxy中加入一個特殊方法,來判斷RxDelegateProxy是否已經在引用鍊中,而不去主動改變這個引用鍊。
類似這樣的改造,就可以解決問題。我們與Rx團隊進行了溝通,也提了PR,可惜最終被拒絕合入了。Rx給出的說明是,Hook是不優雅的方式,不推薦Hook系統的任何方法,也不想相容任何第三方的Hook。
有沒有可能,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代碼
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>相關回調
回顧消息轉發機制
我們可以在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、 運維、産品經理、項目經理以及營運角色,提供一站式整體研發解決方案,助力企業研發模式更新,降低企業研發綜合成本。