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机制)才动态合并到类对象当中的。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345