摘要
最近面试iOS实习生,面试官都好喜欢来这么一句,“你了解OC的runtime吗?”。毫不夸张的说,有点像iOS程序员的必考题啊。可见,runtime 的重要性。所以,准备整理一下以前自己的笔记。写一写我对 runtime 的理解。虽然网上关于 runtime 的文章已经数不胜数,但是这篇博客主要是以一个菜鸟的角度介绍runtime,可能比较好懂吧,哈哈。接下来有空的话,我还会陆续更新一些,我在平时项目里面关于runtime 的使用。
Objective-C Runtime 是什么?
Objective-C 是一门动态语言
,区别于静态语言,它会把一些决定工作从编译链接推迟到运行时。也就是说,光有编译器的支持还不够,还需要一个运行时系统
(runtime system) 来执行编译后的代码。可见,runtime 是 Objective-C 动态性的实现基石。
究其本质,Objective-C 的 Runtime 是一个运行时库
(Runtime Library),它是一个主要使用 C 和汇编写的库(Apple开源了这部分的代码,有兴趣的童鞋可以看看)。这个库主要做一下两件事:
封装C语言的结构体和函数,让开发者在运行时创建、检查或者修改类、对象和方法等等。
处理消息,找出方法的最终执行代码。
Objective-C 的 Runtime 铸就了它动态语言的特性,或许 runtime 在平时项目的使用比较少,实用性可能不是特别大,但是却是每个 Objc 程序员需要了解的。
Runtime 干了些什么?
我们主动去使用 runtime 可能较少,但是 runtime 却一直处在我们深深的代码里。
Objective-C 从三种不同的层级上与 Runtime 系统进行交互:
- 通过 Objective-C 源代码
- 通过 Foundation 框架的 NSObject 类定义的方法
通过对 runtime 函数的直接调用。
以上内容还是比较抽象,接下来这个例子就会比较通俗易懂啦,哈哈~
在我们平时的代码里,经常会有1
2
3
4
5//zen 是类 boy 的实例
[zen playBasketball];
//或者是以下的写法
//[zen performSelector:@selector(playBasketball)];
zen 是一个实例对象,调用它的 playBasketball 方法。
在 Objective-C 中,如果我们停留在表层类比我们之前学习的其他语言这样去理解其实也可以。但是,如果我们深入去看这句代码,就会发现里面大有乾坤。
Objc 中,其实是用发送消息
来实现方法的调用的。发送消息是用中括号([])把接收者和消息括起来,而直到运行时才会把消息与方法实现绑定。简单的说就是,在编译时并不会实例要调用的函数的准确内存地址。
在编译时,以上代码转化为 C语言 的代码:
objc_msgSend(zen, @selector(playBasketball));
这便是实现消息发送
的关键所在。
其实编译器会根据情况在objc_msgSend, objc_msgSend_stret, objc_msgSendSuper, 或 objc_msgSendSuper_stret 四个方法中选择一个来调用。如果消息是传递给超类,那么会调用名字带有”Super”的函数;如果消息返回值是数据结构而不是简单值时,那么会调用名字带有”stret”的函数。
上面的代码也就只是向 zen 发送了一个 playBaskerball 的消息。接下来我们来探究一下 Runtime 是如何实现消息发送(方法的调用)的。
Runtime 的消息发送流程
上面也提到,方法的调用(包括实例方法和类方法)实质都是给对象发送消息。
以上面的代码为例,流程如下:
编译器会把
[zen playBasketball]
或者[zen performSelector:@selector(playBasketball)]
转化为objc_msgSend(zen, SEL)
,其中,SEL 为 @selector(playBasketball)。检测这个 selector 是不是要忽略的。比如 Mac OS X 开发,有了垃圾回收就不理会 retain, release 这些函数了。
检测这个 target(也就是 zen ) 是不是 nil 对象。ObjC 的特性是允许对一个 nil 对象执行任何一个方法不会 Crash,因为会被忽略掉。
如果上面两个都过了,那就开始查找这个类收到这个消息(也就是playBasketball)后,最终会执行的方法(称指向该方法的指针为
IMP
),先从 boy 的方法缓存列表(cache)
里面找,完了找得到就跳到对应的函数去执行。如果 cache 找不到就找一下 boy 方法分发表(
methodList
)。如果分发表找不到就到超类的分发表去找,一直找,直到找到NSObject类为止。
如果还找不到就要面临以下两种情况了:
使用
[zen playBasketball]
的方式调用方法``,编译器会在编译阶段帮你做好方法的检索,检索不到会立刻报错。使用
[zen performSelector:@selector(playBasketball)]
的方式调用方法,编译器只会做出提醒,可以进行编译,需要到运行时才能确定对象是否接受指定的消息,开始进入消息的转发了,后面会提到。
有点类似于自己搞不定的东西就丢给别人来搞咯,是不是很精髓?
Runtime 的消息转发流程
动态解析
当 Runtime 系统在 Cache 和方法分发表中(包括超类)找不到要执行的方法时,Runtime会调用 resolveInstanceMethod:
或 resolveClassMethod:
来给程序员一次动态添加方法实现的机会。我们需要用 class_addMethod
函数完成向特定类添加特定方法实现的操作:
(假设 zen 的 playBasketball 方法尚未实现)
1 | void playBasketballFunc(id self, SEL _cmd) |
重定向
如果以上方法没有做处理,runtime会调用 -(id)forwardingTargetForSelector:(SEL)aSelector
方法。
如果该方法返回了一个非nil(也不能是self)的对象,而且该对象实现了这个方法,那么这个对象就成了消息的接收者,消息就被重定向到该对象。
适用情况:通常在对象内部使用,让内部的另外一个对象处理消息,在外面看起来就像是该对象处理了消息。
比如:zen 让好朋友 xiaoming 来接收这个消息。
1 | - (id)forwardingTargetForSelector:(SEL)aSelector |
如果此方法返回nil或self,则会进入消息转发机制(forwardInvocation:);否则将向返回的对象重新发送消息。
完整消息转发
在 - (void)forwardInvocation:(NSInvocation *)anInvocation
方法中选择转发消息的对象,其中anInvocation对象封装了未知消息的所有细节,并保留调用结果发送到原始调用者。
比如:zen 将消息完整转发給老师 Kobe 来处理
1 | - (void)forwardInvocation:(NSInvocation *)anInvocation |
该消息的唯一参数是个 NSInvocation类型 的对象——该对象封装了原始的消息和消息的参数。我们可以实现forwardInvocation:
方法来对不能处理的消息做一些默认的处理,也可以将消息转发给其他对象来处理,而不抛出错误。
在 forwardInvocation:
消息发送前,Runtime系统会向对象发送 methodSignatureForSelector:
消息,并取到返回的方法签名用于生成 NSInvocation
对象。所以我们在重写 forwardInvocation:
的同时也要重写 methodSignatureForSelector:
方法,否则会抛异常。
如果在以上三个方法都没有处理未知消息,则会引发异常
下面的示意图很好的解释了这个过程:
Runtime 相关术语
之所以没有一开始就对这一部分进行分析与讨论,是因为觉得这一部分涉及一些源码,有点像冷了的馒头,有点难嚼;但是因为理解 runtime 的需要,我们又不得不“嚼”。所以放在了最后。
这部分也是参考了各方资料啊。
SEL
SEL
是 selector 在Objc中的表示类型。
selector 是方法选择器,可以理解为区分方法的标志,而这个标志的数据结构是SEL
:
1 | typedef struct objc_selector *SEL; |
它就是个映射到方法的C字符串,你可以用 Objc 编译器命令@selector()或者 Runtime 系统的sel_registerName函数来获得一个SEL类型的方法选择器。
有时候我们可能会抱怨基于 Objc 的 Cocoa 函数又长又难记,其实是有原因的:不同类中相同名字的方法所对应的方法选择器是相同的,即使方法名字相同而变量类型不同也会导致它们具有相同的方法选择器,所以 Objc 函数命名有时会带上参数类型。
id
id
是一个指向类实例的指针:
1 | typedef struct objc_object *id; |
objc_object结构体包含一个isa指针:
1 | struct objc_object { Class isa; }; |
要谨记的是,isa 指针不总是指向实例对象所属的类,不能依靠它来确定类型,而是应该用 class 方法来确定实例对象的类。
Class
之所以说isa是指针是因为 Class
其实是一个指向 objc_class 结构体的指针:
1 | typedef struct objc_class *Class; |
objc_class 具体结构如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16struct objc_class {
Class isa OBJC_ISA_AVAILABILITY;
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;
} OBJC2_UNAVAILABLE;
可以看到运行时一个类还关联了它的超类指针,类名,成员变量,方法,缓存,还有附属的协议。
在 objc_class 结构体中:
ivars 是 objc_ivar_list 指针;methodLists 是指向 objc_method_list 指针的指针。也就是说可以动态修改 *methodLists 的值来添加成员方法,这也是Category实现的原理,同样解释了Category不能添加属性的原因。
需要注意一点的是:
objc_class 中也有一个 isa 对象,这是因为一个 ObjC 类本身同时也是一个对象。
为了处理类和对象的关系,runtime 库创建了一种叫做元类 (Meta Class) 的东西,类对象所属类型就叫做元类,它用来表述类对象本身所具备的元数据。类方法就定义于此处,因为这些方法可以理解成类对象的实例方法。每个类仅有一个类对象,而每个类对象仅有一个与之相关的元类。
当你发出一个类似 [NSObject alloc] 的消息时,你事实上是把这个消息发给了一个类对象 (Class Object) ,这个类对象必须是一个元类的实例,而这个元类同时也是一个根元类 (root meta class) 的实例。所有的元类最终都指向根元类为其超类。所有的元类的方法列表都有能够响应消息的类方法。所以当 [NSObject alloc] 这条消息发给类对象的时候,objc_msgSend()会去它的元类里面去查找能够响应消息的方法,如果找到了,然后对这个类对象执行方法调用。
上图实线是 super_class
指针,虚线是 isa
指针。 有趣的是根元类的超类是 NSObject,而 isa 指向了自己,而 NSObject 的超类为nil,也就是它没有超类。
Method
Method
是一种代表类中的某个方法的类型:
1 | typedef struct objc_method *Method; |
objc_method 它存储了方法名,方法类型和方法实现:1
2
3
4
5struct objc_method {
SEL method_name OBJC2_UNAVAILABLE;
char *method_types OBJC2_UNAVAILABLE;
IMP method_imp OBJC2_UNAVAILABLE;
}
方法名类型为 SEL,前面提到过相同名字的方法即使在不同类中定义,它们的方法选择器也相同。
方法类型 method_types 是个 char 指针,其实存储着方法的参数类型和返回值类型。
method_imp 指向了方法的实现,本质上是一个函数指针,下面会详细讲到。
IMP
IMP
在objc.h中的定义是:
1 | typedef id (*IMP)(id, SEL, ...); |
它就是一个函数指针,这是由编译器生成的。当你发起一个 ObjC 消息之后,最终它会执行的那段代码,就是由这个函数指针指定的。而 IMP 这个函数指针就指向了这个方法的实现。
IMP
指向的方法与 objc_msgSend 函数类型相同,参数都包含 id 和 SEL 类型。每个方法名都对应一个 SEL 类型的方法选择器,而每个实例对象中的 SEL 对应的方法实现肯定是唯一的,通过一组 id 和 SEL参数就能确定唯一的方法实现地址。
Cache
在runtime.h中 Cache
的定义如下:
1 | typedef struct objc_cache *Cache |
Cache
为方法调用的性能进行优化,通俗地讲,每当实例对象接收到一个消息时,它不会直接在 isa 指向的类的方法列表中遍历查找能够响应消息的方法,因为这样效率太低了,而是优先在 Cache
中查找。Runtime 系统会把被调用的方法存到 Cache
中(理论上讲一个方法如果被调用,那么它有可能今后还会被调用),下次查找的时候效率更高。
Runtime 实战篇
这个先留一个坑,等我后续慢慢更新吧~