絕大多數 iOS 開發者在學習 runtime 時都閱讀過 runtime.h 檔案中的這段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
struct objc_class { Class isa OBJC_ISA_AVAILABILITY; #if !__OBJC2__ Class super_class OBJC2_UNAVAILABLE; const char *name OBJC2_UNAVAILABLE; long version OBJC2_UNAVAILABLE; long info OBJC2_UNAVAILABLE; long instance_size OBJC2_UNAVAILABLE; struct objc_ivar_list *ivars OBJC2_UNAVAILABLE; struct objc_method_list **methodLists OBJC2_UNAVAILABLE; struct objc_cache *cache OBJC2_UNAVAILABLE; struct objc_protocol_list *protocols OBJC2_UNAVAILABLE; #endif } OBJC2_UNAVAILABLE; |
可以看到其中儲存了類的例項變數,方法列表等資訊。
不知道有多少讀者思考過 OBJC2_UNAVAILABLE
意味著什麼。其實早在 2006 年,蘋果在 WWDC 大會上就釋出了 Objective-C 2.0,其中的改動包括 Max OS X 平臺上的垃圾回收機制(現已廢棄),runtime 效能優化等。
這意味著上述程式碼,以及任何帶有 OBJC2_UNAVAILABLE
標記的內容,都已經在 2006 年就永遠的告別了我們,只停留在歷史的文件中。
Category 的原理
雖然上述程式碼已經過時,但仍具備一定的參考意義,比如 methodLists
作為一個二級指標,其中每個元素都是一個陣列,陣列中的每個元素則是一個方法。
接下來就介紹一下 category 的工作原理,在美團的技術部落格 深入理解Objective-C:Category 中已經有了非常詳細的解釋,然而可能由於時間問題,其中的不少內容已經過時,我根據目前最新的版本(objc-680) 做一些簡單的分析,為了便於閱讀,在不影響程式碼邏輯的前提下有可能刪除部分無關緊要的內容。
概述
首先 runtime 依賴於 dyld 動態載入,在 objc-os.mm 檔案中可以找到入口,它的呼叫棧簡單整理如下:
1 2 3 4 |
void _objc_init(void) └──const char *map_2_images(...) └──const char *map_images_nolock(...) └──void _read_images(header_info **hList, uint32_t hCount) |
以上四個方法可以理解為 runtime 的初始化過程,我們暫且不深究。在 _read_images
方法中有如下程式碼:
1 2 3 4 5 6 7 |
if (cat->classMethods || cat->protocols /* || cat->classProperties */) { addUnattachedCategoryForClass(cat, cls->ISA(), hi); if (cls->ISA()->isRealized()) { remethodizeClass(cls->ISA()); } } |
根據註釋可見蘋果曾經計劃利用 category 來新增屬性。在 addUnattachedCategoryForClass
方法中會找到當前類的所有 category,然後在 remethodizeClass
真正的去做處理。不過到目前為止還沒有接觸到相關的 category 處理,我們繼續沿著呼叫棧向下走:
1 2 3 |
void _read_images(header_info **hList, uint32_t hCount) └──static void remethodizeClass(Class cls) └──static void attachCategories(Class cls, category_list *cats, bool flush_caches) |
這裡的 attachCategories
就是處理 category 的核心所在,不過在閱讀這段程式碼之前,我們有必要先熟悉一下相關的資料結構。
Category 相關的資料結構
首先來了解一下一個 Category 是如何儲存的,在 objc-runtime-new.h 中可以看到如下定義,我只列出了其中成員變數:
1 2 3 4 5 6 7 8 |
struct category_t { const char *name; classref_t cls; struct method_list_t *instanceMethods; struct method_list_t *classMethods; struct protocol_list_t *protocols; struct property_list_t *instanceProperties; }; |
可見一個 category 持有了一個 method_list_t
型別的陣列,method_list_t
又繼承自 entsize_list_tt
,這是一種泛型容器:
1 2 3 4 5 6 7 8 9 10 |
struct method_list_t : entsize_list_tt { // 成員變數和方法 }; template struct entsize_list_tt { uint32_t entsizeAndFlags; uint32_t count; Element first; }; |
這裡的 entsize_list_tt
可以理解為一個容器,擁有自己的迭代器用於遍歷所有元素。 Element
表示元素型別,List
用於指定容器型別,最後一個引數為標記位。
雖然這段程式碼實現比較複雜,但仍可瞭解到 method_list_t
是一個儲存 method_t
型別元素的容器。method_t
結構體的定義如下:
1 2 3 4 5 |
struct method_t { SEL name; const char *types; IMP imp; }; |
最後,我們還有一個結構體 category_list
用來儲存所有的 category,它的定義如下:
1 2 3 4 5 6 7 8 9 |
struct locstamped_category_list_t { uint32_t count; locstamped_category_t list[0]; }; struct locstamped_category_t { category_t *cat; struct header_info *hi; }; typedef locstamped_category_list_t category_list; |
除了標記儲存的 category 的數量外,locstamped_category_list_t
結構體還宣告瞭一個長度為零的陣列,這其實是 C99 中的一種寫法,允許我們在執行期動態的申請記憶體。
以上就是相關的資料結構,只要瞭解到這個程度就可以繼續讀原始碼了。
處理 Category
對 Category 中方法的解析並不複雜,首先來看一下 attachCategories
的簡化版程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
static void attachCategories(Class cls, category_list *cats, bool flush_caches) { if (!cats) return; bool isMeta = cls->isMetaClass(); method_list_t **mlists = (method_list_t **)malloc(cats->count * sizeof(*mlists)); // Count backwards through cats to get newest categories first int mcount = 0; int i = cats->count; while (i--) { auto& entry = cats->list[i]; method_list_t *mlist = entry.cat->methodsForMeta(isMeta); if (mlist) { mlists[mcount++] = mlist; } } auto rw = cls->data(); prepareMethodLists(cls, mlists, mcount, NO, fromBundle); rw->methods.attachLists(mlists, mcount); free(mlists); if (flush_caches && mcount > 0) flushCaches(cls); } |
首先,通過 while 迴圈,我們遍歷所有的 category,也就是引數 cats
中的 list
屬性。對於每一個 category,得到它的方法列表 mlist
並存入 mlists
中。
換句話說,我們將所有 category 中的方法拼接到了一個大的二維陣列中,陣列的每一個元素都是裝有一個 category 所有方法的容器。這句話比較繞,但你可以把 mlists
理解為文章開頭所說,舊版本的 objc_method_list **methodLists
。
在 while 迴圈外,我們得到了拼接成的方法,此時需要與類原來的方法合併:
1 2 |
auto rw = cls->data(); rw->methods.attachLists(mlists, mcount); |
這兩行程式碼讀不懂是必然的,因為在 Objective-C 2.0 時代,物件的記憶體佈局已經發生了一些變化。我們需要先了解物件的佈局模型才能理解這段程式碼。
Objective-C 2.0 物件佈局模型
objc_class
相信讀到這裡的大部分讀者都學習過文章開頭所說的物件佈局模型,因此在這一部分,我們採用類比的方法,來看看 Objective-C 2.0 下發生了哪些改變。
首先,Class
和 id
指標的定義並沒有發生改變,他們一個指向類對應的結構體,一個指向物件對應的結構體:
1 2 3 |
// objc.h typedef struct objc_class *Class; typedef struct objc_object *id; |
比較有意思的一點是,objc_class
結構體是繼承自 objc_object
的:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct objc_object { Class isa OBJC_ISA_AVAILABILITY; }; struct objc_class : objc_object { Class superclass; cache_t cache; // formerly cache pointer and vtable class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags class_rw_t *data() { return bits.data(); } }; |
這一點也很容易理解,早在 Objective-C 1.0 時代,我們就知道一個物件的結構體只有 isa
指標,指向它所屬的類。而類的結構體也有 isa
指標指向它的元類。因此讓類結構體繼承自物件結構體就很容易理解了。
可見 Objective-C 1.0 的佈局模型中,cache
和 super_class
被原封不動的移過來了,而剩下的屬性則似乎消失不見。取而代之的是一個 bits
屬性,以及 data()
方法,這個方法呼叫的其實是 bits
屬性的 data()
方法,並返回了一個 class_rw_t
型別的結構體指標。
class_data_bits_t
以下是簡化版 class_data_bits_t
結構體的定義:
1 2 3 4 5 6 7 |
struct class_data_bits_t { uintptr_t bits; public: class_rw_t* data() { return (class_rw_t *)(bits & FAST_DATA_MASK); } } |
可見這個結構體只有一個 64 位的 bits
成員,儲存了一個指向 class_rw_t
結構體的指標和三個標誌位。它實際上由三部分組成。首先由於 Mac OS X 只使用 47 位記憶體地址,所以前 17 位空餘出來,提供給 retain/release 和
alloc/dealloc
方法使用,做一些優化。其次,由於記憶體對齊,指標地址的後三位都是 0,因此可以用來做標誌位:
1 2 3 4 5 6 7 8 9 |
// class is a Swift class #define FAST_IS_SWIFT (1UL<<0) // class or superclass has default retain/release/autorelease/retainCount/ // _tryRetain/_isDeallocating/retainWeakReference/allowsWeakReference #define FAST_HAS_DEFAULT_RR (1UL<<1) // class's instances requires raw isa #define FAST_REQUIRES_RAW_ISA (1UL<<2) // data pointer #define FAST_DATA_MASK 0x00007ffffffffff8UL |
如果計算一下就會發現,FAST_DATA_MASK
這個 16 進位制常量的二進位制表示恰好後三位為0,且長度為47位: 11111111111111111111111111111111111111111111000
,我們通過這個掩碼做按位與運算即可取出正確的指標地址。
引用 Draveness 在 深入解析 ObjC 中方法的結構 中的圖片做一個總結:
class_rw_t
bits
中包含了一個指向 class_rw_t
結構體的指標,它的定義如下:
1 2 3 4 5 6 7 8 9 10 |
struct class_rw_t { uint32_t flags; uint32_t version; const class_ro_t *ro; method_array_t methods; property_array_t properties; protocol_array_t protocols; } |
注意到有一個名字很類似的結構體 class_ro_t
,這裡的 ‘rw’ 和 ro’ 分別表示 ‘readwrite’ 和 ‘readonly’。因為 class_ro_t
儲存了一些由編譯器生成的常量。
These are emitted by the compiler and are part of the ABI.
正是由於 class_ro_t
中的兩個屬性 instanceStart
和 instanceSize
的存在,保證了 Objective-C2.0 的 ABI 穩定性。因為即使父類增加方法,子類也可以在執行時重新計算 ivar 的偏移量,從而避免重新編譯。
關於 ABI 穩定性的問題,本文不做贅述,讀者可以參考 Non Fragile ivars。
如果閱讀 class_ro_t
結構體的定義就會發現,舊版本實現中類結構體中的大部分成員變數現在都定義在 class_ro_t
和 class_rw_t
這兩個結構體中了。感興趣的讀者可以自行對比,本文不再贅述。
class_rw_t
結構體中還有一個 methods
成員變數,它的型別是 method_array_t
,繼承自 list_array_tt
。
list_array_tt
是一個泛型結構體,用於儲存一些後設資料,而它實際上是後設資料的二維陣列:
1 2 3 4 5 6 7 |
template { struct array_t { uint32_t count; List* lists[0]; }; } class method_array_t : public list_array_tt |
其中 Element
表示後設資料的型別,比如 method_t
,而 List
則表示用於儲存後設資料的一維陣列,比如 method_list_t
。
list_array_tt
有三種狀態:
- 自身為空,可以類比為
[[]]
- 它只有一個指標,指向一個後設資料的集合,可以類比為
[[1, 2]]
- 它有多個指標,指向多個後設資料的集合,可以類比為
[[1, 2], [3, 4]]
當一個類剛建立時,它可能處於狀態 1 或 2,但如果使用 class_addMethod
或者 category 來新增方法,就會進入狀態 3,而且一旦進入狀態 3 就再也不可能回到其他狀態,即使新增的方法後來又被移除掉。
方法合併
掌握了這些 runtime 的基礎只是以後就可以繼續鑽研剩下的 category 的程式碼了:
1 2 |
auto rw = cls->data(); rw->methods.attachLists(mlists, mcount); |
這是剛剛卡住的地方,現在來看,rw
是一個 class_rw_t
型別的結構體指標。根據 runtime 中的資料結構,它有一個 methods
結構體成員,並從父類繼承了 attachLists
方法,用來合併 category 中的方法:
1 2 3 4 5 6 7 8 9 |
void attachLists(List* const * addedLists, uint32_t addedCount) { if (addedCount == 0) return; uint32_t oldCount = array()->count; uint32_t newCount = oldCount + addedCount; setArray((array_t *)realloc(array(), array_t::byteSize(newCount))); array()->count = newCount; memmove(array()->lists + addedCount, array()->lists, oldCount * sizeof(array()->lists[0])); memcpy(array()->lists, addedLists, addedCount * sizeof(array()->lists[0])); } |
這段程式碼很簡單,其實就是先呼叫 realloc()
函式將原來的空間擴充,然後把原來的陣列複製到後面,最後再把新陣列複製到前面。
在實際程式碼中,比上面略複雜一些。因為為了提高效能,蘋果做了一些優化,比如當 List 處於第二種狀態(只有一個指標,指向一個後設資料的集合)時,其實並不需要在原地擴容空間,而是隻要重新申請一塊記憶體,並將最後一個位置留給原來的集合即可。
這樣只多花費了很少的記憶體空間,也就是原來二維陣列佔用的記憶體空間,但是 malloc()
的效能優勢會更加明顯,這其實是一個空間換時間的權衡問題。
需要注意的是,無論執行哪種邏輯,引數列表中的方法都會被新增到二維陣列的前面。而我們簡單的看一下 runtime 在查詢方法時的邏輯:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
static method_t *getMethodNoSuper_nolock(Class cls, SEL sel){ for (auto mlists = cls->data()->methods.beginLists(), end = cls->data()->methods.endLists(); mlists != end; ++mlists) { method_t *m = search_method_list(*mlists, sel); if (m) return m; } return nil; } static method_t *search_method_list(const method_list_t *mlist, SEL sel) { for (auto& meth : *mlist) { if (meth.name == sel) return &meth; } } |
可見搜尋的過程是按照從前向後的順序進行的,一旦找到了就會停止迴圈。因此 category 中定義的同名方法不會替換類中原有的方法,但是對原方法的呼叫實際上會呼叫 category 中的方法。
總結
讀完本文後,你應該對以下內容有比較深刻的理解,排名不分先後:
- 定義在 runtime.h 中的資料結構,如果有
OBJC2_UNAVAILABLE
標記則表示已經廢棄。 - Objective-C 2.0 中,類結構體的結構層次:
objc_class
->class_data_bits_t
->class_rw_t
->method_array_t
。 class_ro_t
結構體的作用,與class_rw_t
的區別,以及和 ABI 穩定性的關係。- category 解析過程的呼叫棧以及基本的流程。
method_array_t
為什麼要設計成一種類似於二維陣列的資料結構,以及它的三種狀態之間的關係。
參考資料
- 深入理解Objective-C:Category
- 從原始碼看 ObjC 中訊息的傳送
- 深入解析 ObjC 中方法的結構
- Whats is methodLists attribute of the structure objc_class for?
- Objc與C(C++)之親緣關係(一) Class
- Objective-C Runtime
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式