分类

分类的源码

1
2
3
4
5
6
7
8
9
struct category_t { 
const char *name; // 原类名,而不是分类名
// 要扩展的类对象,编译期间是不会定义的,而是在 Runtime 阶段通过 name 对应到对应的类对象
classref_t cls;
struct method_list_t *instanceMethods; // 分类中新增的对象方法列表
struct method_list_t *classMethods; // 分类中新增的类方法列表
struct protocol_list_t *protocols; // 分类中新增的协议列表
struct property_list_t *instanceProperties; // 分类中新增的属性列表
};

分类为什么不能添加实例变量

  1. 从底层结构上看:没有实例变量的相关字段,所以分类是无法添加实例变量的(即分类在编译时无法保存实例变量的信息,而 instanceProperties 的存在说明属性是可以的)
  2. 从内存结构上看:在运行期,对象的内存布局已经确定,如果添加实例变量会破坏类的内部布局(即使分类保存了实例变量的信息,运行时也无法向本类添加实例变量)

如何理解在运行期,对象的内存布局已经确定,如果添加实例变量会破坏类的内部布局

那为什么添加方法就不会呢?还得从 objc_class 的结构说起

注:本文关于 objc_class 的源码都是基于老版本的源码,其结构更容易理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
1
2
3
4
5
6
7
8
9
10
11
struct objc_ivar_list {
int ivar_count OBJC2_UNAVAILABLE;
/* variable length structure */
struct objc_ivar ivar_list[1] OBJC2_UNAVAILABLE;
}

struct objc_ivar {
char *ivar_name OBJC2_UNAVAILABLE;
char *ivar_type OBJC2_UNAVAILABLE;
int ivar_offset OBJC2_UNAVAILABLE;
}

可以看到,ivars 是一个一级指针,指向的是一个 objc_ivar_list 类,其中 ivar_list 的大小是可变的;这个可以在 class_addIvar 的实现中找到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
BOOL class_addIvar(Class cls, const char *name, size_t size, 
uint8_t alignment, const char *type)
{
ivar_list_t *oldlist, *newlist;
if ((oldlist = (ivar_list_t *)cls->data()->ro->ivars)) {
size_t oldsize = oldlist->byteSize();
// 重新分配内存,影响的是 ivar_list 的大小
newlist = (ivar_list_t *)calloc(oldsize + oldlist->entsize(), 1);
memcpy(newlist, oldlist, oldsize);
free(oldlist);
} else {
newlist = (ivar_list_t *)calloc(sizeof(ivar_list_t), 1);
newlist->entsizeAndFlags = (uint32_t)sizeof(ivar_t);
}

uint32_t offset = cls->unalignedInstanceSize();
uint32_t alignMask = (1<<alignment)-1;
offset = (offset + alignMask) & ~alignMask;

// 对这块内存进行复写
ivar_t& ivar = newlist->get(newlist->count++);
ivar.offset = (int32_t *)malloc(sizeof(int32_t));
*ivar.offset = offset;
ivar.name = name ? strdupIfMutable(name) : nil;
ivar.type = strdupIfMutable(type);
ivar.alignment_raw = alignment;
ivar.size = (uint32_t)size;

// 重新指向这个新的 list
ro_w->ivars = newlist;

// 更新 instance_size
cls->setInstanceSize((uint32_t)(offset + size));
return YES;
}

调用 class_addIvar 会添加一个实例变量,影响 instance_size 和 ivars 所指向的空间的大小。但是仍然无法解释为什么不能添加 ivars

此时还得知道一个 ivar 是如何被系统访问的,如果按照以下这种方式访问 ivar,整个流程要经过好多次指针转移:

1
2
class -> class.rw_data -> class.rw_data.ro_data -> class.rw_data.ro_data.ivars -> 
-> class.rw_data.ro_data.ivars.first[n]

如果是这样,那么动态添加 ivar 似乎变得可行,因为 ivar 是指针,往指针指向的内容扩充并不会影响类的大小,访问时只要遍历所有 ivar list 就可以找到对应的 ivar,但是这样访问,大量使用 ivar 肯定很耗时。事实上 Runtime 不是这样访问 ivar 的

那么,对于 ivar 的访问究竟是怎么样的呢?

这篇 《谈谈 ivar 的直接访问》 提到,对 ivar 的访问,其实是在编译期将 ivar 相对于类本身的偏移量存储在一个全局变量里,全局变量的值在编译的时候就确定了,这个全局变量的地址就存在 objc_ivarivar_offset

