背景
某年某月的某一天,產品小 S 向開發(fā)君小 Q 提出了一個簡約而不簡單的需求:擴大一下某個 button 的點擊區(qū)域。小 Q 聽完暗自竊喜:還好,這是一個我自定義的 button,只需要重寫一下 button 的 pointInside:withEvent:方法即可。只見小 Q 手起刀落在產品小 S 崇拜的目光中輕松完成。代碼如下:

次日,產品小 S 又一次滿懷期待地找到開發(fā)君小 Q:歐巴~,幫我把這個 button 也擴大一下點擊區(qū)域吧。小 Q 這次卻犯了難,心中暗自思忖:這是系統(tǒng)提供的標準 UI 組件里面的 button 啊,我只能拿來用沒法改呀,我看你這分明就是故意為難我胖虎!我…我…我.----小 Q 卒。
在這個 case 中,小 Q 的遭遇著實令人同情。但是痛定思痛,難道產品提出的這個問題真的無解嗎?其實不然,各位看官靜息安坐,且聽我慢慢分析:
1. Objective-C 的動態(tài)特性
Objective-C 作為一門古老而又靈活的語言有很多動態(tài)特性為開發(fā)者所津津樂道,這其中尤其以動態(tài)類型(Dynamic typing)、動態(tài)綁定(Dynamic binding)、動態(tài)加載(Dynamic loading)等特性最為著名,許多在其他語言中看似不可能實現(xiàn)的功能也可以在 OC 中利用這些動態(tài)特性達到事半功倍的效果。
1.1 動態(tài)類型(Dynamic typing)
動態(tài)類型就是說運行時才確定對象的真正類型。例如我們可以向一個 id 類型的對象發(fā)送任何消息,這在編譯期都是合法的,因為類型是可以動態(tài)確定的,消息真正起作用的時機也是在運行時這個對象的類型確定以后,這個下面就會講到。我們甚至可以在運行時動態(tài)修改一個對象的 isa 指針從而修改其類型,OC 中 KVO 的實現(xiàn)正是對動態(tài)類型的典型應用。
1.2 動態(tài)綁定(Dynamic binding)
當一個對象的類型被確定后,其對應的屬性和可響應的消息也被確定,這就是動態(tài)綁定。綁定完成之后就可以在運行時根據(jù)對象的類型在類型信息中查找真正的函數(shù)地址然后執(zhí)行。
1.3 動態(tài)加載(Dynamic loading)
根據(jù)需求加載所需要的素材資源和代碼資源,用戶可根據(jù)需求加載一些可執(zhí)行的代碼資源,而不是在在啟動的時候就加載所有的組件,可執(zhí)行代碼可以含有新的類。
了解了 OC 的這些動態(tài)特性之后,讓我們再次回顧一下產品的需求要領:產品只想任性地修改任何一個 button 的點擊區(qū)域,而恰巧這次這個 button 是系統(tǒng)原生組件中的一個子 View。所以當前要解決的關鍵問題就是如何去改變一個用系統(tǒng)原生類實例化出來的組件的“點擊區(qū)域檢測方法”。剛才在 OC 動態(tài)類型特性的介紹中我們說過“消息真正起作用的時機是在運行時這個對象的類型確定以后”、“我們甚至可以在運行時動態(tài)修改一個對象的 isa 指針從而修改其類型,OC 中 KVO 的實現(xiàn)正是對動態(tài)類型的典型應用”??吹竭@里,你應該大概有了一些思路,我們不妨照貓畫虎模仿 KVO 的原理來實現(xiàn)一下。
2. 初版 SDMagicHook 方案
要想使用這種類似 KVO 的替換 isa 指針的方案,首先需要解決以下幾個問題:
2.1 如何動態(tài)創(chuàng)建一個新的類
在 OC 中,我們可以調用 runtime 的 objc_allocateClassPair、objc_registerClassPair 函數(shù)動態(tài)地生成新的類,然后調用 object_setClass 函數(shù)去將某個對象的 isa 替換為我們自建的臨時類。
2.2 如何給這些新建的臨時類命名
作為一個有意義的臨時類名,首先得可以直觀地看出這個臨時類與其基類的關系,所以我們可以這樣拼接新的類名[NSString stringWithFormat:@“SDHook*%s”, originalClsName],但這有一個很明顯的問題就是無法做到一個對象獨享一個專有類,為此我們可以繼續(xù)擴充下,不妨在類名中加上一個對象的唯一標記–內存地址,新的類名組成是這樣的[NSString stringWithFormat:@“SDHook_%s_%p”, originalClsName, self],這次看起來似乎完美了,但在極端的情況下還會出問題,例如我們在一個一萬次的 for 循環(huán)中不斷創(chuàng)建同一種類型的對象,那么就會大概率出現(xiàn)新對象的內存地址和之前已經釋放了的對象的內存地址一樣,而我們會在一個對象析構后很快就會去釋放它所使用的臨時類,這就會有概率導致那個新生成的對象正在使用的類被釋放了然后就發(fā)生了 crash。為解決此類問題,我們需要再在這個臨時的類名中添加一個隨機標記來降低這種情況發(fā)生的概率,最終的類名組成是這樣的[NSString stringWithFormat:@“SDHook_%s_%p_%d”, originalClsName, self, mgr.randomFlag]。

