面试遇到Runtime的第一天-category

1,144 阅读13分钟

本文是记录一下,通过runtime源码去帮助自己更深刻的理解category的实现原理,有空会回头继续理解挖掘知识点,本文也会不断的更新,丰富内容。

一. Runtime简介

要说runtime就得从Objective-C说起,Objective-C是一门动态语言,这里“动态”是什么意思呢?简单来说就是可以动态的创建类和对象,进行消息传递和转发。而runtime就是为Objective-C提供这种动态性所需要的动态的环境。说的比较抽象,实际去探索一下runtime的源码,可以更好的加深对runtime的理解。

runtime,中文名运行时,是一套C,C++,汇编写成的底层API,给Objective-C提供了运行时的系统。与运行时相对应的是编译时,对Objective-C而言,编译时 (源代码翻译:OC swift Java 都是高级语言, 可读性强 , 但是不会被机器识别 , 需要编译成机器语言 二进制才能被计算机识别,但是OC在编译时应该是被编译成了运行时代码) 只是确定了调用了哪一个方法,而这个方法如何响应,是否能响应,都要再运行时决定。

下面我们先从category开始,一步步去探索runtime中你不知道的知识。

二. category分析

引子

我们可以给一个系统的类,比如NSString创建分类,也可以给我们自定义的类,比如Person创建分类,然后通过import头文件就可以调用分类或者类本身的方法。
这里先简单说一下查找类中方法的流程(或者参考isa和meta-Class消息转发),根据下面这张图:

  1. 实例方法:instanceisa指向class对象,在class对象中查找实例(对象)方法列表
  2. 类方法:class对象的isa指向meta-class,在meta-class中查找类方法

objc_class

先从objc_class开始 ,虽然runtime2.0之后这个结构体改变了,但是之前的代码也值得我们分析一下

struct objc_class {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;

#if !__OBJC2__
    Class _Nullable super_class                              OBJC2_UNAVAILABLE;
    const char * _Nonnull name                               OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list * _Nullable ivars                  OBJC2_UNAVAILABLE;
    struct objc_method_list * _Nullable * _Nullable methodLists                    OBJC2_UNAVAILABLE;
    struct objc_cache * _Nonnull cache                       OBJC2_UNAVAILABLE;
    struct objc_protocol_list * _Nullable protocols          OBJC2_UNAVAILABLE;
#endif

} OBJC2_UNAVAILABLE;
/* Use `Class` instead of `struct objc_class *` */

我们知道category的作用呢,是为已有类添加方法的,那么在上面源码中,我们要关心的自然是methodLists了。 其实,除了为已有类添加方法,category还有一些其他很实用的用途:

  • 减少单个文件的体积
  • 可以把不同的功能组织到不同的category中 这样每一个文件可以有一个明确的功能
  • 可以按需加载
  • 声明私有方法(分类里声明不实现 本类.m文件中实现 外部调用)
  • 把framework的私有方法公开
  • 模拟多继承,因为OC是不支持多继承的,所以我们可以用category来模拟,这里是通过消息转发实现,我们后面在详细说

category和extension

这也是面试中常会被问到的问题,两者的区别。因为平时extension我用的不多,所以这里简单说一下

  • extension是类的一部分,在编译期决议,一般用来隐藏类的私有属性
  • 无法为系统类添加extension,但是可以给其他类添加属性
  • category在运行期决议,不可以添加属性

这里又引出另外一个面试题:为什么category不可以添加成员变量? 因为在运行时,对象的内存布局已经确定,如果添加成员变量会破坏内存布局

这是一句标准答案,但是到底是什么意思你真的理解嘛?还是需要从源码出发去分析一下

源码分析

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;
    // Fields below this point are not always present on disk.
    struct property_list_t *_classProperties;

    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }

    property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
};

在runtime层,category用结构体category_t表示,它包含:

  • name: 类的名字
  • cls: 类
  • instanceMethods:给类添加的实例方法列表
  • classMethods:给类添加的类方法列表
  • protocols:给类添加的协议列表
  • instanceProperties:给类添加的所有属性

这里我们先说一下结论:

  1. 这个结构体里并没有成员变量的列表ivars
  2. 分类的实现原理是将category中的方法,属性,协议数据放在category_t结构体中, 然后拷贝到类对象中
  3. Category可以添加属性,但是并不会自动生成成员变量及set/get方法的实现

继续解释,经典面试题之《category为什么不能给对象添加成员变量》:

因为category_t结构体中并不存在成员变量的列表 我们知道成员变量是存放在实例对象中的,并且编译的那一刻就已经决定好了 而分类是在运行时才去加载的。那么我们就无法在程序运行时将分类的成员变量添加到实例对象的结构体中。因此分类中不可以添加成员变量

这里再唠叨两句所谓的“可变”和“不可变”的意思,因为对象在内存中的排布可以看成一个结构体,该结构体的大小并不能动态改变,所以无法在运行时动态的给对象增加成员变量。

相对的,对象的方法定义都保存在类的可变区域中,方法列表如下:

