iOS RunTime
本文詳細整理了 Cocoa 的 Runtime 系統的知識,它使得 Objective-C 如虎添翼,具備了靈活的動態特性,使這門古老的語言煥發生機。主要內容如下:
- 引言
- 簡介
- 與 Runtime 互動
- Runtime 基礎資料結構
- 訊息
- 動態方法解析
- 訊息轉發
- 健壯的例項變數 (Non Fragile ivars)
- Objective-C Associated Objects
- Method Swizzling
- 總結
引言
曾經覺得Objc特別方便上手,面對著 Cocoa 中大量 API,只知道簡單的查文件和呼叫。還記得初學 Objective-C 時把 [receiver message]
當成簡單的方法呼叫,而無視了“傳送訊息”
[receiver message]
會被編譯器轉化為:
1 |
objc_msgSend(receiver, selector) |
如果訊息含有引數,則為:
1 |
objc_msgSend(receiver, selector, arg1, arg2, ...) |
如果訊息的接收者能夠找到對應的 selector
,那麼就相當於直接執行了接收者這個物件的特定方法;否則,訊息要麼被轉發,或是臨時向接收者動態新增這個 selector
對應的實現內容,要麼就乾脆玩完崩潰掉。
現在可以看出 [receiver message]
message
這條訊息,而 receive
將要如何響應這條訊息,那就要看執行時發生的情況來決定了。
Objective-C 的 Runtime 鑄就了它動態語言的特性,這些深層次的知識雖然平時寫程式碼用的少一些,但是卻是每個 Objc 程式設計師需要了解的。
簡介
因為Objc是一門動態語言,所以它總是想辦法把一些決定工作從編譯連線推遲到執行時。也就是說只有編譯器是不夠的,還需要一個執行時系統 (runtime system) 來執行編譯後的程式碼。這就是 Objective-C Runtime 系統存在的意義,它是整個 Objc 執行框架的一塊基石。
Runtime其實有兩個版本: “modern” 和 “legacy”。我們現在用的 Objective-C 2.0 採用的是現行 (Modern) 版的 Runtime 系統,只能執行在 iOS 和 macOS 10.5 之後的 64 位程式中。而 maxOS 較老的32位程式仍採用 Objective-C 1 中的(早期)Legacy 版本的 Runtime 系統。這兩個版本最大的區別在於當你更改一個類的例項變數的佈局時,在早期版本中你需要重新編譯它的子類,而現行版就不需要。
Runtime 基本是用 C 和彙編寫的,可見蘋果為了動態系統的高效而作出的努力。你可以在這裡下到蘋果維護的開原始碼。蘋果和GNU各自維護一個開源的 runtime 版本,這兩個版本之間都在努力的保持一致。
與 Runtime 互動
Objc 從三種不同的層級上與 Runtime 系統進行互動,分別是通過 Objective-C 原始碼,通過 Foundation 框架的NSObject
類定義的方法,通過對 runtime 函式的直接呼叫。
Objective-C 原始碼
大部分情況下你就只管寫你的Objc程式碼就行,runtime 系統自動在幕後辛勤勞作著。
還記得引言中舉的例子吧,訊息的執行會使用到一些編譯器為實現動態語言特性而建立的資料結構和函式,Objc中的類、方法和協議等在 runtime 中都由一些資料結構來定義,這些內容在後面會講到。(比如 objc_msgSend
函式及其引數列表中的 id
和 SEL
都是啥)
NSObject 的方法
Cocoa 中大多數類都繼承於 NSObject
類,也就自然繼承了它的方法。最特殊的例外是 NSProxy
,它是個抽象超類,它實現了一些訊息轉發有關的方法,可以通過繼承它來實現一個其他類的替身類或是虛擬出一個不存在的類,說白了就是領導把自己展現給大家風光無限,但是把活兒都交給幕後小弟去幹。
有的NSObject
中的方法起到了抽象介面的作用,比如description
方法需要你過載它併為你定義的類提供描述內容。NSObject
還有些方法能在執行時獲得類的資訊,並檢查一些特性,比如class
返回物件的類;isKindOfClass:
和isMemberOfClass:
則檢查物件是否在指定的類繼承體系中;respondsToSelector:
檢查物件能否響應指定的訊息;conformsToProtocol:
檢查物件是否實現了指定協議類的方法;methodForSelector:
則返回指定方法實現的地址。
Runtime 的函式
Runtime 系統是一個由一系列函式和資料結構組成,具有公共介面的動態共享庫。標頭檔案存放於/usr/include/objc
目錄下。許多函式允許你用純C程式碼來重複實現 Objc 中同樣的功能。雖然有一些方法構成了NSObject
類的基礎,但是你在寫 Objc 程式碼時一般不會直接用到這些函式的,除非是寫一些 Objc 與其他語言的橋接或是底層的debug工作。在 Objective-C
Runtime Reference 中有對 Runtime 函式的詳細文件。
Runtime 基礎資料結構
還記得引言中的objc_msgSend:
方法吧,它的真身是這樣的:
1 |
id objc_msgSend ( id self, SEL op, ... ); |
下面將會逐漸展開介紹一些術語,其實它們都對應著資料結構。熟悉 Objective-C 類的記憶體模型或看過相關原始碼的可以直接跳過。
SEL
objc_msgSend
函式第二個引數型別為SEL
,它是selector
在Objc中的表示型別(Swift中是Selector
類)。selector
是方法選擇器,可以理解為區分方法的 ID,而這個 ID 的資料結構是SEL
:
1 |
typedef struct objc_selector *SEL; |
其實它就是個對映到方法的C字串,你可以用 Objc 編譯器命令 @selector()
或者 Runtime 系統的 sel_registerName
函式來獲得一個 SEL
型別的方法選擇器。
不同類中相同名字的方法所對應的方法選擇器是相同的,即使方法名字相同而變數型別不同也會導致它們具有相同的方法選擇器,於是 Objc 中方法命名有時會帶上引數型別(NSNumber
一堆抽象工廠方法拿走不謝),Cocoa 中有好多長長的方法哦。
id
objc_msgSend
第一個引數型別為id
,大家對它都不陌生,它是一個指向類例項的指標:
1 |
typedef struct objc_object *id; |
那objc_object
又是啥呢,參考 objc-private.h 檔案部分原始碼:
1 |
struct objc_object { |
objc_object
結構體包含一個 isa
指標,型別為 isa_t
聯合體。根據 isa
就可以順藤摸瓜找到物件所屬的類。isa
這裡還涉及到 tagged pointer 等概念。因為 isa_t
使用 union
實現,所以可能表示多種形態,既可以當成是指標,也可以儲存標誌位。有關 isa_t
聯合體的更多內容可以檢視 Objective-C
引用計數原理。
PS: isa
指標不總是指向例項物件所屬的類,不能依靠它來確定型別,而是應該用 class
方法來確定例項物件的類。因為KVO的實現機理就是將被觀察物件的 isa
指標指向一箇中間類而不是真實的類,這是一種叫做 isa-swizzling 的技術,詳見官方文件
Class
Class
其實是一個指向 objc_class
結構體的指標:
1 |
typedef struct objc_class *Class; |
而 objc_class
包含很多方法,主要都為圍繞它的幾個成員做文章:
1 |
struct objc_class : objc_object { |
objc_class
繼承於 objc_object
,也就是說一個 ObjC 類本身同時也是一個物件,為了處理類和物件的關係,runtime 庫建立了一種叫做元類 (Meta Class) 的東西,類物件所屬型別就叫做元類,它用來表述類物件本身所具備的元資料。類方法就定義於此處,因為這些方法可以理解成類物件的例項方法。每個類僅有一個類物件,而每個類物件僅有一個與之相關的元類。當你發出一個類似 [NSObject
alloc]
的訊息時,你事實上是把這個訊息發給了一個類物件 (Class Object) ,這個類物件必須是一個元類的例項,而這個元類同時也是一個根元類 (root meta class) 的例項。所有的元類最終都指向根元類為其超類。所有的元類的方法列表都有能夠響應訊息的類方法。所以當 [NSObject alloc]
這條訊息發給類物件的時候,objc_msgSend()
會去它的元類裡面去查詢能夠響應訊息的方法,如果找到了,然後對這個類物件執行方法呼叫。
上圖實線是 superclass
指標,虛線是isa
指標。 有趣的是根元類的超類是 NSObject
,而 isa
指向了自己,而 NSObject
的超類為 nil
,也就是它沒有超類。
可以看到執行時一個類還關聯了它的超類指標,類名,成員變數,方法,快取,還有附屬的協議。
cache_t
1 |
struct cache_t { |
_buckets
儲存 IMP
,_mask
和 _occupied
對應 vtable
。
cache
為方法呼叫的效能進行優化,通俗地講,每當例項物件接收到一個訊息時,它不會直接在isa
指向的類的方法列表中遍歷查詢能夠響應訊息的方法,因為這樣效率太低了,而是優先在 cache
中查詢。Runtime 系統會把被呼叫的方法存到 cache
中(理論上講一個方法如果被呼叫,那麼它有可能今後還會被呼叫),下次查詢的時候效率更高。
bucket_t
中儲存了指標與 IMP 的鍵值對:
1 |
struct bucket_t { |
有關快取的實現細節,可以檢視 objc-cache.mm 檔案。
class_data_bits_t
objc_class
中最複雜的是 bits
,class_data_bits_t
結構體所包含的資訊太多了,主要包含 class_rw_t
, retain/release/autorelease/retainCount
和 alloc
等資訊,很多存取方法也是圍繞它展開。檢視
objc-runtime-new.h 原始碼如下:
1 |
struct class_data_bits_t { |
注意 objc_class
的 data
方法直接將 class_data_bits_t
的data
方法返回,最終是返回 class_rw_t
,保了好幾層。
可以看到 class_data_bits_t
裡又包了一個 bits
,這個指標跟不同的 FAST_
字首的 flag 掩碼做按位與操作,就可以獲取不同的資料。bits
在記憶體中每個位的含義有三種排列順序:
32 位:
0 | 1 | 2 - 31 |
---|---|---|
FAST_IS_SWIFT | FAST_HAS_DEFAULT_RR | FAST_DATA_MASK |
64 位相容版:
0 | 1 | 2 | 3 - 46 | 47 - 63 |
---|---|---|---|---|
FAST_IS_SWIFT | FAST_HAS_DEFAULT_RR | FAST_REQUIRES_RAW_ISA | FAST_DATA_MASK | 空閒 |
64 位不相容版:
0 | 1 | 2 | 3 - 46 | 47 |
---|---|---|---|---|
FAST_IS_SWIFT | FAST_REQUIRES_RAW_ISA | FAST_HAS_CXX_DTOR | FAST_DATA_MASK | FAST_HAS_CXX_CTOR |
48 | 49 | 50 | 51 | 52 - 63 |
FAST_HAS_DEFAULT_AWZ | FAST_HAS_DEFAULT_RR | FAST_ALLOC | FAST_SHIFTED_SIZE_SHIFT | 空閒 |
其中 64 位不相容版每個巨集對應的含義如下:
1 |
// class is a Swift class |
這裡面除了 FAST_DATA_MASK
是用一段空間儲存資料外,其他巨集都是隻用 1 bit 儲存 bool 值。class_data_bits_t
提供了三個方法用於位操作:getBit
,setBits
和 clearBits
,對應到儲存 bool 值的掩碼也有封裝函式,比如:
1 |
bool isSwift() { |
重頭戲在於最大的那塊儲存區域–FAST_DATA_MASK
,它其實就儲存了指向 class_rw_t
的指標:
1 |
class_rw_t* data() { |
對這片記憶體讀寫處於併發環境,但並不需要加鎖,因為會通過對一些狀態(realization or construction)判斷來決定是否可讀寫。
class_data_bits_t
甚至還包含了一些對 class_rw_t
中 flags
成員存取的封裝函式。
class_ro_t
objc_class
包含了 class_data_bits_t
,class_data_bits_t
儲存了 class_rw_t
的指標,而 class_rw_t
結構體又包含 class_ro_t
的指標。
class_ro_t
中的 method_list_t
, ivar_list_t
, property_list_t
結構體都繼承自 entsize_list_tt<Element, List, FlagMask>
。結構為 xxx_list_t
的列表元素結構為 xxx_t
,命名很工整。protocol_list_t
與前三個不同,它儲存的是 protocol_t
*
指標列表,實現比較簡單。
entsize_list_tt
實現了 non-fragile 特性的陣列結構。假如蘋果在新版本的 SDK 中向 NSObject
類增加了一些內容,NSObject
的佔據的記憶體區域會擴大,開發者以前編譯出的二進位制中的子類就會與新的 NSObject
記憶體有重疊部分。於是在編譯期會給 instanceStart
和 instanceSize
賦值,確定好編譯時每個類的所佔記憶體區域起始偏移量和大小,這樣只需將子類與基類的這兩個變數作對比即可知道子類是否與基類有重疊,如果有,也可知道子類需要挪多少偏移量。更多細節可以參考後面的章節
Non Fragile ivars。
1 |
struct class_ro_t { |
class_ro_t->flags
儲存了很多在編譯時期就確定的類的資訊,也是 ABI 的一部分。下面這些 RO_
字首的巨集標記了 flags
一些位置的含義。其中後三個並不需要被編譯器賦值,是預留給執行時載入和初始化類的標誌位,涉及到與 class_rw_t
的型別強轉。執行時會用到它做判斷,後面會講解。
1 |
|
class_rw_t
class_rw_t
提供了執行時對類拓展的能力,而 class_ro_t
儲存的大多是類在編譯時就已經確定的資訊。二者都存有類的方法、屬性(成員變數)、協議等資訊,不過儲存它們的列表實現方式不同。
class_rw_t
中使用的 method_array_t
, property_array_t
, protocol_array_t
都繼承自 list_array_tt<Element, List>
, 它可以不斷擴張,因為它可以儲存 list 指標,內容有三種:
- 空
- 一個
entsize_list_tt
指標 entsize_list_tt
指標陣列
class_rw_t
的內容是可以在執行時被動態修改的,可以說執行時對類的拓展大都是儲存在這裡的。
1 |
struct class_rw_t { |
class_rw_t->flags
儲存的值並不是編輯器設定的,其中有些值可能將來會作為 ABI 的一部分。下面這些 RW_
字首的巨集標記了 flags
一些位置的含義。這些 bool 值標記了類的一些狀態,涉及到宣告週期和記憶體管理。有些位目前甚至還空著。
1 |
|
demangledName
是計算機語言用於解決實體名稱唯一性的一種方法,做法是向名稱中新增一些型別資訊,用於從編譯器中向連結器傳遞更多語義資訊。
realizeClass
在某個類初始化之前,objc_class->data()
返回的指標指向的其實是個 class_ro_t
結構體。等到 static Class realizeClass(Class cls)
靜態方法在類第一次初始化時被呼叫,它會開闢 class_rw_t
的空間,並將 class_ro_t
指標賦值給 class_rw_t->ro
。這種偷天換日的行為是靠 RO_FUTURE
標誌位來記錄的:
1 |
ro = (const class_ro_t *)cls->data(); |
注意之前 RO 和 RW flags 巨集標記的一個細節:
1 |
|
也就是說 ro = (const class_ro_t *)cls->data();
這種強轉對於接下來的 ro->flags & RO_FUTURE
操作完全是 OK 的,兩種結構體第一個成員都是 flags
,RO_FUTURE
與 RW_FUTURE
值一樣的。
經過 realizeClass
函式處理的類才是『真正的』類,呼叫它時不能對類做寫操作。
Category
Category
為現有的類提供了拓展性,它是 category_t
結構體的指標。
1 |
typedef struct category_t *Category; |
category_t
儲存了類別中可以拓展的例項方法、類方法、協議、例項屬性和類屬性。類屬性是 Objective-C 2016 年新增的特性,沾 Swift 的光。所以 category_t
中有些成員變數是為了相容 Swift 的特性,Objective-C 暫沒提供介面,僅做了底層資料結構上的相容。
1 |
struct category_t { |
在 App 啟動載入映象檔案時,會在 _read_images