Github 項目地址:GitHub - larksuite/SDMagicHook: A safe and influence-restricted method hooking for both Objective-C and Swift.。git
某年某月的某一天,產品小 S 向開發君小 Q 提出了一個簡約而不簡單的需求:擴大一下某個 button 的點擊區域。小 Q 聽完暗自竊喜:還好,這是一個我自定義的 button,只須要重寫一下 button 的 pointInside:withEvent:方法便可。只見小 Q 手起刀落在產品小 S 崇拜的目光中輕鬆完成。代碼以下:github
第二天,產品小 S 又一次滿懷期待地找到開發君小 Q:歐巴~,幫我把這個 button 也擴大一下點擊區域吧。小 Q 此次卻犯了難,心中暗自思忖:這是系統提供的標準 UI 組件裏面的 button 啊,我只能拿來用無法改呀,我看你這分明就是故意爲難我胖虎!我…我…我.----小 Q 卒。面試
在這個 case 中,小 Q 的遭遇着實使人同情。可是痛定思痛,難道產品提出的這個問題真的無解嗎?其實否則,各位看官靜息安坐,且聽我慢慢分析:api
Objective-C 做爲一門古老而又靈活的語言有不少動態特性爲開發者所津津樂道,這其中尤爲以動態類型(Dynamic typing)、動態綁定(Dynamic binding)、動態加載(Dynamic loading)等特性最爲著名,許多在其餘語言中看似不可能實現的功能也能夠在 OC 中利用這些動態特性達到事半功倍的效果。安全
動態類型就是說運行時才肯定對象的真正類型。例如咱們能夠向一個 id 類型的對象發送任何消息,這在編譯期都是合法的,由於類型是能夠動態肯定的,消息真正起做用的時機也是在運行時這個對象的類型肯定之後,這個下面就會講到。咱們甚至能夠在運行時動態修改一個對象的 isa 指針從而修改其類型,OC 中 KVO 的實現正是對動態類型的典型應用。多線程
當一個對象的類型被肯定後,其對應的屬性和可響應的消息也被肯定,這就是動態綁定。綁定完成以後就能夠在運行時根據對象的類型在類型信息中查找真正的函數地址而後執行。dom
根據需求加載所須要的素材資源和代碼資源,用戶可根據需求加載一些可執行的代碼資源,而不是在在啓動的時候就加載全部的組件,可執行代碼能夠含有新的類。ide
瞭解了 OC 的這些動態特性以後,讓咱們再次回顧一下產品的需求要領:產品只想任性地修改任何一個 button 的點擊區域,而恰巧此次這個 button 是系統原生組件中的一個子 View。因此當前要解決的關鍵問題就是如何去改變一個用系統原生類實例化出來的組件的「點擊區域檢測方法」。剛纔在 OC 動態類型特性的介紹中咱們說過「消息真正起做用的時機是在運行時這個對象的類型肯定之後」、「咱們甚至能夠在運行時動態修改一個對象的 isa 指針從而修改其類型,OC 中 KVO 的實現正是對動態類型的典型應用」。看到這裏,你應該大概有了一些思路,咱們不妨照貓畫虎模仿 KVO 的原理來實現一下。函數
要想使用這種相似 KVO 的替換 isa 指針的方案,首先須要解決如下幾個問題:學習
在 OC 中,咱們能夠調用 runtime 的 objc_allocateClassPair、objc_registerClassPair 函數動態地生成新的類,而後調用 object_setClass 函數去將某個對象的 isa 替換爲咱們自建的臨時類。
做爲一個有意義的臨時類名,首先得能夠直觀地看出這個臨時類與其基類的關係,因此咱們能夠這樣拼接新的類名[NSString stringWithFormat:@「SDHook*%s」, originalClsName],但這有一個很明顯的問題就是沒法作到一個對象獨享一個專有類,爲此咱們能夠繼續擴充下,不妨在類名中加上一個對象的惟一標記–內存地址,新的類名組成是這樣的[NSString stringWithFormat:@「SDHook_%s_%p」, originalClsName, self],此次看起來彷佛完美了,但在極端的狀況下還會出問題,例如咱們在一個一萬次的 for 循環中不斷建立同一種類型的對象,那麼就會大機率出現新對象的內存地址和以前已經釋放了的對象的內存地址同樣,而咱們會在一個對象析構後很快就會去釋放它所使用的臨時類,這就會有機率致使那個新生成的對象正在使用的類被釋放了而後就發生了 crash。爲解決此類問題,咱們須要再在這個臨時的類名中添加一個隨機標記來下降這種狀況發生的機率,最終的類名組成是這樣的[NSString stringWithFormat:@「SDHook_%s_%p_%d」, originalClsName, self, mgr.randomFlag]。
咱們經過 objc_setAssociatedObject 的方式能夠爲每一個 NSObject 對象動態關聯上一個 SDNewClassManager 實例,在 SDNewClassManager 實例裏面持有當前對象所使用的臨時類。當前對象銷燬時也會銷燬這個 SDNewClassManager 實例,而後咱們就能夠在 SDNewClassManager 實例的 dealloc 方法裏面作一些銷燬臨時類的操做。但這裏咱們又不能當即作銷燬臨時類的操做,由於此時這個對象尚未徹底析構,它還在作一些其它善後操做,若是此時去銷燬那個臨時類必然會形成 crash,因此咱們須要稍微延遲一段時間來作這些臨時類的銷燬操做,代碼以下:
好了,到目前爲止咱們已經實現了初版 hook 方案,不過這裏兩個明顯的問題:
爲此,咱們研發了第二版針對初版的不足予以改進和優化。
針對上面提到的兩個問題,咱們能夠經過用 block 生成 IMP 而後將這個 IMP 替換到目標 Selector 對應的 method 上便可,API 示例代碼以下:
這個 block 方案看上去確實簡潔和方便了不少,但一樣面臨着任何一個 hook 方案都避不開的問題那就是,如何在 block 裏面調用原生的對應方法呢?
在第一版方案中,咱們在一個類的 category 中增長了一個 hook 專用的方法,而後在完成方法交換以後經過向實例發送 hook 專用的方法自身對應的 selector 消息便可實現對原生方法的回調。可是如今咱們是使用的 block 建立了一個「匿名函數」來替換原生方法,既然是匿名函數也就沒有明確的 selector,這也就意味着咱們根本沒有辦法在方法交換後找到它的原生方法了!
那麼眼下的關鍵問題就是找到一個合適的 Selector 來映射到被 hook 的原生函數。而目前來看,咱們惟一能夠在當前編譯環境下方便調用且和這個 block 還有必定關聯關係的 Selector 就是原方法的 Selector 也就是咱們的 demo 中的pointInside:withEvent:了。這樣一來pointInside:withEvent:這個 Selector 就變成了一個一對多的映射 key,當有人在外部向咱們的 button 發送 pointInside:withEvent:消息時,咱們應該首先將 pointInside:withEvent:轉發給咱們自定義的 block 實現的 IMP,而後當在 block 內部再次向 button 發送 pointInside:withEvent:消息時就將這個消息轉發給系統原生的方法實現,如此一來就能夠完成了一次完美的方法調度了。
在 OC 中要想調度方法派發就須要拿到消息轉發的控制權,而要想得到這個消息轉發控制權就須要強制讓這個 receiver 每次收到這個消息都觸發其消息轉發機制而後咱們在消息轉發的過程當中作對應的調度。在這個例子中咱們將目標 button 的 pointInside:withEvent:對應的 method 的 imp 指針替換爲_objc_msgForward,這樣每當有人調用這個 button 的 pointInside:withEvent:方法時最終都會走到消息轉發方法 forwardInvocation:裏面,咱們實現這個方法來完成具體的方法調度工做。
由於目標 button 的 pointInside:withEvent:對應的 method 的 imp 指針被替換成了_objc_msgForward,因此咱們須要另外新增一個方法 A 和方法 B 來分別存儲目標 button 的 pointInside:withEvent:方法的 block 自定義實現和原生實現。而後當須要在自定義的方法內部調用原始方法時經過調用 callOriginalMethodInBlock:這個 api 來顯式告知,示例代碼以下:
callOriginalMethodInBlock 方法的內部實現其實就是爲這次調用加了一個標識符用於在方法調度時判斷是否須要調用原始方法,其實現代碼以下:
當目標 button 實例收到 pointInside:withEvent:消息時會啓用咱們自定義的消息調度機制,檢查若是 OriginalCallFlag 爲 false 就去調用自定義實現方法 A,不然就去調用原始實現方法 B,從而順利實現一次方法調度。流程圖及示例代碼以下:
想象這樣一個應用場景:有一個全局的 keywindow,各個業務都想監聽一下 keywindow 的 layoutSubviews 方法,那咱們該如何去管理和維護添加到 keywindow 上的多個 hook 實現之間的關係呢?若是一個對象要銷燬了,它須要移除掉以前對 keywindow 的 hook,這時又該如何處理呢?
咱們的解決方案是爲每一個被 hook 的目標原生方法生成一張 hook 表,按照 hook 發生的順序依次爲其生成內部 selector 並加入到 hook 表中。當 keywindow 收到 layoutSubviews 消息時,咱們從 hook 表中取出該次消息對應的 hook selector 發送給 keywindow 讓它執行對應的動做。若是刪除某個 hook 也只需將其對應的 selector 從 hook 表中移除便可。代碼以下:
咱們都知道在對某個方法進行 hook 操做時都須要在咱們的 hook 代碼方法體中調用一下被 hook 的那個原始方法,若是遺漏了此步操做就會形成 hook 鏈斷裂,這樣就會致使被 hook 的那個原始方法永遠不會被調用到,若是有人在你以前也 hook 了這個方法的話就會致使在你以前的全部 hook 都莫名失效了,由於這是一個很隱蔽的問題因此你每每很難意識到你的 hook 操做已經給其餘人形成了嚴重的問題。
爲了方便 hook 操做者快速及時發現這一問題,咱們在 DEBUG 模式下增長了一套「hook 鏈斷裂檢測機制」,其實現原理大體以下:
前面已經提到過,咱們實現了對 hook 目標方法的自定義調度,這就使得咱們有機會在這些方法調用結束後檢測其是否在方法執行過程當中經過 callOriginalMethodInBlock 調用原始方法。若是發現某個方法體不是被 hook 的目標函數的最原始的方法體且此次方法執行結束以後也沒有調用過原始方法就會經過 raise(SIGTRAP)方式發送一箇中斷信號暫停當前的程序以提醒開發者當次 hook 操做沒有調用原始方法。
與傳統的在 category 中新增一個自定義方法而後進行 hook 的方案對比,SDMagicHook的優缺點以下:
只用一個 block 便可對任意一個實例的任意方法實現 hook 操做,不須要新增任何 category,簡潔高效,能夠大大提升你調試程序的效率;
hook 的做用域能夠控制在單個實例粒度內,將 hook 的反作用降到最低;
能夠對任意普通實例甚至任意類進行 hook 操做,不管這個實例或者類是你本身生成的仍是第三方提供的;
能夠隨時添加或去除者任意 hook,易於對 hook 進行管理。
爲了保證增刪 hook 時的線程安全,SDMagicHook 進行增刪 hook 相關的操做時在實例粒度內增長了讀寫鎖,若是有在多線程頻繁的 hook 操做可能會帶來一點線程等待開銷,可是大多數狀況下能夠忽略不計;
由於是基於實例維度的因此比較適合處理對某個類的個別實例進行 hook 的場景,若是你須要你的 hook 對某個類的全部實例都生效建議繼續沿用傳統方式的 hook。
SDMagicHook 方案在 OC 中和 Swift 的 UIKit 層都可直接使用,並且 hook 做用域能夠限制在你指定的某個實例範圍內從而避免污染其它不相關的實例。Api 設計簡潔易用,你只須要花費一分鐘的時間便可輕鬆快速上手,但願咱們的這套方案能夠給你帶來更美妙的 iOS 開發體驗。
2020年iOS高級大廠面試題 點擊進羣,密碼:111 做爲一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個個人iOS交流羣:651612063 進羣密碼111,分享BAT,阿里面試題、面試經驗,討論技術, 你們一塊兒交流學習成長!