struct objc_method_list * _Nullable * _Nullable methodLists

是一个指向指针的指针,通过修改指向指针的指针的值,就可以实现动态的为某一个类增加成员方法,这也是category的实现原理,同时也说明了category不能增加成员变量。

加载category

这里源码略长,就不一一贴了,主要贴一些关键跟踪路径的源码和如下两点重要的结论:

  1. category的方法不会覆盖掉原来类的同名方法 (category的方法被放到了方法列表的前面,原来的方法只是排在了后面,并没有正在意义上的覆盖)
  2. category的方法会覆盖掉原来类的同名方法 (运行时在查找方法的时候是顺着方法列表的顺序查找的,只要一找到对应名字的方法,就返回不再继续查找了)
    这里的源码,又要从盘古开天辟地的时候开始说起了,找到我们的objc_init方法
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);
}

然后看下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中找到最后一句关键代码

_read_images(hList, hCount, totalClasses, unoptimizedTotalClasses);

当然这里的images不是图片的意思,可以理解为一个模块吧,在read_images中找到处理category的代码Discover categories

   // 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;
            }
            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);
                }
            }
        }
    }

查看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(cls, cats, true /*flush caches*/); 这个函数的函数名也不难猜出就是在这里把category结构体中的列表追加到了cls的列表中去,这里也是加载category的核心代码了

    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);

跟踪了这么多代码,终于找到了能证明我们上面结论1中说的:category的方法不会“覆盖”掉原来类的同名方法 (category的方法被放到了新方法列表的前面)

 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;
            //内存移动   类对象方法列表的数据往后移动一个长度addedCount  就是分类中方法列表的长度
            memmove(array()->lists + addedCount, array()->lists, 
                    oldCount * sizeof(array()->lists[0]));
            //内存拷贝   分类方法列表的数据 copy到类对象方法列表原来的位置
            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]));
        }
    }

代码中我也添加了注释,通过memmovememcpy就把category中的方法列表添加到了类对象原有方法列表之前,所以最后加工完毕的方法列表是category中的methodlist + class对象中的methodlist

这样的追加方式,也保证了category中定义的方法可以优先被调用

到这里category就加载完毕了。

那么这里就又有一个面试题了,如何调用原来类中被category覆盖掉的方法呢?

分析到这里我们思路就比较清晰了,category其实并不是完全替换掉原来类的同名方法,只是category在方法列表的前面而已,所以我们只要顺着方法列表找到最后一个对应名字的方法,就可以调用原来类的方法

还有另外一个问题,多个category中的同名方法的执行顺序是怎么样的呢?

执行顺序是根据编译顺序决定的。可以在xcode中Build Phases -> Compile Sources中查看顺序。

关联对象

category本身没有添加成员变量的功能,但是开发中我们实际会遇到这种需求,这时就需要用到关联对象来实现了。相信你在开发中也用到过很多次了,下面测试代码主要看使用中key的几种设置方式:

#import "Sark+test.h"

#import <objc/runtime.h>

@implementation Sark (test)

