Objective-C之Category的底層實現原理

Objective-C的+load方法調用原理分析
Objective-C的+initialize方法調用原理分析

Category的使用場景

我個人粗淺理解,就是將一個類的實現,拆解成小的模塊,便于管理和維護。因為實際項目中,有些類的功能可能會非常復雜,導致一個類的代碼過多,這對后期修改和維護是比較不利的,所以category方便了程序員,可以根據功能,業務等形式的劃分,將類的一大堆方法分組放置以及調用。

有趣的思考

先來看一個最簡單的category結構,一下代碼定義了一個CLPerson類 和它的一個category CLPerson+Test

// ******************** CLPerson
#import <Foundation/Foundation.h>
@interface CLPerson : NSObject
-(void)run;
@end

#import "CLPerson.h"  
@implementation CLPerson
-(void)run
{
    NSLog(@"CLPerson Run");
}
@end

// ******************** CLPerson+Test
#import "CLPerson.h"
@interface CLPerson (Test)
-(void)test;
@end

#import "CLPerson+Test.h"
@implementation CLPerson (Test)
-(void)test{
    NSLog(@"Test");
}
@end

// ******************** CLPerson+Eat
#import "CLPerson.h"
@interface CLPerson (Eat)
-(void)eat;
@end

#import "CLPerson+Eat.h"
@implementation CLPerson (Eat)
-(void)eat{
    NSLog(@"Eat");
}
@end

請問???:以下的兩個方法調用,底層到底發生了什么,它們本質是否相同?

CLPerson *person = [[CLPerson alloc]init];
[person run]; //類的實例方法調用
[person test];//分類的實例方法調用
[person eat];//分類的實例方法調用

我們都知道,[實例對象 方法]這種寫法,經過底層轉換之后,實際上就是,objc_msgSend(類對象, @selector(實例方法)),也就我們oc的一個基本概念,消息發送機制。因此,我們可以推定,[person run]這句代碼,在消息發送機制下,首先會根據 personisa指針找到CLPerson的類對象,然后在類對象的方法列表(method_list_t * methods)里面找到該方法的實現,然后進行調用。
接下來,你肯定會想

  • 那么[person test][person eat]呢?它的消息是發送給誰呢?
  • 是發送給person的類對象嗎?
  • 還是說,對于CLPerson+Test.hCLPerson+Eat.h來說,也有其獨立對應的分類對象呢?
    帶著這些思考和問題,我們接下來一步一步地進行拆解。




Category的實現原理

底層結構——所有一切始于編譯

要想知道原理,不要猜,也不要輕易相信別人說的東西,自己驗證一下才是最靠譜的。在命令行下,進入CLPerson+Test.m文件所在路徑執行以下命令-->

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc CLPerson+Test.m

得到編譯后的c++文件CLPerson+Test.cpp,將其拖入xcode項目中進行查看,但是不要加入編譯列表,否則程序跑不起來。直接查看文件底部,就可以找到category相關的底層信息,請看下圖剖析

上圖比較粗糙,請諒解,但比文字描述來的更加直觀,上面基本上分析清楚了在編譯結束之后,category是以何種形式存在的,現在用文字來總結一下:
category經過編譯過程之后,系統為其定義了如下的一個結構體

  //注意,編譯后的cpp文件一般比較長,會有好幾萬行,
  //一般我們關注類結構相關的信息,都在最后,
  //所以可以直接把文件拖到底,便可以找到這些信息
  struct _category_t {
  const char  *name; //用來存放類名
  struct _class_t *cls;
  const struct _method_list_t *instance_methods;//用來存放category里面的實例方法列表
  const struct _method_list_t *class_methods;//用來存放category里面的類方法列表
  const struct _protocol_list_t *protocols;//用來存放category里面的協議列表
  const struct _prop_list_t *properties;//用來存放category里面的屬性列表
  }; 

這個struct _category_t結構體,就是在程序在編譯之后,被用來存放category的相關信息(instance methods, class methodsprotocolproperty)的。

