神經病院 Objective-C Runtime 住院第二天—訊息傳送與轉發

一縷殤流化隱半邊冰霜發表於2016-09-29
111194012-7ad3e550f972f4be

前言

現在越來越多的app都使用了JSPatch實現app熱修復,而JSPatch 能做到通過 JS 呼叫和改寫 OC 方法最根本的原因是 Objective-C 是動態語言,OC 上所有方法的呼叫/類的生成都通過 Objective-C Runtime 在執行時進行,我們可以通過類名/方法名反射得到相應的類和方法,也可以替換某個類的方法為新的實現,理論上你可以在執行時通過類名/方法名呼叫到任何 OC 方法,替換任何類的實現以及新增任意類。今天就來詳細解析一下OC中runtime最為吸引人的地方。

目錄

  • 1.objc_msgSend函式簡介
  • 2.訊息傳送Messaging階段—objc_msgSend原始碼解析
  • 3.訊息轉發Message Forwarding階段
  • 4.forwardInvocation的例子
  • 5.入院考試
  • 6.Runtime中的優化

一.objc_msgSend函式簡介

最初接觸到OC Runtime,一定是從[receiver message]這裡開始的。[receiver message]會被編譯器轉化為:

這是一個可變引數函式。第二個引數型別是SEL。SEL在OC中是selector方法選擇器。

objc_selector是一個對映到方法的C字串。需要注意的是@selector()選擇子只與函式名有關。不同類中相同名字的方法所對應的方法選擇器是相同的,即使方法名字相同而變數型別不同也會導致它們具有相同的方法選擇器。由於這點特性,也導致了OC不支援函式過載。

在receiver拿到對應的selector之後,如果自己無法執行這個方法,那麼該條訊息要被轉發。或者臨時動態的新增方法實現。如果轉發到最後依舊沒法處理,程式就會崩潰。

所以編譯期僅僅是確定了要傳送訊息,而訊息如何處理是要執行期需要解決的事情。

objc_msgSend函式究竟會幹什麼事情呢?從這篇「objc_msgSend() Tour」文章裡面可以得到一個比較詳細的結論。

  1. Check for ignored selectors (GC) and short-circuit.
  2. Check for nil target.
    If nil & nil receiver handler configured, jump to handler
    If nil & no handler (default), cleanup and return.
  3. Search the class’s method cache for the method IMP(use hash to find&store method in cache)
    -1. If found, jump to it.
    -2. Not found: lookup the method IMP in the class itself corresponding its hierarchy chain.
    If found, load it into cache and jump to it.
    If not found, jump to forwarding mechanism.

總結一下objc_msgSend會做一下幾件事情:
1.檢測這個 selector是不是要忽略的。
2.檢查target是不是為nil。

如果這裡有相應的nil的處理函式,就跳轉到相應的函式中。
如果沒有處理nil的函式,就自動清理現場並返回。這一點就是為何在OC中給nil傳送訊息不會崩潰的原因。

3.確定不是給nil發訊息之後,在該class的快取中查詢方法對應的IMP實現。

如果找到,就跳轉進去執行。
如果沒有找到,就在方法分發表裡面繼續查詢,一直找到NSObject為止。

神經病院 Objective-C Runtime 住院第二天—訊息傳送與轉發

4.如果還沒有找到,那就需要開始訊息轉發階段了。至此,傳送訊息Messaging階段完成。這一階段主要完成的是通過select()快速查詢IMP的過程。

二. 訊息傳送Messaging階段—objc_msgSend原始碼解析

在這篇文章Obj-C Optimization: The faster objc_msgSend中看到了這樣一段C版本的objc_msgSend的原始碼。

該原始碼中有一個do-while迴圈,這個迴圈就是上一章裡面提到的在方法分發表裡面查詢method的過程。

不過在obj4-680裡面的objc-msg-x86_64.s檔案中實現是一段彙編程式碼。

來分析一下這段彙編程式碼。

乍一看,如果從LCacheMiss:這裡上下分開,可以很明顯的看到objc_msgSend就幹了兩件事情—— CacheLookup 和 MethodTableLookup。