即编译时,系统会将对这个 ivar 的读写访问的代码转为,本类地址加上对应的全局偏移量,就能访问到对应 ivar 的值。

1
2
@property (nonatomic, assign) NSInteger myInt;
self.myInt = 5;

编译后的代码为

1
2
extern "C" unsigned long OBJC_IVAR_$_MyObject$_myInt;
(*(NSInteger *)((char *)self + OBJC_IVAR_$_MyObject$_myInt)) = 5;

而正是由于这种关系,在运行时如果想添加一个 ivar,势必会导致所有全局偏移量不正确

有人会说,如果加在 ivar list 的前面会影响旧的 ivar 的全局偏移量,那加在 ivar list 后面不就影响不了吗?
答案是,类是可以被继承的,给父类的 ivar list 尾部添加一个 ivar,尽管不影响父类自己的 ivar 偏移,却影响了子类的 ivar 偏移

这个就是所谓的在运行期,对象的内存布局已经确定,如果添加实例变量会破坏类的内部布局

对于方法的访问与 ivar 的访问不同,是通过 objc_msgSend 找到对应的方法列表,所以可以动态添加方法

那运行时如何给类添加实例变量呢?

只能在 objc_allocateClassPairobjc_registerClassPair 两个函数之间为类添加变量

1
2
3
4
Class class = objc_allocateClassPair(NSObject.class, "Sark", 0);
class_addIvar(class, "_girlFriend", sizeof(id), log2(sizeof(id)), @encode(id));
class_addIvar(class, "_company", sizeof(id), log2(sizeof(id)), @encode(id));
objc_registerClassPair(class);

分类方法的加载与覆盖

我们主要探究四个问题:

  1. 分类的方法什么时候被添加到本类
  2. 分类的方法在运行时会覆盖本类,那么在内存结构中是否覆盖了本类
  3. 分类在运行时是怎么覆盖本类方法
  4. 如果有多个分类有同名的方法,其调用顺序是怎样的

分类插入本类方法的源码

启动时,_objc_init 里面的调用的 map_images 最终会调用 objc-runtime-new.mm 里面的 _read_images 方法有以下的代码片段,我们删除一些无用代码得到

注:如果一个类实现了 +load 方法,那么它就会在启动时被加载,会调用 realizeClass 进行加载,加载后 isRealized 将会返回 true;如果没实现 +load 方法,那么就会懒加载这个类,直到给这个类发送消息时才会去 realizeClass。懒加载和非懒加载处理分类的时机是不一样的,但是原理大致相同。我们这里只讨论类和分类都实现了 +load 的情况,更多情况请参考 iOS 底层探索 - 分类的加载

以下是处理分类的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Discover categories. 
for (EACH_HEADER) {
// hi 表示 headerInfo
// 从每一个 headerInfo 中获取分类列表以及分类个数
category_t **catlist = _getObjc2CategoryList(hi, &count);

// 注意这里的 count 指的是一个头文件定义的分类个数
for (i = 0; i < count; i++) {
// 获取分类实例
category_t *cat = catlist[i];
// 获取分类指向的本类
Class cls = remapClass(cat->cls);

// 处理本类不存在的异常
if (!cls) {
// 此处省略...
continue;
}

// 开始处理分类
// 第一步,注册分类到本类(addUnattachedCategoryForClass)
// 第二步,重建这个类的方法列表(remethodizeClass)

// 处理实例方法、协议、属性
if (cat->instanceMethods || cat->protocols
|| cat->instanceProperties)
{
addUnattachedCategoryForClass(cat, cls, hi);
if (cls->isRealized()) {
remethodizeClass(cls);
}
}

// 按照同样的逻辑处理类方法、协议、类属性
if (cat->classMethods || cat->protocols)
{
addUnattachedCategoryForClass(cat, cls->ISA(), hi);
if (cls->ISA()->isRealized()) {
remethodizeClass(cls->ISA());
}
}
}
}

这段代码主要告诉我们,在启动初始化 objc 的时候分类的方法列表会被插到本类的方法中

它主要实现了,遍历所有头文件获取所有分类列表,并对每个分类:

  1. 把 category 的实例方法、协议以及属性添加到类上
  2. 把 category 的类方法和协议添加到类的 metaclass 上

这里我们只研究实例方法的插入,其他项是同理的

addUnattachedCategoryForClass 只是负责把类和 category 做一个关联映射,并没有修改类的结构,我们暂不关心

真正生效的是 remethodizeClass,它负责重新对方法列表进行排列,但其实也是一个壳,主要调用了 attachCategories