static const void *SarkNameKey = &SarkNameKey;
- (void)setName:(NSString *)name {
    objc_setAssociatedObject(self, SarkNameKey, name, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)name {
    return objc_getAssociatedObject(self, SarkNameKey);
}

static const char SarkWeightKey;
- (void)setWeight:(NSInteger)weight {
    objc_setAssociatedObject(self, &SarkWeightKey, @(weight), OBJC_ASSOCIATION_ASSIGN);
}

- (NSInteger)weight {
    return [objc_getAssociatedObject(self, &SarkWeightKey) integerValue];
}

//直接写的字符串保存在常量区  地址保持不变
#define SarkAgeKey @"age"
- (void)setAge:(NSInteger)age {
//    这里传入的是@"age"的地址
    objc_setAssociatedObject(self, SarkAgeKey, @(age), OBJC_ASSOCIATION_ASSIGN);
}

- (NSInteger)age {
    return [objc_getAssociatedObject(self, SarkAgeKey) integerValue];
}

- (void)setAddress:(NSString *)address {
    objc_setAssociatedObject(self, @selector(address), address, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)address {
//    @selector(address) == _cmd  隐式参数
    return objc_getAssociatedObject(self, _cmd);
}
@end

还有一点需要注意的是,关联属性的策略policy是没有weak

然后我们主要关注一下关联对象的生命周期 比如存在什么地方呢? 如何存储? 对象销毁的时候如何处理关联对象呢?也就是关联对象的底层实现原理
下面这张图,很好的诠释了调用objc_getAssociatedObject之后底层的处理:

对应的源码

void _object_set_associative_reference(id object, void *key, id value, uintptr_t policy) {
    // retain the new value (if any) outside the lock.
    ObjcAssociation old_association(0, nil);
    id new_value = value ? acquireValue(value, policy) : nil;
    {
        AssociationsManager manager;
        AssociationsHashMap &associations(manager.associations());
        disguised_ptr_t disguised_object = DISGUISE(object);
        if (new_value) {
            // break any existing association.
            AssociationsHashMap::iterator i = associations.find(disguised_object);
            if (i != associations.end()) {
                // secondary table exists
                ObjectAssociationMap *refs = i->second;
                ObjectAssociationMap::iterator j = refs->find(key);
                if (j != refs->end()) {
                    old_association = j->second;
                    j->second = ObjcAssociation(policy, new_value);
                } else {
                    (*refs)[key] = ObjcAssociation(policy, new_value);
                }
            } else {
                // create the new association (first time).
                ObjectAssociationMap *refs = new ObjectAssociationMap;
                associations[disguised_object] = refs;
                (*refs)[key] = ObjcAssociation(policy, new_value);
                object->setHasAssociatedObjects();
            }
        } else { //value为nil
            // setting the association to nil breaks the association.
            AssociationsHashMap::iterator i = associations.find(disguised_object);
            if (i !=  associations.end()) {
                ObjectAssociationMap *refs = i->second;
                ObjectAssociationMap::iterator j = refs->find(key);
                if (j != refs->end()) {
                    old_association = j->second;
                    refs->erase(j);//移除
                }
            }
        }
    }
    // release the old value (outside of the lock).
    if (old_association.hasValue()) ReleaseValue()(old_association);
}

可以得到下面的结论:

  • 关联对象并不是存储在被关联对象本身的内存中,而是存储在一张全局的hash表
  • 如果关联对象为nil,就相当于是移除关联对象

关联属性通过自己定义一个新的数据结构 ObjcAssociation 容器来保存用户设置的内容 以及读取用户设置的内容 . 以此达到属性那种通过方法访问成员变量的效果. 分类关联属性的生命周期同原先类 . 通过在 isa 中标识是否有关联对象来在 dealloc 中实现销毁操作. runtime的销毁对象函数objc_destructInstance里面会判断这个对象有没有关联对象,如果有,会调用_object_remove_assocations做关联对象的清理工作。

匿名分类

匿名分类这个名词,应该很少听过,就是把方法定义在.m文件的@interface中,实现只能写在@implementation中,可以看下面的测试代码:

@interface Sark()
- (void)category_method;
@end


@implementation Sark

- (id)init
{
    self = [super init];
    if (self)
    {
        NSLog(@"%@", NSStringFromClass([self class]));
        NSLog(@"%@", NSStringFromClass([super class]));
    }
    return self;
}

- (void)speak {
    NSLog(@"my name's %@", self.name);
}
@end

所以匿名分类不是严格意义上的分类,可以理解为只是把方法私有化,@interface中的方法声明写或者不写都无关紧要,我们最终编译的都只是方法的实现。

load、initialize

先强调一下这也是一个常见的面试题-。-,感觉一个category能被问的东西可真多。

Category中可以添加load方法,load方法在程序启动装载类信息的时候就会调用(但是附加category到类的工作会先于+load方法的执行)。load方法可以继承。调用子类的load方法之前,会先调用父类的load方法

区别

区别在于调用方式和调用时刻 以及分类中的方法是否会覆盖类本身

调用方式:

  • load是根据函数地址直接调用
for (i = 0; i < used; i++) {
        Class cls = classes[i].cls;
        load_method_t load_method = (load_method_t)classes[i].method;
        if (!cls) continue; 

        if (PrintLoading) {
            _objc_inform("LOAD: +[%s load]\n", cls->nameForLogging());
        }
        (*load_method)(cls, SEL_load);
    }
  • initialize是通过objc_msgSend调用
void callInitialize(Class cls)
{
    ((void(*)(Class, SEL))objc_msgSend)(cls, SEL_initialize);
    asm("");
}

调用时刻:

  • load是程序启动装载类信息的时候就会去调用(只会调用1次),哪怕是你根本没有用到这些类,也没有import,load方法都会调用,并且是先调用类的load方法,再调用category中的load方法

  • initialize当类第一次接收到消息的时候调用,每一个类只会initialize一次,并且会先调用父类的initialize(父类的initialize方法可能会被调用多次)

调用顺序:

  • load方法中直接拿到load方法的内存地址直接调用方法,不再是通过消息发送机制调用。分类中也是通过直接拿到load方法的地址进行调用。
    根据如下源码可知:分类中load方法不会覆盖本类的load方法,先编译的分类优先调用load方法
 do {
        // 1. Repeatedly call class +loads until there aren't any more
        while (loadable_classes_used > 0) {
            call_class_loads();
        }

        // 2. Call category +loads ONCE
        more_categories = call_category_loads();

        // 3. Run more +loads if there are classes OR more untried categories
    } while (loadable_classes_used > 0  ||  more_categories);
  • initialize是通过消息发送机制调用的,消息发送机制通过isa指针找到对应的对象然后找方法实现,因此先找到分类方法中的实现,会优先调用分类方法中的实现。 先初始化父类,之后再初始化子类。如果子类没有实现+initialize,会调用父类的+initialize(所以父类的+init ialize可能会被调用多次),如果分类实现了+initialize,就覆盖类本身的+initialize调用。