反過來描述,編譯的時候,系統會給每一個category生成一個對應的結構體變量,而且他們都是struct _category_t類型的,然后把category里面的信息存到這個變量里面。

在我的示例里面,這個變量的名稱叫_OBJC_$_CATEGORY_CLPerson_$_Test,這個名字很清晰的表明,它存儲的是Objective-c下的CLPerson類的Test分類的信息。

struct _category_t中定義了六個成員變量,除去其中的第二個,我個人還沒搞明白有什么用,其他的五個作用則非常清晰了

  • const char *name;
    上圖中的a部分,其值表示category所對應的類的名字。
  • const struct _method_list_t *instance_methods;
    上圖中的b部分,其值就是實例方法列表,可以看到里面正好放了我們定義的實例方法 -test
  • const struct _method_list_t *class_methods;
    上圖中的c部分,其值就是類方法列表,可以看到里面放了我們定義的類方法 -classTest
  • const struct _protocol_list_t *protocols;
    上圖中的d部分,其值就是協議列表,可以看到里面存放了 NSCoping協議
  • const struct _prop_list_t *properties;
    上圖中的e部分,其值就是屬性,可以看到里面有我們定義的age屬性
源碼分析

上面的篇章,我們通過查看編譯后的cpp文件,了解了category在編譯階段完成后的存在形式,以CLPerson+Test為例,它所對應的struct _category_t變量中,第一個成員變量name的值為"CLPerson"(CLPerson+Eat對應的name也是"CLPerson",可以自行驗證),而且根據我在對象的本質(上)——OC對象的底層實現中所討論所得出的結果可以知道,一個OC類XXX在底層都存在一個對應的C++結構體實現struct XXX_IMPL,但我們在CLPerson+Test.cpp文件中,并沒有發現 struct CLPerson+Test_IMPL/struct CLPerson+Eat_IMPL,因此,我猜想CLPersoncategory中的信息,應該還是存儲在CLPerson所對應的class對象和meta-class對象中,category自己并沒有獨立的class對象和meta-class對象。CLPerson旗下的所有category里面的信息,應該是在某個階段被合并到了類的CLPersonclass對象和meta-class對象中。從編譯的結果看,我們并沒有發現有合并的操作,僅僅是給每個category生成了對應的struct _category_t類型的變量,存放其信息。所以我合理懷疑,合并操作應該是發生在Runtime階段。

為了證明以上猜想,我們還是要挖掘Runtime的源碼。我們先去蘋果官網下載一份objc4的最新源碼。然后我們直接尋找objc-os.mm文件,這個文件可以看作是Runtime進行初始化的地方。然后找到_objc_init()方法,這個方法是Runtime被加載后執行的第一個方法,可以理解成Runtime的入口方法。

/***********************************************************************
* _objc_init
* Bootstrap initialization. Registers our image notifier with dyld.
* Called by libSystem BEFORE library initialization time
**********************************************************************/

void _objc_init(void)
{
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    // fixme defer initialization until an objc-using image is found?
    environ_init();
    tls_init();
    static_init();
    lock_init();
    exception_init();

    _dyld_objc_notify_register(&map_images, load_images, unmap_image);
}

_objc_init() 中前面的一堆方法,跟本文的主題不相關,不入坑,且看最后一個方法_dyld_objc_notify_register(&map_images, load_images, unmap_image)。這個函數里面的三個參數分別是另外三個函數:

  • map_images -- Process the given images which are being mapped in by dyld.(處理那些正在被dyld映射的鏡像文件)
  • load_images -- Process +load in the given images which are being mapped in by dyld.(處理那些正在被dyld映射的鏡像文件中的+load方法)
  • unmap_image -- Process the given image which is about to be unmapped by dyld.(處理那些將要被dyld進行去映射操作的鏡像文件)

我們查看一下map_images方法,點進去

void
map_images(unsigned count, const char * const paths[],
          const struct mach_header * const mhdrs[])
{
   mutex_locker_t lock(runtimeLock);
   return map_images_nolock(count, paths, mhdrs);
}

