【iOS】OC底层系列九 - runtime-Class

类对象存储着isa、superclass、属性、对象方法、协议、成员变量等信息。元类对象存储着isa、superclass、类方法等信息。类对象和元类对象存储的信息有点相似,而且两者都是objc_class类型,因此可以把元类对象称作是类对象的一种特殊对象。

一、Class

Class的结构:

1
2
3
4
5
6
struct objc_class {
Class isa;
Class superclass;
cache_t cache; // 方法缓存
class_data_bits_t bits; // 用于获取具体的类信息
}

bits & FAST_DATA_MASK的结果指向结构体class_rw_t

1
2
3
4
5
6
7
8
9
10
11
struct class_rw_t {
uint32_t flags;
uint32_t version;
const class_ro_t *ro;
method_list_t * methods; // 方法列表(类对象存储对象方法,元类对象存储类方法)
property_list_t *properties; // 属性列表
const protocol_list_t *protocols; // 协议列表
Class firstSubclass;
Class nextSiblingClass;
char *demangledName;
}

结构体class_ro_t的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize; // instance对象占用的内存空间大小
#ifdef __LP64__
uint32_t reserved;
#endif
const uint8_t *ivarLayout;
const char *name; // 类名
method_list_t *baseMethodList;
protocol_list_t *baseProtocols;
const ivar_list_t *ivars; // 成员变量列表
const uint8_t *weakIvarLayout;
property_list_t *baseProperties;
}

疑问:结构体class_rw_t中的methodspropertiesprotocols和结构体class_ro_t中的baseMethodListbasePropertiesbaseProtocols有什么区别呢?

1.1. class_rw_t

class_rw_t(rw代表可读可写,readwrite)里面的methodspropertiesprotocols二维数组,是可读可写的,包含了类的初始内容、分类的内容

methods数组里面存储的元素是method_list_t类型的数组,method_list_t数组中的元素是method_t。而且分类方法在前面,类方法列表放在后面(优先执行分类方法的原因)。

propertiesprotocols的存储原理同methods

1.2. class_ro_t

class_ro_t(ro代表只读,readonly)里面的baseMethodListbaseProtocolsivarsbaseProperties一维数组,是只读的,包含了类的初始内容

method_list_t数组中的元素是method_t,里面的方法是类的初始方法。

class_rw_t中的methods是从class_ro_t中的baseMethodList复制过去的。objc_class中的变量bits最开始指向的是class_ro_t,后面创建class_rw_t后,把class_rw_t中的变量ro指向class_ro_t,并让objc_class中的变量bits重新指向class_rw_t

1.3. method_t

method_t是对方法\函数的封装,包含函数名、返回值类型、参数类型、函数地址。

1
2
3
4
5
struct method_t {
SEL name; // 函数名
const char *types; // 编码(返回值类型、参数类型)
IMP imp; // 指向函数的指针(函数地址)
}
  • IMP代表函数的具体实现:
1
typedef if _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...);
  • SEL代表方法\函数名,一般叫做选择器,底层结构跟char *类似(可以认为SEL就是一个名字)。
1
typedef struct objc_selector *SEL;

获取SEL:可以通过@selector()sel_registerName()获得。

1
2
3
4
// C
SEL sel2 = sel_registerName("test");
// OC
SEL sel1 = @selector(test);

SEL转成字符串:可以通过sel_getName()NSStringFromSelector()SEL转成字符串。

1
2
3
4
// C
const char *test1Name = sel_getName(sel1);
// OC
NSString *test2Name = NSStringFromSelector(sel2);

不同类中相同名字的方法,所对应的方法选择器是相同的(本质都是字符串),而且方法选择器的地址是唯一的。

1
2
NSLog(@"%p, %p, %p", @selector(test), @selector(test), sel_registerName("test"));
// 输出:0x1dc962098, 0x1dc962098, 0x1dc962098
  • types包含了函数返回值、参数编码的字符串

- (void)test;方法会有两个默认参数:(id)self(SEL)_cmd(注意:任意OC方法都会携带这两个默认参数)。

test方法中的types存储的是v16@0:8v代表返回值类型是void@代表第一个参数是id类型,:代表第二个参数SEL类型。

返回值类型后面的数字代表方法所有参数占用的字节数,由于默认有idSEL两个类型参数(都是指针类型,指针在64bit环境占用8个字节),所以一共占用16个字节。

参数类型后面的数字代表参数从哪个位置开始计算最终的参数内存地址,0代表从第0个字节开始就是参数self的内存地址,8代表从第8个字节开始就是参数_cmd的内存地址。从这也可以看出,后面参数类型的数字代表前面参数占用的字节总和。

返回值 参数1 参数2 …… 参数n

在OC中,程序运行时会把对应的类型转成字符编码,就像上面的v@:,具体参考下面的1.4章节。

- (int)test:(int)age height:(float)height;方法中的types存储的是i24@0:8i16f20i代表返回值类型是int@:分别代表第一和第二个参数类型,后面的i代表第三个参数是int类型,f代表第四个参数类型是float。方法中所有参数类型一共占用id(8) + SEL(8) + int(4) + float(4) = 24 个字节(也可以直接用最后一个参数类型后面的数字加上参数类型所占用的字节得出总占用字节数,20 + float(4) = 24)。

综上可以断定,有了method_t就可以知道一个方法的名字、返回值类型、参数及参数类型是什么了。

1.4. TypeEncoding

iOS中提供了一个叫做@encode的指令,可以将具体的类型表示成字符串编码。

1
2
3
4
5
NSLog(@"%s", @encode(int));  // 输出:i
NSLog(@"%s", @encode(void)); // 输出:v
NSLog(@"%s", @encode(SEL)); // 输出::
NSLog(@"%s", @encode(id)); // 输出:@
NSLog(@"%s", @encode(Class)); // 输出:#

1.5. 方法缓存

Class内部结构中有个方法缓存(cache_t),用散列表(哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度。

1
2
3
4
5
struct cache_t {
struct bucket_t *_buckets; // 散列表
mask_t _mask; // 散列表的长度-1
mask_t _occupied; // 已经缓存的方法数量
}

数组_buckets中存储的元素是结构体bucket_t

1
2
3
4
struct bucket_t {
cache_key_t _key; // SEL作为key
IMP _imp; // 函数的内存地址
}

缓存查找函数(objc-cache.mm):

1
bucket_t * cache_t::find(cache_key_t k, id receiver)

每次查找缓存方法是通过遍历的方式在_buckets中查找。比如,DBPerson类中有个- (void)test;方法,在_buckets中存储的就是[bucket_t(@selector(test), _imp)],计算@selector(test) & _mask的哈希值(哈希值对应的是散列表中的索引),然后按照索引位置存储到_buckets中。取值时到_buckets里面遍历是否有和刚才的哈希值一致的索引值,找到索引对应的bucket_t,找出key对应的IMP,最终执行对应的方法。

在objc源码中,如果存储缓存方法时遇到计算的索引位置已经有方法,并且两个key(方法名)不一致,会让索引减1后继续和_mask按位与计算,直到找到两个key一致的方法。散列表扩容时会把缓存都清掉,之前缓存的方法都不存在了,然后重新开始缓存。可以查阅相关资料了解散列表的原理,有可能不同平台计算索引的方法不一样,但原理都是一样的(空间换时间)。

方法查找过程:先从类对象中的缓存中查找方法,如果缓存中没有找到方法,就从bits中查找方法,找到后就把方法实体返回,并且在cache中缓存。如果在本类中没有找到方法,就到父类的cachebits中查找,找到后会在自己的cache中缓存(哪个类调用方法,就把方法缓存到哪个类中)。