02.10 Objective-C & Swift 最輕量級 Hook 方案

本文從一個 iOS 日常開發的 hook 案例入手,首先簡要介紹了 Objective-C 的動態特性以及傳統 hook 方式常見的命名衝突、操作繁瑣、hook 鏈意外斷裂、hook 作用範圍不可控制等缺陷,然後詳細介紹了一套基於消息轉發機制的 instance 粒度的輕量級 hook 方案:

SDMagicHook

Github 項目地址: 。

背景

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

次日,產品小 S 又一次滿懷期待地找到開發君小 Q:歐巴~,幫我把這個 button 也擴大一下點擊區域吧。小 Q 這次卻犯了難,心中暗自思忖:這是系統提供的標準 UI 組件裡面的 button 啊,我只能拿來用沒法改呀,我看你這分明就是故意為難我胖虎!我…我…我.----小 Q 卒。

在這個 case 中,小 Q 的遭遇著實令人同情。但是痛定思痛,難道產品提出的這個問題真的無解嗎?其實不然,各位看官靜息安坐,且聽我慢慢分析:

1. Objective-C 的動態特性

Objective-C 作為一門古老而又靈活的語言有很多動態特性為開發者所津津樂道,這其中尤其以動態類型(Dynamic typing)、動態綁定(Dynamic binding)、動態加載(Dynamic loading)等特性最為著名,許多在其他語言中看似不可能實現的功能也可以在 OC 中利用這些動態特性達到事半功倍的效果。

1.1 動態類型(Dynamic typing)

動態類型就是說運行時才確定對象的真正類型。例如我們可以向一個 id 類型的對象發送任何消息,這在編譯期都是合法的,因為類型是可以動態確定的,消息真正起作用的時機也是在運行時這個對象的類型確定以後,這個下面就會講到。我們甚至可以在運行時動態修改一個對象的 isa 指針從而修改其類型,OC 中 KVO 的實現正是對動態類型的典型應用。

1.2 動態綁定(Dynamic binding)

當一個對象的類型被確定後,其對應的屬性和可響應的消息也被確定,這就是動態綁定。綁定完成之後就可以在運行時根據對象的類型在類型信息中查找真正的函數地址然後執行。

1.3 動態加載(Dynamic loading)

根據需求加載所需要的素材資源和代碼資源,用戶可根據需求加載一些可執行的代碼資源,而不是在在啟動的時候就加載所有的組件,可執行代碼可以含有新的類。

瞭解了 OC 的這些動態特性之後,讓我們再次回顧一下產品的需求要領:產品只想任性地修改任何一個 button 的點擊區域,而恰巧這次這個 button 是系統原生組件中的一個子 View。所以當前要解決的關鍵問題就是如何去改變一個用系統原生類實例化出來的組件的“點擊區域檢測方法”。剛才在 OC 動態類型特性的介紹中我們說過“消息真正起作用的時機是在運行時這個對象的類型確定以後”、“我們甚至可以在運行時動態修改一個對象的 isa 指針從而修改其類型,OC 中 KVO 的實現正是對動態類型的典型應用”。看到這裡,你應該大概有了一些思路,我們不妨照貓畫虎模仿 KVO 的原理來實現一下。

2. 初版 SDMagicHook 方案

要想使用這種類似 KVO 的替換 isa 指針的方案,首先需要解決以下幾個問題:

2.1 如何動態創建一個新的類

在 OC 中,我們可以調用 runtime 的 objc_allocateClassPair、objc_registerClassPair 函數動態地生成新的類,然後調用 object_setClass 函數去將某個對象的 isa 替換為我們自建的臨時類。

2.2 如何給這些新建的臨時類命名

作為一個有意義的臨時類名,首先得可以直觀地看出這個臨時類與其基類的關係,所以我們可以這樣拼接新的類名[NSString stringWithFormat:@“SDHook*%s”, originalClsName],但這有一個很明顯的問題就是無法做到一個對象獨享一個專有類,為此我們可以繼續擴充下,不妨在類名中加上一個對象的唯一標記–內存地址,新的類名組成是這樣的[NSString stringWithFormat:@“SDHook_%s_%p”, originalClsName, self],這次看起來似乎完美了,但在極端的情況下還會出問題,例如我們在一個一萬次的 for 循環中不斷創建同一種類型的對象,那麼就會大概率出現新對象的內存地址和之前已經釋放了的對象的內存地址一樣,而我們會在一個對象析構後很快就會去釋放它所使用的臨時類,這就會有概率導致那個新生成的對象正在使用的類被釋放了然後就發生了 crash。為解決此類問題,我們需要再在這個臨時的類名中添加一個隨機標記來降低這種情況發生的概率,最終的類名組成是這樣的[NSString stringWithFormat:@“SDHook_%s_%p_%d”, originalClsName, self, mgr.randomFlag]。

2.3 何時銷燬這些臨時類

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

好了,到目前為止我們已經實現了第一版 hook 方案,不過這裡兩個明顯的問題:

每次 hook 都要增加一個 category 定義一個函數相對比較麻煩;如果我們在某個 Class 的兩個 category 裡面分別實現了一個同名的方法就會導致只有一個方法最終能被調用到。

為此,我們研發了第二版針對第一版的不足予以改進和優化。

3. 優化版 SDMagicHook 方案

針對上面提到的兩個問題,我們可以通過用 block 生成 IMP 然後將這個 IMP 替換到目標 Selector 對應的 method 上即可,API 示例代碼如下:

這個 block 方案看上去確實簡潔和方便了很多,但同樣面臨著任何一個 hook 方案都避不開的問題那就是,如何在 block 裡面調用原生的對應方法呢?

3.1 關鍵點一:如何在 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:消息時就將這個消息轉發給系統原生的方法實現,如此一來就可以完成了一次完美的方法調度了。

3.2 關鍵點二:如何設計消息調度方案

在 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 表中移除即可。代碼如下:

4. 防止 hook 鏈意外斷裂

我們都知道在對某個方法進行 hook 操作時都需要在我們的 hook 代碼方法體中調用一下被 hook 的那個原始方法,如果遺漏了此步操作就會造成 hook 鏈斷裂,這樣就會導致被 hook 的那個原始方法永遠不會被調用到,如果有人在你之前也 hook 了這個方法的話就會導致在你之前的所有 hook 都莫名失效了,因為這是一個很隱蔽的問題所以你往往很難意識到你的 hook 操作已經給其他人造成了嚴重的問題。

為了方便 hook 操作者快速及時發現這一問題,我們在 DEBUG 模式下增加了一套“hook 鏈斷裂檢測機制”,其實現原理大致如下:

前面已經提到過,我們實現了對 hook 目標方法的自定義調度,這就使得我們有機會在這些方法調用結束後檢測其是否在方法執行過程中通過 callOriginalMethodInBlock 調用原始方法。如果發現某個方法體不是被 hook 的目標函數的最原始的方法體且這次方法執行結束之後也沒有調用過原始方法就會通過 raise(SIGTRAP)方式發送一箇中斷信號暫停當前的程序以提醒開發者當次 hook 操作沒有調用原始方法。

5. SDMagicHook 的優缺點

與傳統的在 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 開發體驗。