NilTest是用來檢測是否為nil的。傳入引數有4種,NORMAL / FPRET / FP2RET / STRET。

objc_msgSend 傳入的引數是NilTest NORMAL
objc_msgSend_fpret 傳入的引數是NilTest FPRET
objc_msgSend_fp2ret 傳入的引數是NilTest FP2RET
objc_msgSend_stret 傳入的引數是NilTest STRET

如果檢測方法的接受者是nil,那麼系統會自動clean並且return。

GetIsaFast巨集可以快速地獲取到物件的 isa 指標地址(放到 r11
暫存器,r10會被重寫;在 arm 架構上是直接賦值到 r9)

r12裡面存的是方法method,r9裡面是cache。r1,r2是SEL。在這個CacheLookup函式中,不斷的通過SEL與cache中的bucket->sel進行比較,如果r12 = = 0,則跳轉到LCacheMiss_f標記去繼續執行。如果r12找到了,r12 = =1,即在cache中找到了相應的SEL,則直接執行該IMP(放在r10中)。

程式跳到LCacheMiss,就說明cache中無快取,未命中快取。這個時候就要開始下一階段MethodTableLookup的查詢了。

MethodTableLookup 可以算是個介面層巨集,主要用於儲存環境與準備引數,來呼叫 __class_lookupMethodAndLoadCache3函式(在objc-class.mm中)。具體是把receiver, selector, class三個引數傳給$0,$1,r11,然後再去呼叫lookupMethodAndLoadCache3方法。最後會將 IMP 返回(從 r11 挪到 rax)。最後在 objc_msgSend中呼叫 IMP。

__class_lookupMethodAndLoadCache3函式也是個介面層(C編寫),此函式提供相應引數配置,實際功能在lookUpImpOrForward函式中。

再來看看lookUpImpOrForward函式實現

接下來一行行的解析。

runtimeLock.assertUnlocked(); 這個是加一個讀寫鎖,保證執行緒安全。

lookUpImpOrForward第5個新參是是否找到cache的布林量,如果傳入的是YES,那麼就會呼叫cache_getImp方法去找到快取裡面的IMP。

cache_getImp會把找到的IMP放在r11中。

呼叫realizeClass方法是申請class_rw_t的可讀寫空間。

_class_initialize是類初始化的過程。

runtimeLock.read();這裡加了一個讀鎖。因為在執行時中會動態的新增方法,為了保證執行緒安全,所以要加鎖。從這裡開始,下面會出現5處goto done的地方,和一處goto retry。

在done的地方,會完成IMP的查詢,於是可以開啟讀鎖。

緊接著GC selectors是為了忽略macOS中GC垃圾回收機制用到的方法,iOS則沒有這一步。如果忽略,則進行cache_fill,然後跳轉到goto done那裡去。

在cache_fill中還會去呼叫cache_fill_nolock函式,如果快取中的內容大於容量的 3/4就會擴充快取,使快取的大小翻倍。找到第一個空的 bucket_t,以 (SEL, IMP)的形式填充進去。

如果不忽略,則再次嘗試從類的cache中獲取IMP,如果獲取到,然後也會跳轉到goto done去。

如果在cache快取中獲取失敗,則再去類方法列表裡面進行查詢。找到後跳轉到goto done。

如果以上嘗試都失敗了,接下來就會迴圈嘗試父類的快取和方法列表。一直找到NSObject為止。因為NSObject的superclass為nil,才跳出迴圈。

如果在父類中找到了該方法method的IMP,接下來就應該把這個方法cache回自己的快取中。fill完之後跳轉goto done語句。

如果沒有在父類的cache中找到IMP,繼續在父類的方法列表裡面查詢。如果找到,跳轉goto done語句。

這裡可以解析一下method的查詢過程。在getMethodNoSuper_nolock方法中,會遍歷一次methodList連結串列,從begin一直遍歷到end。遍歷過程中會呼叫search_method_list函式。

在search_method_list函式中,會去判斷當前methodList是否有序,如果有序,會呼叫findMethodInSortedMethodList方法,這個方法裡面的實現是一個二分搜尋,具體程式碼就不貼了。如果非有序,就呼叫線性的傻瓜式遍歷搜尋。

