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

語言: CN / TW / HK

本文從一個 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 如何給這些新建的臨時類命名 

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 方案,不過這裡兩個明顯的問題:

1.  每次 hook 都要增加一個 category 定義一個函式相對比較麻煩;

2.  如果我們在某個 Class 的兩個 category 裡面分別實現了一個同名的方法就會導致只有一個方法最終能被呼叫到。

為此,我們研發了第二版針對第一版的不足予以改進和優化。 3. 優化版 SDMagicHook 方案 針對上面提到的兩個問題,我們可以通過用 block 生成 IMP 然後將這個 IMP 替換到目標 Selector 對應的 method 上即可,API 示例

程式碼如下:

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:訊息時就將這個訊息轉發給系統原生的方法實現,如此一來就可以完成了一次完美的方法排程了。 

因為目標 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 的優缺點如下:

優點:

1. 只用一個 block 即可對任意一個例項的任意方法實現 hook 操作,不需要新增任何 category,簡潔高效,可以大大提高你除錯程式的效率;

2. hook 的作用域可以控制在單個例項粒度內,將 hook 的副作用降到最低; 

3. 可以對任意普通例項甚至任意類進行 hook 操作,無論這個例項或者類是你自己生成的還是第三方提供的;

4. 可以隨時新增或去除者任意 hook,易於對 hook 進行管理。 

缺點:

1.為了保證增刪 hook 時的執行緒安全,SDMagicHook 進行增刪 hook 相關的操作時在例項粒度內增加了讀寫鎖,如果有在多執行緒頻繁的 hook 操作可能會帶來一點執行緒等待開銷,但是大多數情況下可以忽略不計;

2. 因為是基於例項維度的所以比較適合處理對某個類的個別例項進行 hook 的場景,如果你需要你的 hook 對某個類的所有例項都生效建議繼續沿用傳統方式的 hook。 

總結

SDMagicHook 方案在 OC 中和 Swift 的 UIKit 層均可直接使用,而且 hook 作用域可以限制在你指定的某個例項範圍內從而避免汙染其它不相關的例項。

Api 設計簡潔易用,你只需要花費一分鐘的時間即可輕鬆快速上手,希望我們的這套方案可以給你帶來更美妙的 iOS 開發體驗。 

參考專案:/ Github /