這里面看不出啥,返回了map_images_nolock(count, paths, mhdrs),感覺像是一層轉換,繼續點進該方法看一下。好家伙,這個方法就比較豐富了,為了節約紙張,這里就不貼完整代碼了,有興趣自己上源碼看。經過牛人指點,找到里面一個關鍵方法_read_images(hList, hCount, totalClasses, unoptimizedTotalClasses);。從方法名字可以看出,意思是要讀取鏡像,也就處理系統動態庫以及我們寫過的代碼中的各種自定義類文件。這個方法也比較長,就截取關鍵的一段

// Discover categories. 
    for (EACH_HEADER) {
        category_t **catlist = 
            _getObjc2CategoryList(hi, &count);
        bool hasClassProperties = hi->info()->hasCategoryClassProperties();

        for (i = 0; i < count; i++) {
            category_t *cat = catlist[i];
            Class cls = remapClass(cat->cls);

            if (!cls) {
                // Category's target class is missing (probably weak-linked).
                // Disavow any knowledge of this category.
                catlist[i] = nil;
                if (PrintConnecting) {
                    _objc_inform("CLASS: IGNORING category \?\?\?(%s) %p with "
                                 "missing weak-linked target class", 
                                 cat->name, cat);
                }
                continue;
            }

            // Process this category. 
            // First, register the category with its target class. 
            // Then, rebuild the class's method lists (etc) if 
            // the class is realized. 
            bool classExists = NO;
            if (cat->instanceMethods ||  cat->protocols  
                ||  cat->instanceProperties) 
            {
                addUnattachedCategoryForClass(cat, cls, hi);
                if (cls->isRealized()) {
                    remethodizeClass(cls);
                    classExists = YES;
                }
                if (PrintConnecting) {
                    _objc_inform("CLASS: found category -%s(%s) %s", 
                                 cls->nameForLogging(), cat->name, 
                                 classExists ? "on existing class" : "");
                }
            }

            if (cat->classMethods  ||  cat->protocols  
                ||  (hasClassProperties && cat->_classProperties)) 
            {
                addUnattachedCategoryForClass(cat, cls->ISA(), hi);
                if (cls->ISA()->isRealized()) {
                    remethodizeClass(cls->ISA());
                }
                if (PrintConnecting) {
                    _objc_inform("CLASS: found category +%s(%s)", 
                                 cls->nameForLogging(), cat->name);
                }
            }
        }
    }

一句// Discover categories真的是對讀者非常友好,這立馬使我明白,接下來的代碼是處理category相關內容的。這個read_images方法從上倒下,分好幾大塊,每大塊頭部都有類似的注釋,說明該部分所做的事情,將作者的思路描述的非常清晰,不愧是蘋果的源碼。下面通過圖解來說明一下category處理部分的大致思路

這里注意我一個細節,上圖的第一部分我已經畫出來了,一開始的那個catlist是一個二維數組,里面的成員也是一個一個的數組,也就是代碼里面的cat所指向的數組,它的類型是category_t *,說明cat數組里面裝的就是category_t,(有點繞,慢慢來:-)一個cat里面裝的就是某個class所對應的所有category。

那么什么決定了這些category_t在cat數組中的順序呢?

答案是category文件的編譯順序決定的。先參與編譯的,就放在數組的前面,后參與編譯的,就放在數組后面。我們可以在xcode-->target-->Build Phases-->Compile Sources列表查看和調整category文件的編譯順序



在上面的category先編譯,下面的category后編譯。可以鼠標拖拽進行調整。

然后我們繼續往下看,進入remethodizeClass方法看一看

static void remethodizeClass(Class cls)
{
    category_list *cats;
    bool isMeta;

    runtimeLock.assertLocked();

    isMeta = cls->isMetaClass();

    // Re-methodizing: check for more categories
    if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) {
        if (PrintConnecting) {
            _objc_inform("CLASS: attaching categories to class '%s' %s", 
                         cls->nameForLogging(), isMeta ? "(meta)" : "");
        }
        
        attachCategories(cls, cats, true /*flush caches*/);        
        free(cats);
    }
}