如果父類找到NSObject還沒有找到,那麼就會開始嘗試_class_resolveMethod方法。注意,這些需要開啟讀鎖,因為開發者可能會在這裡動態增加方法實現,所以不需要快取結果。此處雖然鎖被開啟,可能會出現執行緒問題,所以在執行完_class_resolveMethod方法之後,會goto retry,重新執行一遍之前查詢的過程。

這個函式首先判斷是否是meta-class類,如果不是元類,就執行_class_resolveInstanceMethod,如果是元類,執行_class_resolveClassMethod。這裡有一個lookUpImpOrNil的函式呼叫。

在這個函式實現中,還會去呼叫lookUpImpOrForward去查詢有沒有傳入的sel的實現,但是返回值還會返回nil。在imp == _objc_msgForward_impcache會返回nil。_objc_msgForward_impcache是一個標記,這個標記用來表示在父類的快取中停止繼續查詢。

再回到_class_resolveMethod的實現中,如果lookUpImpOrNil返回nil,就代表在父類中的快取中找到,於是需要再呼叫一次_class_resolveInstanceMethod方法。保證給sel新增上了對應的IMP。

回到lookUpImpOrForward方法中,如果也沒有找到IMP的實現,那麼method resolver也沒用了,只能進入訊息轉發階段。進入這個階段之前,imp變成_objc_msgForward_impcache。最後再加入快取中。

三. 訊息轉發Message Forwarding階段

到了轉發階段,會呼叫id _objc_msgForward(id self, SEL _cmd,…)方法。在objc-msg-x86_64.s中有其彙編的實現。

在執行_objc_msgForward之後會呼叫__objc_forward_handler函式。

在最新的Objc2.0中會有一個objc_defaultForwardHandler,看原始碼實現我們可以看到熟悉的語句。當我們給一個物件傳送一個沒有實現的方法的時候,如果其父類也沒有這個方法,則會崩潰,報錯資訊類似於這樣:unrecognized selector sent to instance,然後接著會跳出一些堆疊資訊。這些資訊就是從這裡而來。

要設定轉發只要重寫_objc_forward_handler方法即可。在objc_setForwardHandler方法中,可以設定ForwardHandler。

但是當你想要弄清objc_setForwardHandler呼叫棧的情況的時候,你會發現列印不出來入口。因為蘋果在這裡做了點手腳。關於objc_setForwardHandler的呼叫,以及之後的訊息轉發呼叫棧的問題,需要用到逆向的知識。推薦大家看這兩篇文章就會明白其中的原理。

Objective-C 訊息傳送與轉發機制原理
Hmmm, What’s that Selector?

還是回到訊息轉發上面來。當前的SEL無法找到相應的IMP的時候,開發者可以通過重寫- (id)forwardingTargetForSelector:(SEL)aSelector方法來“偷樑換柱”,把訊息的接受者換成一個可以處理該訊息的物件。

當然也可以替換類方法,那就要重寫 + (id)forwardingTargetForSelector:(SEL)aSelector方法,返回值是一個類物件。

這一步是替訊息找備援接收者,如果這一步返回的是nil,那麼補救措施就完全的失效了,Runtime系統會向物件傳送methodSignatureForSelector:訊息,並取到返回的方法簽名用於生成NSInvocation物件。為接下來的完整的訊息轉發生成一個 NSMethodSignature物件。NSMethodSignature 物件會被包裝成 NSInvocation 物件,forwardInvocation: 方法裡就可以對 NSInvocation 進行處理了。

接下來未識別的方法崩潰之前,系統會做一次完整的訊息轉發。

我們只需要重寫下面這個方法,就可以自定義我們自己的轉發邏輯了。

實現此方法之後,若發現某呼叫不應由本類處理,則會呼叫超類的同名方法。如此,繼承體系中的每個類都有機會處理該方法呼叫的請求,一直到NSObject根類。如果到NSObject也不能處理該條訊息,那麼就是再無挽救措施了,只能丟擲“doesNotRecognizeSelector”異常了。

