前言
导读
本文较长,分为以下几个部分
- isa
- class结构
- Type Encoding
cache_t
- 方法调用
- 消息机制的三个阶段
- 消息发送
- 动态解析
- 消息转发
- 源码分析
什么是runtime
苹果官方说法
The Objective-C language defers as many decisions as it can from compile time and link time to runtime.
(尽量将决定放到运行的时候,而不是在编译和链接过程)
版本和平台
runtime是有个两个版本的: legacy 、 modern
在Objective-C 1.0使用的是legacy,在2.0使用的是modern。这里简单介绍下区别:
- 在legacy runtime,如果你改变了实例变量的设计,需要重新编译它的子类。支持 32bit的OS X 程序
- 在modern runtime,如果你改变了实例变量的设计,不需要重新编译它的子类。支持iphone程序和OS X10.5之后的64bit程序
现在一般来说runtime都是指modern
isa详解
共用体
要想学习Runtime,首先要了解它底层的一些常用数据结构,比如isa指针
在arm64架构之前,isa就是一个普通的指针,存储着Class、Meta-Class对象的内存地址
从arm64架构开始,对isa进行了优化,变成了一个共用体(union)结构,还使用位域来存储更多的信息。
查看runtime源码可以看到关于isa结构。官方的源码是不能编译的。我自己编译了一份可以运行的源码在github上。
1 | union isa_t { |
在runtime723版本以前,直接把结构体放在isa里面了。750版本之后,抽成宏了,展开宏ISA_BITFIELD
在__arm64__
架构下 如下所示
下面的代码对isa_t中的结构体进行了位域声明,地址从nonpointer
起到extra_rc
结束,从低到高进行排列。位域也是对结构体内存布局进行了一个声明,通过下面的结构体成员变量可以直接操作某个地址。位域总共占8字节,所有的位域加在一起正好是64位。
小提示:union
中bits
可以操作整个内存区,而位域只能操作对应的位。
eg: 一个对象的地址是0x7faf1b580450
转换成二进制11111111010111100011011010110000000010001010000
,然后根据不同位置,去匹配不同的含义
1 | define ISA_BITFIELD \ |
isa中不同的位域代表不同的含义。
nonpointer
- 0,代表普通的指针,存储着Class、Meta-Class对象的内存地址
- 1,代表优化过,使用位域存储更多的信息
has_assoc
- 是否有设置过关联对象,如果没有,释放时会更快
has_cxx_dtor
- 是否有C++的析构函数(.cxx_destruct),如果没有,释放时会更快
shiftcls
- 存储着Class、Meta-Class对象的内存地址信息
magic
- 用于在调试时分辨对象是否未完成初始化
weakly_referenced
- 是否有被弱引用指向过,如果没有,释放时会更快
deallocating
- 对象是否正在释放
extra_rc
- 里面存储的值是引用计数器减1
has_sidetable_rc
- 引用计数器是否过大无法存储在isa中
- 如果为1,那么引用计数会存储在一个叫SideTable的类的属性中
eg: 查看objc_runtime-new.mm
文件中有如下代码。
1 | void *objc_destructInstance(id obj) |
可以看出,释放时候,会先判断是否有设置过关联对象,如果没有,释放时会更快。
是否有C++的析构函数(.cxx_destruct),如果没有,释放时会更快。其他的弱引用,nonpointer等,读者可自行看源码。
关Tagged Pointer技术,深入研究的话,可以参考唐巧博客深入理解Tagged Pointer
class结构
用一幅图来表示
objc_class
查看源码(只保留了主要代码)
1 | struct objc_object { |
也就是说结构体objc_class
里面
1 | struct objc_class : objc_object { |
class_rw_t
根据bits可以得到class_rw_t
,class_rw_t
里面的methods、properties、protocols是二维数组,是可读可写的,包含了类的初始内容、分类的内容
eg:方法列表methods中存放着很多一维数组method_list_t
,而每一个method_list_t
中存放这method_t
,method_t
中是对应方法的imp指针,名字。类型等方法信息,在详解iOS中分类Cateogry一文中,我们知道,每个分类编译完成之后都会生成一个_category_t
,对应着method_list_t
。
1 |
|
由代码可知 bits & FAST_DATA_MASK
可获得class_rw_t
.
1 | struct class_rw_t { |
结构体method_array_t
1 | class method_array_t : |
method_t
method_t
是对方法、函数的封装
- IMP
- IMP代表函数的具体实现
1 | // IMP代表函数的具体实现 |
SEL
- SEL代表方法、函数名,一般叫做选择器,底层结构跟
char *
类似 - 可以通过
@selector()
和sel_registerName()
获得 - 可以通过
sel_getName()
和NSStringFromSelector()
转成字符串 - 不同类中相同名字的方法,所对应的方法选择器是相同的
- SEL代表方法、函数名,一般叫做选择器,底层结构跟
1 | typedef struct objc_selector *SEL; |
types包含了函数返回值、参数编码的字符串
- 返回值 参数1 参数2 …… 参数n
- eg:
v16@0:8
代表,返回值void类型,第一个参数是id类型,第二个参数是SEL类型。后面会详细说明。
1 | struct method_t { |
- 创建两个不同的类,并定义两个相同的方法,通过@selector()获取SEL并打印。可以发现SEL都是同一个对象,地址都是相同的。由此证明,不同类的相同SEL是同一个对象。
1 | @interface TestObject : NSObject |
class_ro_t
class_ro_t
里面的baseMethodList、baseProtocols、ivars、baseProperties是一维数组,是只读的,包含了类的初始内容
1 | struct class_ro_t { |
Type Encoding
前面说了,v16@0:8
代表,返回值void类型,第一个参数是id类型,第二个参数是SEL类型。这里详细说明
iOS中提供了一个叫做@encode的指令,可以将具体的类型表示成字符串编码,链接为 Type Encodings
eg: 我们有如下函数
1 | void objc_msgSend(id receiver, SEL selector) |
就可以用v16@0:8
表示
- 其中返回值void类型,第一个参数是id类型,第二个参数是SEL类型。
- 另外第一个数字16代表总共16个字节,0代表第一个参数从第0个字节开始,8代表第二个参数从第8个字节开始。
- 其实也可以简写为
v@:
,这在后面讲到消息转发的时候会用到。
再如:
1 | - (void)viewDidLoad { |
输出结果为:
RuntimeDemo[28247:303205] test的类型 = v16@0:8
RuntimeDemo[28247:303205] testWithNum: = i20@0:8i16
对于方法testWithNum
来说
- i表示返回值是int类型,20是参数总共20字节
- @表示第一个参数是id类型,0表示第一个参数从第0个字节开始
- :表示第二个参数是SEL类型。8表示第二个参数从第8个字节开始。
- i表示第三个参数是int类型,16表示第三个参数从第16个字节开始
- 第三个参数从第16个字节开始,是Int类型,占用4字节。总共20字节
@encode
方法缓存cache_t
前面讲了Class内部结构,其中有个方法缓存cache_t
,用散列表(哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度
1 | struct cache_t { |
散列表数组_buckets
中存放着bucket_t
,bucket_t
的结构如下
1 |
|
散列表cache_t
查找原理
在cache_t
中如何查找方法,其实对于其他散列表也是通用的。
在文件objc-cache.mm
中找到bucket_t * cache_t::find(cache_key_t k, id receiver)
1 | // 散列表中查找方法缓存 |
其中,根据key
和散列表长度减1 mask
计算出下标 key & mask
,取出的值如果key和当初传进来的Key相同,就说明找到了。否则,就不是自己要找的方法,就有了hash冲突,把i的值加1,继续计算。如下代码
1 | // 计算下标 |
cache_t
的扩容
当方法缓存太多的时候,超过了容量的3/4s时候,就需要扩容了。扩容是,把原来的容量增加为2倍
1 | static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) |
具体扩容代码为
1 |
|
方法调用
我们经常写的OC方法调用,究竟是怎么个调用流程,怎么找到方法的呢?
我们如下代码
1 | Person *per = [[Person alloc]init]; |
执行指令
clang -rewrite-objc main.m -o main.cpp
生成cpp文件,对应上面的代码为
1 | ((void (*)(id, SEL))(void *)objc_msgSend)((id)per, sel_registerName("test")); |
简化为
1 | objc_msgSend)(per, sel_registerName("test")); |
其中,per称为消息接收者(receiver), test称为消息名称,也就是说,OC中方法的调用其实都是转换为objc_msgSend函数的调用
消息机制
三大阶段
OC中的方法调用,其实都是转换为objc_msgSend
函数的调用
objc_msgSend的执行流程可以分为3大阶段
消息发送
动态方法解析
消息转发
运行时期,调用方法流程为
实例对象中存放 isa 指针以及实例变量,有 isa 指针可以找到实例对象所属的类对象 (类也是对象,面向对象中一切都是对象),类中存放着实例方法列表,在这个方法列表中 SEL 作为 key,IMP 作为 value。 在编译时期,根据方法名字会生成一个唯一标识,这个标识就是 SEL。IMP 其实就是函数指针 指向了最终的函数实现。整个 Runtime 的核心就是 objc_msgSend 函数,通过给类发送 SEL 以传递消息,找到匹配的 IMP 再获取最终的实现
类中的 super_class
指针可以追溯整个继承链。向一个对象发送消息时,Runtime 会根据实例对象的 isa 指针找到其所属的类,并自底向上直至根类(NSObject)中 去寻找 SEL 所对应的方法,找到后就运行整个方法。
用一张经典的图来表示就是
类中的 super_class 指针可以追溯整个继承链。向一个对象发送消息时,Runtime 会根据实例对象的 isa 指针找到其所属的类,并自底向上直至根类(NSObject)中 去寻找 SEL 所对应的方法,找到后就运行整个方法。
metaClass是元类,也有 isa 指针、super_class 指针。其中保存了类方法列表。
跟读源码顺序
objc-msg-arm64.s
里面都是汇编
1 | objc-msg-arm64.s |
objc-runtime-new.mm
1 | objc-runtime-new.mm |
一直跟到 __forwarding__
的时候,已经不开源的了。
1 | objc-msg-arm64.s |
_objc_msgSend
先来看 objc-msg-arm64.s
主要代码为
1 | //1.进入objcmsgSend |
从上面的代码可以看出方法查找 IMP 的工作交给了 OC 中的 _class_lookupMethodAndLoadCache3 函数,并将 IMP 返回(从 r11 挪到 rax)。最后在 objc_msgSend 中调用 IMP。
汇编代码比较晦涩难懂,因此这里将函数的实现反汇编成C语言的伪代码:
1 | /* |
消息发送阶段
前面跟到_class_lookupMethodAndLoadCache3
之后,后面就不是汇编了,是C语言的实现
- runtime的消息发送阶段,首先判断receiver是否为空,如果为空就直接返回
- 如果不为空,从receiverClass的缓存中,查找方法,如果找到了,就调用方法
- 如果没找到,就从receiverClass的
class_rw_t
中查找方法(分为二分查找和线性查找,两种),如果找到了,就结束查找,缓存一份到自己缓存中,调用方法 - 如果没找到,就去父类的缓存中查找,如果找到了,就就结束查找,缓存一份到自己缓存中,调用方法
- 如果没找到,就从父类的
class_rw_t
中查找方法,如果找到了,就结束查找,缓存一份到自己缓存中,调用方法 - 如果没找到,就看是否还有父类,如果有,就继续查父类的缓存,方法列表
- 如果没有父类,说明消息发送阶段结束,那么就进入第二阶段,动态方法解析阶段。
1 | IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls) |
关键代码在lookUpImpOrForward
里面,下面的代码,增加了注释
1 | IMP lookUpImpOrForward(Class cls, SEL sel, id inst, |
动态方法解析
前面的消息发送阶段,没有找到,就来到动态方法解析阶段
头文件中定义两个方法
1 | - (void)test; |
只实现test
1 | -(void)test{ |
调用的是时候
1 | Person *per = [[Person alloc]init]; |
由前面的消息发送阶段知道,去查缓存,查方法列表,查父类等等,这些操作之后,都没有找到这个方法的实现,如果后面不做处理,必然抛出异常
报错方法找不到
Terminating app due to uncaught exception ‘NSInvalidArgumentException’, reason: ‘-[Person run]: unrecognized selector sent to instance 0x100f436c0’
如果要处理的话,消息发送阶段处理不了。那么就来到第二阶段,动态解析阶段。这个阶段的处理,从前面的源码可知
1 | // 动态方法解析 |
系统默认的resolveClassMethod
和resolveInstanceMethod
默认返回NO
1 | + (BOOL)resolveClassMethod:(SEL)sel { |
我们可以在动态解析阶段,重写resolveInstanceMethod
并添加方法的实现
1 |
|
上面的代码中,因为-(void)test
无参无返回值,函数类型为v@:
,所以,上面的method_getTypeEncoding(method)
可以换成"v@:"
也是没问题的。
这样的话,就相当于,调用run的时候,实际上调用的是test。由源码可知,动态解析完之后,回到查找缓存的地方开始查找,缓存中没有加过,这次去查找,可以再方法列表中查到。这样就可以正确执行了。输出结果为
objc-test[6681:75992] -[Person test]
直接运行源码,如下图
消息转发
如果前面消息发送和动态解析阶段,对方法都没有处理,我们还有最后一个阶段,消息转发阶段来处理。从源码的imp = (IMP)_objc_msgForward_impcache;
可以看出,_objc_msgForward_impcache
的代码是在汇编里面
1 |
|
跟到 ___forwarding___
之后就不开源了
1 |
|
在上述调用栈中,发现了在 Core Foundation 中会调用___forwarding___
。根据资料也可以了解到,在 objc_setForwardHandler
时会传入 __CF_forwarding_prep_0
和 ___forwarding_prep_1___
两个参数,而这两个指针都会调用____forwarding___
。这个函数中,也交代了消息转发的逻辑
接下来怎么办呢?可以通过汇编调试,或逆向来进一步分析后续的实现。
站在前人的代码上,能看的更远 —鲁迅.尼古拉斯
___forwarding___
的实现
国外有大神复原了___forwarding___
的实现,具体可参考Hmmm, What’s that Selector?
需要注意的是,复原了___forwarding___
的实现是伪代码。具体代码我已经放在了github上。
1 | 伪代码 |
小结
- 消息转发阶段,先判断
forwardingTargetForSelector
的返回值,如果有值,就向这个返回值发送消息。也就是objc_msgSend(返回值, SEL)
。 - 如果返回为nil,就调用
methodSignatureForSelector
方法,如果有值,就调用forwardInvocation
,其中的参数是一个 NSInvocation 对象,并将消息全部属性记录下来。 NSInvocation 对象包括了选择子、target 以及其他参数。其中的实现仅仅是改变了 target 指向,使消息保证能够调用。倘若发现本类无法处理,则继续想父类进行查找。直至 NSObject 。 - 如果
methodSignatureForSelector
方法返回nil,就调用doesNotRecognizeSelector:
方法
上面都是源码分析,那下面代码验证
在源码中forwardingTargetForSelector
系统默认返回nil 。
1 | + (id)forwardingTargetForSelector:(SEL)sel { |
消息转发实例一
我们有类Person
只定义了方法- (void)run;
但是没有实现,另外有类Car
,实现了方法- (void)run;
1 | @interface Car : NSObject |
在person中,重写forwardingTargetForSelector
让返回Car
对象
1 | // 消息转发 |
调用的时候
1 |
|
输出objc-test[16694:174917] -[Car run]
验证了前面说的,forwardingTargetForSelector
返回值不为空的话,就向这个返回值发送消息,也就是 objc_msgSend(返回值, SEL)
消息转发实例二
如果前面的forwardingTargetForSelector
返回为空, 就会调用 methodSignatureForSelector
获取方法签名后再调用 forwardInvocation
// 方法签名:返回值类型、参数类型
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
{
if (aSelector == @selector(run)) {
return [NSMethodSignature signatureWithObjCTypes:"v16@0:8"];
}
return [super methodSignatureForSelector:aSelector];
}
// NSInvocation封装了一个方法调用,包括:方法调用者、方法名、方法参数
// anInvocation.target 方法调用者
// anInvocation.selector 方法名
// [anInvocation getArgument:NULL atIndex:0]
- (void)forwardInvocation:(NSInvocation *)anInvocation
{
[anInvocation invokeWithTarget:[[Car alloc] init]];
}
依然可以调用到-[Car run]
注意点1
消息转发的forwardingTargetForSelector
和methodSignatureForSelector
以及forwardInvocation
不仅支持实例方法,还支持类方法。不过系统没有提示,需要写成实例方法,然后把前面的-
改成+
即可。
注意点2
只能向运行时动态创建的类添加ivars,不能向已经存在的类添加ivars
这是因为在编译时只读结构体class_ro_t就会被确定,在运行时是不可更改的。ro结构体中有一个字段是instanceSize,表示当前类在创建对象时需要多少空间,后面的创建都根据这个size分配类的内存。
如果对一个已经存在的类增加一个参数,改变了ivars的结构,这样在访问改变之前创建的对象时,就会出现问题。