然后在這里面找到一個方法attachCategories,肯名字就知道,附著分類,也就是把分類的內容添加/合并到class里面,貌似快接近真相了,小雞動??

static void 
attachCategories(Class cls, category_list *cats, bool flush_caches)
{
    if (!cats) return;
    if (PrintReplacedMethods) printReplacements(cls, cats);

    bool isMeta = cls->isMetaClass();

    // fixme rearrange to remove these intermediate allocations
    //分配一塊空間來放所有分類的方法數組,這里是一個二維數組,數組的每個成員,對應著某個分類的方法數組
    method_list_t **mlists = (method_list_t **)
        malloc(cats->count * sizeof(*mlists));
    //分配一開空間來放所有分類的屬性數組,理解同上
    property_list_t **proplists = (property_list_t **)
        malloc(cats->count * sizeof(*proplists));
    //分配一塊空間來放所有分類的協議數組,理解同上
    protocol_list_t **protolists = (protocol_list_t **)
        malloc(cats->count * sizeof(*protolists));

    // Count backwards through cats to get newest categories first
    int mcount = 0;
    int propcount = 0;
    int protocount = 0;
    int i = cats->count;
    bool fromBundle = NO;
    while (i--) {//這里i--說明,是從
        //取出某個分類變量
          entry = cats->list[i];
        //提取分類中的對象方法/類方法
        /* mlists最終會是以下形式
         [
            [method_t, method_t],
            [method_t, method_t]
         ]
         
         */
        method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
        if (mlist) {
            mlists[mcount++] = mlist;
            fromBundle |= entry.hi->isBundle();
        }
        //提取分類中的屬性
        /* proplists最終會是以下形式
         [
         [property_t, property_t],
         [property_t, property_t]
         ]
         
         */
        property_list_t *proplist = 
            entry.cat->propertiesForMeta(isMeta, entry.hi);
        if (proplist) {
            proplists[propcount++] = proplist;
        }
        
        //提取分類中的協議
        /* protolists最終會是以下形式
         [
         [protocol_t, protocol_t],
         [protocol_t, protocol_t]
         ]
         
         */
        protocol_list_t *protolist = entry.cat->protocols;
        if (protolist) {
            protolists[protocount++] = protolist;
        }
    }


    //得到類對象里面的數據
    auto rw = cls->data();

    prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
    //將所有分類的對象方法附加到類對象的方法列表中
    rw->methods.attachLists(mlists, mcount);
    free(mlists);
    if (flush_caches  &&  mcount > 0) flushCaches(cls);
    //將所有分類的屬性附加到類對象的屬性列表中
    rw->properties.attachLists(proplists, propcount);
    free(proplists);
    //將所有分類的協議附加到類對象的協議列表中
    rw->protocols.attachLists(protolists, protocount);
    free(protolists);
    
    //搞定,結束
}

??以上這一部分代碼中的注解引用自MJ大神在騰訊平臺的相關分享??

這里注意一個地方,這里面用了while (i--) {entry = cats->list[i]; ......},entry可以簡單理解成 category_t,(里面還有一些其他內容,不影響我們的理解),那么list里面就裝了一堆的category_t,他們都對應著同一個class,這些category_t在數組中的順序,和前面我們討論的category文件的編譯順序是相同的,也就是先編譯的category在前,后編譯的category在后。 在while循環里面進行處理的時候,是從下標 cats->count-1(也就是i--)開始的,也就是從數組的尾部向前一個一個的處理。處理過程主要就是把category的方法列表添加到mlists里面,mlists[mcount++] = mlist;,而mcount是從0開始的,所以結果就是最終,放到mlists里面的方法列表順序是倒過來的,最前面的方法列表,對應著最后編譯的cetegory(協議和屬性的處理過程和這里一樣)

上述方法里面的最后一個操作rw->methods.attachLists我們再進一步分析一下,看一看,最終分類中的方法和class中的方法,最終是以怎么樣的順序合并存放到最后的方法列表里面的,進入attachLists函數