注:由于我们对分类和类写了 +load,所以执行到这里的时候,类已经被加载过了,所以 isRealized 是 true

1
2
3
4
5
6
7
8
9
10
11
// 重新对方法列表进行排列
static void remethodizeClass(Class cls)
{
category_list *cats;

// unattachedCategoriesForClass,我们暂不关心
if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) {
attachCategories(cls, cats, true /*flush caches*/);
free(cats);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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));
// 本类的所有分类的所有属性列表
property_list_t **proplists = (property_list_t **)
malloc(cats->count * sizeof(*proplists));
// 本类的所有分类的所有协议列表
protocol_list_t **protolists = (protocol_list_t **)
malloc(cats->count * sizeof(*protolists));

// 方法个数
int mcount = 0;
// 属性个数
int propcount = 0;
// 协议个数
int protocount = 0;
// 分类的个数,这里一般是 1 个,因为参数是每个头文件中的每个分类
int i = cats->count;
bool fromBundle = NO;
while (i--) {
// 从后往前数,获取每一个分类
auto& entry = cats->list[i];

// 处理方法
// 获取该分类的所有方法
method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
if (mlist) {
// 并插到临时变量 mlists 的尾部
// 注意分类的遍历顺序,最终会导致分类列表中靠后的分类的方法排在 mlists 前面
mlists[mcount++] = mlist;
fromBundle |= entry.hi->isBundle();
}

// 处理属性列表,同理
property_list_t *proplist =
entry.cat->propertiesForMeta(isMeta, entry.hi);
if (proplist) {
proplists[propcount++] = proplist;
}

// 处理协议,同理
protocol_list_t *protolist = entry.cat->protocols;
if (protolist) {
protolists[protocount++] = protolist;
}
}

// rw 代表原类的信息
auto rw = cls->data();

// 这里会对同个分类的方法进行排序,比如 test2 排在 test 前面
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);
}

这个方法主要做了两件事,以实例方法为例,属性和协议同理

注意这里虽然是数组,但是一般只有一个类,即 cats->count = 1

  1. 把所有分类的方法读取出来放到一个数组里,越靠后的分类的方法在数组中的位置越靠前
  2. 把这个方法数组添加到原类方法的首部,见 attachLists
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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;
// void *memmove(void *str1, const void *str2, size_t n) 从 str2 复制 n 个字符到 str1
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]));
}
}

关于 attachLists 方法,就是分类方法覆盖本类方法的关键所在

首先,我们要明确,每次进入这个方法的参数 addedLists,是指一个分类的所有方法;这个方法实现的是将一个分类的所有方法添加到类结构的方法列表前面(虽然这里的参数可能是一个 method_list_t,不过实测下来这个数组只会有一个元素,因此暂时理解为一个元素)

然后我们看看这里是怎么实现的,这里主要处理如何将一个元素添加进数组,根据数组的情况分为 3 种情况

  1. 类的方法列表中没有 method_list_t 时,把单个新增元素这个赋值给指针(0 lists -> 1 list)
  2. 类的方法列表中只有一个 method_list_t 时,重新申请内存,把老的第 0 个元素挪到最后,再把新增元素拷贝到最前
  3. 类的方法列表中有多个 method_list_t 时,重新申请内存,把老的元素通过 memmove 挪到最后,再把新增元素拷贝到最前

如果一个类自身没有声明方法时,当第一个分类进来的时候就会进到情况 1;再继续处理第二个分类或者本类已经有方法的时候回进入到情况 2,其他会进入情况 3。这一点还是很好理解的,主要看现在的类结构里面有没有方法列表

void *memmove(void *str1, const void *str2, size_t n) 从 str2 复制 n 个字符到 str1

void *memcpy(void *str1, const void *str2, size_t n) 从 str2 复制 n 个字符到 str1

在重叠内存块这方面,memmove() 是比 memcpy() 更安全的方法。如果目标区域和源区域有重叠的话,memmove() 能够保证源串在被覆盖之前将重叠区域的字节拷贝到目标区域中,复制后源区域的内容会被更改。如果目标区域与源区域没有重叠,则和 memcpy() 函数功能相同。

加载分类同名方法的源码

我们先看看 objc_class 结构中 methodLists 这个二级指针。首先,**methodLists 是个二级指针,它指向的是一个数组,这个数组就是由 objc_method_list 构成,它是一个一维数组,即 *methodLists,如下图

