分类的源码
1 | struct category_t { |
分类为什么不能添加实例变量
- 从底层结构上看:没有实例变量的相关字段,所以分类是无法添加实例变量的(即分类在编译时无法保存实例变量的信息,而 instanceProperties 的存在说明属性是可以的)
- 从内存结构上看:在运行期,对象的内存布局已经确定,如果添加实例变量会破坏类的内部布局(即使分类保存了实例变量的信息,运行时也无法向本类添加实例变量)
如何理解在运行期,对象的内存布局已经确定,如果添加实例变量会破坏类的内部布局?
那为什么添加方法就不会呢?还得从 objc_class
的结构说起
注:本文关于
objc_class
的源码都是基于老版本的源码,其结构更容易理解
1 | struct objc_class |
1 | struct objc_ivar_list { |
可以看到,ivars 是一个一级指针,指向的是一个 objc_ivar_list
类,其中 ivar_list
的大小是可变的;这个可以在 class_addIvar
的实现中找到
1 | BOOL class_addIvar(Class cls, const char *name, size_t size, |
调用 class_addIvar
会添加一个实例变量,影响 instance_size
和 ivars 所指向的空间的大小。但是仍然无法解释为什么不能添加 ivars
此时还得知道一个 ivar 是如何被系统访问的,如果按照以下这种方式访问 ivar,整个流程要经过好多次指针转移:
1 | class -> class.rw_data -> class.rw_data.ro_data -> class.rw_data.ro_data.ivars -> |
如果是这样,那么动态添加 ivar 似乎变得可行,因为 ivar 是指针,往指针指向的内容扩充并不会影响类的大小,访问时只要遍历所有 ivar list 就可以找到对应的 ivar,但是这样访问,大量使用 ivar 肯定很耗时。事实上 Runtime 不是这样访问 ivar 的
那么,对于 ivar 的访问究竟是怎么样的呢?
这篇 《谈谈 ivar 的直接访问》 提到,对 ivar 的访问,其实是在编译期将 ivar 相对于类本身的偏移量存储在一个全局变量里,全局变量的值在编译的时候就确定了,这个全局变量的地址就存在 objc_ivar
的 ivar_offset
即编译时,系统会将对这个 ivar 的读写访问的代码转为,本类地址加上对应的全局偏移量,就能访问到对应 ivar 的值。
1 | @property (nonatomic, assign) NSInteger myInt; |
编译后的代码为
1 | extern "C" unsigned long OBJC_IVAR_$_MyObject$_myInt; |
而正是由于这种关系,在运行时如果想添加一个 ivar,势必会导致所有全局偏移量不正确
有人会说,如果加在 ivar list 的前面会影响旧的 ivar 的全局偏移量,那加在 ivar list 后面不就影响不了吗?
答案是,类是可以被继承的,给父类的 ivar list 尾部添加一个 ivar,尽管不影响父类自己的 ivar 偏移,却影响了子类的 ivar 偏移
这个就是所谓的在运行期,对象的内存布局已经确定,如果添加实例变量会破坏类的内部布局
对于方法的访问与 ivar 的访问不同,是通过 objc_msgSend
找到对应的方法列表,所以可以动态添加方法
那运行时如何给类添加实例变量呢?
只能在 objc_allocateClassPair
与 objc_registerClassPair
两个函数之间为类添加变量
1 | Class class = objc_allocateClassPair(NSObject.class, "Sark", 0); |
分类方法的加载与覆盖
我们主要探究四个问题:
- 分类的方法什么时候被添加到本类
- 分类的方法在运行时会覆盖本类,那么在内存结构中是否覆盖了本类
- 分类在运行时是怎么覆盖本类方法
- 如果有多个分类有同名的方法,其调用顺序是怎样的
分类插入本类方法的源码
启动时,_objc_init
里面的调用的 map_images
最终会调用 objc-runtime-new.mm 里面的 _read_images
方法有以下的代码片段,我们删除一些无用代码得到
注:如果一个类实现了 +load 方法,那么它就会在启动时被加载,会调用 realizeClass 进行加载,加载后 isRealized 将会返回 true;如果没实现 +load 方法,那么就会懒加载这个类,直到给这个类发送消息时才会去 realizeClass。懒加载和非懒加载处理分类的时机是不一样的,但是原理大致相同。我们这里只讨论类和分类都实现了 +load 的情况,更多情况请参考 iOS 底层探索 - 分类的加载
以下是处理分类的代码
1 | // Discover categories. |
这段代码主要告诉我们,在启动初始化 objc 的时候分类的方法列表会被插到本类的方法中
它主要实现了,遍历所有头文件获取所有分类列表,并对每个分类:
- 把 category 的实例方法、协议以及属性添加到类上
- 把 category 的类方法和协议添加到类的 metaclass 上
这里我们只研究实例方法的插入,其他项是同理的
addUnattachedCategoryForClass 只是负责把类和 category 做一个关联映射,并没有修改类的结构,我们暂不关心
真正生效的是 remethodizeClass,它负责重新对方法列表进行排列,但其实也是一个壳,主要调用了 attachCategories
注:由于我们对分类和类写了 +load,所以执行到这里的时候,类已经被加载过了,所以 isRealized 是 true
1 | // 重新对方法列表进行排列 |
1 | static void attachCategories(Class cls, category_list *cats, bool flush_caches) |
这个方法主要做了两件事,以实例方法为例,属性和协议同理
注意这里虽然是数组,但是一般只有一个类,即 cats->count
= 1
- 把所有分类的方法读取出来放到一个数组里,越靠后的分类的方法在数组中的位置越靠前
- 把这个方法数组添加到原类方法的首部,见 attachLists
1 | void attachLists(List* const * addedLists, uint32_t addedCount) { |
关于 attachLists 方法,就是分类方法覆盖本类方法的关键所在
首先,我们要明确,每次进入这个方法的参数 addedLists,是指一个分类的所有方法;这个方法实现的是将一个分类的所有方法添加到类结构的方法列表前面(虽然这里的参数可能是一个 method_list_t
,不过实测下来这个数组只会有一个元素,因此暂时理解为一个元素)
然后我们看看这里是怎么实现的,这里主要处理如何将一个元素添加进数组,根据数组的情况分为 3 种情况
- 类的方法列表中没有
method_list_t
时,把单个新增元素这个赋值给指针(0 lists -> 1 list) - 类的方法列表中只有一个
method_list_t
时,重新申请内存,把老的第 0 个元素挪到最后,再把新增元素拷贝到最前 - 类的方法列表中有多个
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 | IMP lookUpImpOrForward(Class cls, SEL sel, id inst, |
可以看到先找缓存,再找本类,最后找父类,最关键的是 getMethodNoSuper_nolock
,源码如下
1 | static method_t *getMethodNoSuper_nolock(Class cls, SEL sel) |
注意这里的 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 | static method_t *search_method_list(const method_list_t *mlist, SEL sel) |
总结成一句话就是,方法查找时,从类的 method 列表中开始顺序查找,列表的每个元素是一个 method_list_t
,这个结构里面存储着一个分类或本类的方法列表,遍历这个 method_list_t
与调用的方法的名字是否一致,一致则返回
结论
分类的方法什么时候被添加到本类
答:在启动的时候,
objc_setup
阶段会进行 objc 类的注册,将分类的方法插到本类的方法列表分类的方法在运行时会覆盖本类,那么在内存结构中是否覆盖了本类
答:对于 load 方法来说比较特殊,见下节;对于普通方法来说,内存结构中分类的方法并没有覆盖本类,而是插到了本类的方法列表前面
分类在运行时是怎么覆盖本类方法
答:
objc_msgSend
时,会从类的方法列表中查找对应的 method,是从头往后查找的,由于分类的方法被插在了本类的方法前面,因此会优先找到,从而达到了覆盖的效果如果有多个分类有同名的方法,其调用顺序是怎样的
答:分类的顺序是按照在编译选项中的顺序决定的,越靠后的分类的方法会被放到本类方法列表的越前面,会被优先调用到。
如何调试 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 方法顺序
父类 > 本类 > 分类