void  attachLists(List* const * addedLists, uint32_t addedCount) {
        if (addedCount == 0) return;

        if (hasArray()) {
            // many lists -> many lists
            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]));
        }
        else if (!list  &&  addedCount == 1) {
            // 0 lists -> 1 list
            list = addedLists[0];
        } 
        else {
            // 1 list -> many lists
            List* oldList = list;
            uint32_t oldCount = oldList ? 1 : 0;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)malloc(array_t::byteSize(newCount)));
            array()->count = newCount;
            if (oldList) array()->lists[addedCount] = oldList;
            memcpy(array()->lists, addedLists, 
                   addedCount * sizeof(array()->lists[0]));
        }
    }

這個函數的兩個參數分別代表

  • addedLists--將要被添加的category中的方法列表組成的的數組,
  • addedCount--addedLists數組的元素數量。
    這個方法是今天討論的問題里面最有趣的地方,將會解釋我們在使用category中所碰到的各種現象。請看下圖分解
    category的合并過程

如此看來,最終類的方法列表里面,如果class有自己對應的category,那么category中的方法列表會被合并放置在class的方法列表的前部,類本身的方法則會被往列表尾部挪,當我們通過[obj method]的方式調用方法的時候,系統會到在類的方法列表里面,從前往后遍歷查找。

因此,如果category里面如果重寫了class里面的方法,那么,最終會調用category的方法實現,就是因為它被放在了列表前面,先被找到,就被調用了,其實class里面的同名方法還是在的,并沒有被覆蓋,只不過看起來像是覆蓋了。

另外,我們在上面分析attachCategories方法的時候得知,該方法實際上將category的方法列表按照編譯順序倒過來存到了一個數組里,供后續方法使用。

那么過程走到這里,便可以知道,最最最終,在class的方法列表里面,最后參加編譯的category的方法會出現在方法列表的最前面,先參加編譯的category的方法會出現在方法列表的后面,列表的最后存著class自己的方法[對于meta-class也是一樣的],好,分析結束。

回答開篇的幾個問題

????????

  • [person test][person eat]的消息是發送給誰呢?

發送給 CLPerson的類對象

  • 還是說,對于CLPerson+Test.h來說,也有其獨立對應的分類對象呢?

不存在所謂的 分類的類對象,一個類以及它的所有分類,都只對應一個類對象,它們所有的實例方法(-方法),屬性(@property),協議(@protocol)都被合并到了這一個類對象里面,它們所有的類方法(+方法),都被合并到了這個類的元類對象里面。上面所說的合并,都是發生在程序運行階段,運用了Objc的Runtime機制完成。

????????






*****************砍瓜切菜*****************

(1)category里面的方法存放在哪里?
  • 一個類所對應的分類下的對象方法,存放在該類的類對象的方法列表里面。
  • 一個類所對應的分類下的類方法,會存放在該類的元類對象的方法列表里面
(2)category里面的方法,是什么時候被放到類的類對象/元類對象的方法列表里面的?(編譯階段 or 運行階段)
  • 結論:是程序運行的時候進行的。通過runtime動態地將分類的方法,合并到類對象、元類對象中。

所有的category結構是一樣的,只不過里面存儲的具體數據不同,每一個category都有自己對應的一個變量,類型為 struct _category_t ,在編譯過程中,會完成對struct _category_t類型變量的賦值。

(3)程序運行過程中,分類中的方法是如何合并到類的方法列表中的?

面試官要問,就直接畫圖改他看吧,文字描述感覺弱爆了:)

(4)分類方法會覆蓋類里面的方法嗎?

不會

(5)如果有多個分類有同名的方法A,那么實際哪一個方法A會被調用?

最后參加編譯的category里面的A方法會被調用

(6)如何控制分類的編譯順序?

在Build Phase->Compile Sources里面調整,直接拖拽

(7)category和extension的區別是什么?
  • extension的內容是在編譯完成后,就存在于類對象里面,extension只不過是將原本.h文件里面的內容挪到了.m文件里面,不讓外界看見,實質上它就是class.h的一部分,
  • category的內容,是在編譯的時候,保存到了struct _category_t 結構體變量中,然后在程序運行階段(runtime機制)才動態合并到類對象當中的。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。