注:在最新的 Runtime 源码中,objc_method_list 被替换为 method_list_t,所以 methodLists 也可以看是 method_list_t 的二级指针

假如本类有 a、b 方法,分类 1 有 a、c、d 方法,分类 2 有 a、e 方法,分类 1 先声明,则 methodLists 的方法列表应该如下

1
*methodLists = [[a, e], [a, c, d], [a, b]];

其中 [a, e]、[a, c, d] 和 [a, b] 都是一个 method_list_t

category_t 的结构中我们可以看出,一个分类拥有一个 method_list_t,即分类本身的方法列表;而本类的方法列表存放的是一个指针,指向的是一个数组,这个数组的元素是 method_list_t

关于这个数据结构的证明我们除了从这篇文章可以得到 二级指针指向的数据结构是什么样的?,也可以从方法的调用中一探究竟

首先,我们看下方法调用的栈

1
objc_msgSend -> _class_lookupMethodAndLoadCache3 -> lookUpImpOrForward

lookUpImpOrForward 关键代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
bool initialize, bool cache, bool resolver)
// 省略其他代码
// 1. 找缓存
imp = cache_getImp(cls, sel);
if (imp) goto done;

// 2. 找本类
{
Method meth = getMethodNoSuper_nolock(cls, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, cls);
imp = meth->imp;
goto done;
}
}

// 3. 找父类
// 省略其他代码

可以看到先找缓存,再找本类,最后找父类,最关键的是 getMethodNoSuper_nolock,源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static method_t *getMethodNoSuper_nolock(Class cls, SEL sel)
{
runtimeLock.assertLocked();
assert(cls->isRealized());

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

注意这里的 methods 是 objc_class 中的结构,是新版本源码,我们这里关于 objc_class 是老的源码,但这并不妨碍我们理解

objc_class 的 methods 是一个二级指针,其指向一个数组,数组的每个元素都是一个 method_list_t *

cls->data()->methods.beginLists() 返回的是该数组的第一个元素的迭代器(类比 C++ STL 中的 list 的 begin()),对其解引用后可以得到一个 method_list_t *。这个也是 search_method_list 的入参

search_method_list 的源码可以直接简化如下,对这个一维数组进行顺序查找,找到立刻返回

1
2
3
4
5
6
7
static method_t *search_method_list(const method_list_t *mlist, SEL sel)
{
for (auto& meth : *mlist) {
if (meth.name == sel) return &meth;
}
return nil;
}

总结成一句话就是,方法查找时,从类的 method 列表中开始顺序查找,列表的每个元素是一个 method_list_t,这个结构里面存储着一个分类或本类的方法列表,遍历这个 method_list_t 与调用的方法的名字是否一致,一致则返回

结论

  1. 分类的方法什么时候被添加到本类

    答:在启动的时候,objc_setup 阶段会进行 objc 类的注册,将分类的方法插到本类的方法列表

  2. 分类的方法在运行时会覆盖本类,那么在内存结构中是否覆盖了本类

    答:对于 load 方法来说比较特殊,见下节;对于普通方法来说,内存结构中分类的方法并没有覆盖本类,而是插到了本类的方法列表前面

  3. 分类在运行时是怎么覆盖本类方法

    答:objc_msgSend 时,会从类的方法列表中查找对应的 method,是从头往后查找的,由于分类的方法被插在了本类的方法前面,因此会优先找到,从而达到了覆盖的效果

  4. 如果有多个分类有同名的方法,其调用顺序是怎样的

    答:分类的顺序是按照在编译选项中的顺序决定的,越靠后的分类的方法会被放到本类方法列表的越前面,会被优先调用到。

如何调试 Runtime

可以下载源码

Xcode 11.4 无法编译成功,改为 11.3.1 才可。另外 10.15 系统版本的 Mac 需要选择 Deployment Target 为 10.14 才能编译成功

使用以下源码来判断正在处理哪一个类

1
if (strcmp(cls->data()->ro->name, "Person") == 0)

相关知识

如何为分类添加属性

关联对象

分类的 load 方法顺序

父类 > 本类 > 分类

分类的 initialize 方法顺序

父类 > 本类 > 分类

源码链接

  1. 分类的源码
  2. 分类为什么不能添加实例变量
  3. 分类方法的加载与覆盖
    1. 分类插入本类方法的源码
    2. 加载分类同名方法的源码
    3. 结论
  4. 如何调试 Runtime
  5. 相关知识
    1. 如何为分类添加属性
    2. 分类的 load 方法顺序
    3. 分类的 initialize 方法顺序
  6. 源码链接