2.3 何時銷毀這些臨時類
我們通過 objc_setAssociatedObject 的方式可以為每個 NSObject 對象動態(tài)關聯(lián)上一個 SDNewClassManager 實例,在 SDNewClassManager 實例里面持有當前對象所使用的臨時類。當前對象銷毀時也會銷毀這個 SDNewClassManager 實例,然后我們就可以在 SDNewClassManager 實例的 dealloc 方法里面做一些銷毀臨時類的操作。但這里我們又不能立即做銷毀臨時類的操作,因為此時這個對象還沒有完全析構,它還在做一些其它善后操作,如果此時去銷毀那個臨時類必然會造成 crash,所以我們需要稍微延遲一段時間來做這些臨時類的銷毀操作,代碼如下:

好了,到目前為止我們已經實現(xiàn)了第一版 hook 方案,不過這里兩個明顯的問題:
每次 hook 都要增加一個 category 定義一個函數(shù)相對比較麻煩;
如果我們在某個 Class 的兩個 category 里面分別實現(xiàn)了一個同名的方法就會導致只有一個方法最終能被調用到。
為此,我們研發(fā)了第二版針對第一版的不足予以改進和優(yōu)化。
3. 優(yōu)化版 SDMagicHook 方案
針對上面提到的兩個問題,我們可以通過用 block 生成 IMP 然后將這個 IMP 替換到目標 Selector 對應的 method 上即可,API 示例代碼如下:

這個 block 方案看上去確實簡潔和方便了很多,但同樣面臨著任何一個 hook 方案都避不開的問題那就是,如何在 block 里面調用原生的對應方法呢?
3.1 關鍵點一:如何在 block 里面調用原生方法
在初版方案中,我們在一個類的 category 中增加了一個 hook 專用的方法,然后在完成方法交換之后通過向實例發(fā)送 hook 專用的方法自身對應的 selector 消息即可實現(xiàn)對原生方法的回調。但是現(xiàn)在我們是使用的 block 創(chuàng)建了一個“匿名函數(shù)”來替換原生方法,既然是匿名函數(shù)也就沒有明確的 selector,這也就意味著我們根本沒有辦法在方法交換后找到它的原生方法了!
那么眼下的關鍵問題就是找到一個合適的 Selector 來映射到被 hook 的原生函數(shù)。而目前來看,我們唯一可以在當前編譯環(huán)境下方便調用且和這個 block 還有一定關聯(lián)關系的 Selector 就是原方法的 Selector 也就是我們的 demo 中的pointInside:withEvent:了。這樣一來pointInside:withEvent:這個 Selector 就變成了一個一對多的映射 key,當有人在外部向我們的 button 發(fā)送 pointInside:withEvent:消息時,我們應該首先將 pointInside:withEvent:轉發(fā)給我們自定義的 block 實現(xiàn)的 IMP,然后當在 block 內部再次向 button 發(fā)送 pointInside:withEvent:消息時就將這個消息轉發(fā)給系統(tǒng)原生的方法實現(xiàn),如此一來就可以完成了一次完美的方法調度了。
3.2 關鍵點二:如何設計消息調度方案
在 OC 中要想調度方法派發(fā)就需要拿到消息轉發(fā)的控制權,而要想獲得這個消息轉發(fā)控制權就需要強制讓這個 receiver 每次收到這個消息都觸發(fā)其消息轉發(fā)機制然后我們在消息轉發(fā)的過程中做對應的調度。在這個例子中我們將目標 button 的 pointInside:withEvent:對應的 method 的 imp 指針替換為_objc_msgForward,這樣每當有人調用這個 button 的 pointInside:withEvent:方法時最終都會走到消息轉發(fā)方法 forwardInvocation:里面,我們實現(xiàn)這個方法來完成具體的方法調度工作。
因為目標 button 的 pointInside:withEvent:對應的 method 的 imp 指針被替換成了_objc_msgForward,所以我們需要另外新增一個方法 A 和方法 B 來分別存儲目標 button 的 pointInside:withEvent:方法的 block 自定義實現(xiàn)和原生實現(xiàn)。然后當需要在自定義的方法內部調用原始方法時通過調用 callOriginalMethodInBlock:這個 api 來顯式告知,示例代碼如下:

callOriginalMethodInBlock 方法的內部實現(xiàn)其實就是為此次調用加了一個標識符用于在方法調度時判斷是否需要調用原始方法,其實現(xiàn)代碼如下:

當目標 button 實例收到 pointInside:withEvent:消息時會啟用我們自定義的消息調度機制,檢查如果 OriginalCallFlag 為 false 就去調用自定義實現(xiàn)方法 A,否則就去調用原始實現(xiàn)方法 B,從而順利實現(xiàn)一次方法調度。流程圖及示例代碼如下:



想象這樣一個應用場景:有一個全局的 keywindow,各個業(yè)務都想監(jiān)聽一下 keywindow 的 layoutSubviews 方法,那我們該如何去管理和維護添加到 keywindow 上的多個 hook 實現(xiàn)之間的關系呢?如果一個對象要銷毀了,它需要移除掉之前對 keywindow 的 hook,這時又該如何處理呢?
我們的解決方案是為每個被 hook 的目標原生方法生成一張 hook 表,按照 hook 發(fā)生的順序依次為其生成內部 selector 并加入到 hook 表中。當 keywindow 收到 layoutSubviews 消息時,我們從 hook 表中取出該次消息對應的 hook selector 發(fā)送給 keywindow 讓它執(zhí)行對應的動作。如果刪除某個 hook 也只需將其對應的 selector 從 hook 表中移除即可。代碼如下:

4. 防止 hook 鏈意外斷裂
我們都知道在對某個方法進行 hook 操作時都需要在我們的 hook 代碼方法體中調用一下被 hook 的那個原始方法,如果遺漏了此步操作就會造成 hook 鏈斷裂,這樣就會導致被 hook 的那個原始方法永遠不會被調用到,如果有人在你之前也 hook 了這個方法的話就會導致在你之前的所有 hook 都莫名失效了,因為這是一個很隱蔽的問題所以你往往很難意識到你的 hook 操作已經給其他人造成了嚴重的問題。
為了方便 hook 操作者快速及時發(fā)現(xiàn)這一問題,我們在 DEBUG 模式下增加了一套“hook 鏈斷裂檢測機制”,其實現(xiàn)原理大致如下:
前面已經提到過,我們實現(xiàn)了對 hook 目標方法的自定義調度,這就使得我們有機會在這些方法調用結束后檢測其是否在方法執(zhí)行過程中通過 callOriginalMethodInBlock 調用原始方法。如果發(fā)現(xiàn)某個方法體不是被 hook 的目標函數(shù)的最原始的方法體且這次方法執(zhí)行結束之后也沒有調用過原始方法就會通過 raise(SIGTRAP)方式發(fā)送一個中斷信號暫停當前的程序以提醒開發(fā)者當次 hook 操作沒有調用原始方法。

5. SDMagicHook 的優(yōu)缺點
與傳統(tǒng)的在 category 中新增一個自定義方法然后進行 hook 的方案對比,SDMagicHook的優(yōu)缺點如下:
優(yōu)點:
只用一個 block 即可對任意一個實例的任意方法實現(xiàn) hook 操作,不需要新增任何 category,簡潔高效,可以大大提高你調試程序的效率;
hook 的作用域可以控制在單個實例粒度內,將 hook 的副作用降到最低;
可以對任意普通實例甚至任意類進行 hook 操作,無論這個實例或者類是你自己生成的還是第三方提供的;
可以隨時添加或去除者任意 hook,易于對 hook 進行管理。
缺點:
為了保證增刪 hook 時的線程安全,SDMagicHook?進行增刪 hook 相關的操作時在實例粒度內增加了讀寫鎖,如果有在多線程頻繁的 hook 操作可能會帶來一點線程等待開銷,但是大多數(shù)情況下可以忽略不計;
因為是基于實例維度的所以比較適合處理對某個類的個別實例進行 hook 的場景,如果你需要你的 hook 對某個類的所有實例都生效建議繼續(xù)沿用傳統(tǒng)方式的 hook。
總結
SDMagicHook?方案在 OC 中和 Swift 的 UIKit 層均可直接使用,而且 hook 作用域可以限制在你指定的某個實例范圍內從而避免污染其它不相關的實例。Api 設計簡潔易用,你只需要花費一分鐘的時間即可輕松快速上手,希望我們的這套方案可以給你帶來更美妙的 iOS 開發(fā)體驗。
正在跳轉(iOS交流裙 密碼:123)