至此,訊息傳送和轉發的過程都清楚明白了。

141194012-e96387802506ea96

四. forwardInvocation的例子

151194012-8c9df13005b38aa5

這裡我想舉一個好玩的例子,來說明一下forwardInvocation的使用方法。

這個例子中我們會利用runtime訊息轉發機制建立一個動態代理。利用這個動態代理來轉發訊息。這裡我們會用到兩個基類的另外一個神祕的類,NSProxy。

NSProxy類和NSObject同為OC裡面的基類,但是NSProxy類是一種抽象的基類,無法直接例項化,可用於實現代理模式。它通過實現一組經過簡化的方法,代替目標物件捕捉和處理所有的訊息。NSProxy類也同樣實現了NSObject的協議宣告的方法,而且它有兩個必須實現的方法。

另外還需要說明的是,NSProxy類的子類必須宣告並實現至少一個init方法,這樣才能符合OC中建立和初始化物件的慣例。Foundation框架裡面也含有多個NSProxy類的具體實現類。

  • NSDistantObject類:定義其他應用程式或執行緒中物件的代理類。
  • NSProtocolChecker類:定義物件,使用這話物件可以限定哪些訊息能夠傳送給另外一個物件。

接下來就來看看下面這個好玩的例子。

定義一個student類,裡面隨便給兩個方法。

在兩個方法實現裡面增加log資訊,這是為了一會列印的時候方便知道呼叫了哪個方法。

定義一個AspectProxy類,這個類專門用來轉發訊息的。

接著我們定義一個代理協議

最後還需要一個遵守協議的類

在這個遵循代理類裡面我們只實現協議裡面的兩個方法。

寫出測試程式碼

這裡有3個例子。裡面會分別輸出什麼呢?

例子1中會輸出3句話。呼叫Student物件的代理中的study:andRead:方法,會使該代理呼叫AuditingInvoker物件中的preInvoker:方法、真正目標(Student物件)中的study:andRead:方法,以及AuditingInvoker物件中的postInvoker:方法。一個方法的呼叫,呼叫起了3個方法。原因是study:andRead:方法是通過Student物件的代理註冊的;

例子2就只會輸出1句話。呼叫Student物件代理中的study::方法,因為該方法還未通過這個代理註冊,所以程式僅會將呼叫該方法的訊息轉發給Student物件,而不會呼叫AuditorInvoker方法。

例子3又會輸出3句話了。因為study::通過這個代理進行了註冊,然後程式再次呼叫它,在這次呼叫過程中,程式會呼叫AuditingInvoker物件中的AOP方法和真正目標(Student物件)中的study::方法。

這個例子就實現了一個簡單的AOP(Aspect Oriented Programming)面向切面程式設計。我們把一切功能”切”出去,與其他部分分開,這樣可以提高程式的模組化程度。AOP能解耦也能動態組裝,可以通過預編譯方式和執行期動態代理實現在不修改原始碼的情況下給程式動態統一新增功能。比如上面的例子三,我們通過把方法註冊到動態代理類中,於是就實現了該類也能處理方法的功能。

五. 入院考試

下面的程式碼會?Compile Error / Runtime Crash / NSLog…?

這道有兩處難點,難點一是給NSObject增加了一個分類,分類宣告的是一個加號的類方法,而實現中是一個減號的例項方法。在main中去NSObject去呼叫了這個foo方法,會編譯錯誤,還是會Crash呢?

難點二是會輸出什麼內容呢?

先來看難點一,這裡會牽扯到Category的知識。推薦文章還是美團的這篇經典的深入理解Objective-C:Category

OC在初始化的時候,會去載入map_images,map_images最終會呼叫objc-runtime-new.mm裡面的_read_images方法。_read_images方法裡面會去初始化記憶體中的map, 這個時候將會load所有的類,協議還有Category。NSOBject的+load方法就是這個時候呼叫的。

在這個載入中,for迴圈中會反覆呼叫_getObjc2CategoryList
方法,這個方法的具體實現是:

最後一個引數__objc_catlist就是編譯器剛剛生成的category陣列。

載入完所有的category之後,就開始處理這些類別。大體思路還是分為2類來分開處理。

第一類是例項方法

第二類是類方法。

處理完之後的結果
1)、把category的例項方法、協議以及屬性新增到類上
2)、把category的類方法和協議新增到類的metaclass上

這兩種情況裡面的處理方式都差不多,先去呼叫addUnattachedCategoryForClass函式,申請記憶體,分配空間。remethodizeClass這個方法裡面會呼叫attachCategories方法。

attachCategories方法程式碼就不貼了,有興趣的可以自己去看看。這個方法裡面會用頭插法,把新加的方法從頭插入方法連結串列中。並且最後還會flushCaches。

這也就是為什麼我們可以在Category裡面覆蓋原有的方法的原因,因為頭插法,新的方法在連結串列的前面,會優先被遍歷到。

以上就是Category載入時候的流程。

再回到這道題目上面來,在載入NSObject的Category中,在編譯期會提示我們沒有實現+(void)foo的方法,因為在.m檔案中並沒有找到+的方法,而是一個-號的方法,所以會提示。

但是在實際載入Category的時候,會把-(void)foo載入進去,由於是例項方法,所以會放在NSObject的例項方法連結串列裡面。

根據第二章分析的objc_msgSend原始碼實現,我們可以知道:

在呼叫[NSObject foo]的時候,會先在NSObject的meta-class中去查詢foo方法的IMP,未找到,繼續在superClass中去查詢,NSObject的meta-class的superClass就是本身NSObject,於是又回到NSObject的類方法中查詢foo方法,於是乎找到了,執行foo方法,輸出

在呼叫[[NSObject new] foo]的時候,會先生成一個NSObject的物件,用這個NSObject例項物件再去呼叫foo方法的時候,會去NSObject的類方法裡面去查詢,找到,於是也會輸出

所以上面這題,不會Compile Error ,更不會 Runtime Crash ,會輸出兩個相同的結果。

六. Runtime中的優化

171194012-b850ce3bfc5abcd2

關於Runtime系統中,有3種地方進行了優化。

  • 1.方法列表的快取
  • 2.虛擬函式表vTable
  • 3.dyld共享快取
1.方法列表的快取

在訊息傳送過程中,查詢IMP的過程,會優先查詢快取。這個快取會儲存最近使用過的方法都快取起來。這個cache和CPU裡面的cache的工作方式有點類似。原理是呼叫的方法有可能經常會被呼叫。如果沒有這個快取,直接去類方法的方法連結串列裡面去查詢,查詢效率實在太低。所以查詢IMP會優先搜尋飯方法快取,如果沒有找到,接著會在虛擬函式表中尋找IMP。如果找到了,就會把這個IMP儲存到快取中備用。

基於這個設計,使Runtime系統能能夠執行快速高效的方法查詢操作。

2.虛擬函式表

虛擬函式表也稱為分派表,是程式語言中常用的動態繫結支援機制。在OC的Runtime執行時系統庫實現了一種自定義的虛擬函式表分派機制。這個表是專門用來提高效能和靈活性的。這個虛擬函式表是用來儲存IMP型別的陣列。每個object-class都有這樣一個指向虛擬函式表的指標。

3.dyld共享快取

在我們的程式中,一定會有很多自定義類,而這些類中,很多SEL是重名的,比如alloc,init等等。Runtime系統需要為每一個方法給定一個SEL指標,然後為每次呼叫個各個方法更新後設資料,以獲取唯一值。這個過程是在應用程式啟動的時候完成。為了提高這一部分的執行效率,Runtime會通過dyld共享快取實現選擇器的唯一性。

dyld是一種系統服務,用於定位和載入動態庫。它含有共享快取,能夠使多個程式共用這些動態庫。dyld共享快取中含有一個選擇器表,從而能使執行時系統能夠通過使用快取訪問共享庫和自定義類的選擇器。

關於dyld的知識可以看看這篇文章dyld: Dynamic Linking On OS X

未完待續,請大家多多指教。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

神經病院 Objective-C Runtime 住院第二天—訊息傳送與轉發 神經病院 Objective-C Runtime 住院第二天—訊息傳送與轉發

相關文章