Runtime 又叫运行时,是一套底层的 C 语言 API,其为 iOS 内部的核心之一,我们平时编写的 OC 代码,底层都是基于它来实现的!
简介
1 |
|
Objective-C 是一门动态语言,它会将一些工作放在代码运行时才处理而并非编译时。也就是说,有很多类和成员变量在我们编译的时是不知道的,而在运行时,我们所编写的代码会转换成完整的确定的代码运行。
因此,编译器是不够的,我们还需要一个运行时系统(Runtime system)来处理编译后的代码。
Runtime 基本是用 C 和汇编写的。
了解Runtime的方式
Objc 在三种层面上与 Runtime 系统进行交互:
通过 Objective-C 源代码
通过 Foundation 框架的 NSObject 类定义的方法
通过对 Runtime 库函数的直接调用
通过 Objective-C 源代码
多数情况我们只需要编写 OC 代码即可,Runtime 系统自动在幕后搞定一切,还记得简介中如果我们调用方法,编译器会将 OC 代码转换成运行时代码,在运行时确定数据结构和函数。
通过 Foundation 框架的 NSObject 类定义的方法
Cocoa 程序中绝大部分类都是 NSObject 类的子类,所以都继承了 NSObject 的行为。(NSProxy
类时个例外,它是个抽象超类)
一些情况下,NSObject 类仅仅定义了完成某件事情的模板,并没有提供所需要的代码。例如 -description
方法,该方法返回类内容的字符串表示,该方法主要用来调试程序。NSObject 类并不知道子类的内容,所以它只是返回类的名字和对象的地址,NSObject 的子类可以重新实现。
-class
方法返回对象的类;-isKindOfClass:
和-isMemberOfClass:
方法检查对象是否存在于指定的类的继承体系中(是否是其子类或者父类或者当前类的成员变量);
-respondsToSelector:
检查对象能否响应指定的消息;-conformsToProtocol:
检查对象是否实现了指定协议类的方法;-methodForSelector:
返回指定方法实现的地址
通过对 Runtime 库函数的直接调用
Runtime 系统是具有公共接口的动态共享库。头文件存放于/usr/include/objc
目录下,这意味着我们使用时只需要引入objc/Runtime.h
头文件即可。
许多函数可以让你使用纯 C 代码来实现 Objc 中同样的功能。除非是写一些 Objc 与其他语言的桥接或是底层的 debug 工作,你在写 Objc 代码时一般不会用到这些 C 语言函数。对于公共接口都有哪些,后面会讲到。我将会参考苹果官方的 API 文档。
Runtime 详解
要想全面了解 Runtime 机制,我们必须先了解 Runtime 的一些术语,他们都对应着数据结构。
SEL
它是selector
在 Objc 中的表示(Swift 中是 Selector 类)
Selector
是方法选择器,其实作用就和名字一样,日常生活中,我们通过人名辨别谁是谁,注意 Objc 在相同的类中不会有命名相同的两个方法
selector
对方法名进行包装,以便找到对应的方法实现
数据结构:
1 |
|
我们可以看出它是个映射到方法的 C 字符串,你可以通过 Objc 编译器器命令@selector()
或者 Runtime 系统的 sel_registerName
函数来获取一个 SEL 类型的方法选择器。
注意
:
不同类中相同名字的方法所对应的 selector 是相同的,由于变量的类型不同,所以不会导致它们调用方法实现混乱。
id
id 是一个参数类型,它是指向某个类的实例的指针。定义如下:
1 |
|
以上定义,看到 objc_object
结构体包含一个 isa
指针,根据 isa
指针就可以找到对象所属的类。
注意
:isa
指针在代码运行时并不总指向实例对象所属的类型,所以不能依靠它来确定类型,要想确定类型还是需要用对象的 -class
方法。
PS:KVO 的实现机理就是将被观察对象的 isa 指针指向一个中间类而不是真实类型,
Class
Class 其实是指向 objc_class
结构体的指针
1 |
|
objc_class 的数据结构如下:
1 |
|
从 objc_class
可以看到,一个运行时类中关联了它的父类指针、类名、成员变量、方法、缓存以及附属的协议。
其中 objc_ivar_list
和 objc_method_list
分别是成员变量列表和方法列表:
1 |
|
由此可见,我们可以动态修改 *methodList
的值来添加成员方法,这也是 Category 实现的原理,同样解释了 Category 不能添加属性的原因。这里可以参考下美团技术团队的文章:深入理解 Objective-C: Category。
objc_ivar_list
结构体用来存储成员变量的列表,而 objc_ivar
则是存储了单个成员变量的信息;
‘同理,objc_method_list
结构体存储着方法数组的列表,而单个方法的信息则由 objc_method
结构体存储
值得注意的时,objc_class
中也有一个 isa 指针,这说明 Objc 类本身也是一个对象。为了处理类和对象的关系,Runtime 库创建了一种叫做 Meta Class(元类)
的东西,类对象所属的类就叫做元类。Meta Class
表述了类对象本身所具备的元数据。
我们所熟悉的类方法,就源自于 Meta Class
。我们可以理解为类方法就是类对象的实例方法。每个类仅有一个类对象,而每个类对象仅有一个与之相关的元类。
当你发出一个类似 [NSObject alloc](类方法)
的消息时,实际上,这个消息被发送给了一个类对象(Class Object)
,这个类对象必须是一个元类的实例,而这个元类同时也是一个根元类(Root Meta Class)
的实例。所有元类的 isa 指针最终都指向根元类。
所以当 [NSObject alloc]
这条消息发送给类对象的时候,运行时代码 objc_msgSend()
会去它元类中查找能够响应消息的方法实现,如果找到了,就会对这个类对象执行方法调用
上图实现是 super_class
指针,虚线时 isa 指针。而根元类的父类是 NSObject
,isa指向了自己。而 NSObject 没有父类。
最后 objc_class
中还有一个 objc_cache
,缓存,它的作用很重要,后面会提到。
Method
Method 代表类中某个方法的类型
1 |
|
objc_method
存储了方法名,方法类型和方法实现:
方法名类型为 SEL
方法类型method_types
是个 char 指针,存储方法的参数类 型和返回值类型method_imp
指向了方法的实现,本质是一个函数指针
Ivar
Ivar 是表示成员变量的类型。
1 |
|
其中 ivar_offset
是基地址偏移字节
IMP
IMP在objc.h中的定义是:
1 |
|
IMP在objc.h中的定义是:
typedef id (*IMP)(id, SEL, ...);
它就是一个函数指针,这是由编译器生成的。当你发起一个 ObjC 消息之后,最终它会执行的那段代码,就是由这个函数指针指定的。而 IMP
这个函数指针就指向了这个方法的实现。
如果得到了执行某个实例某个方法的入口,我们就可以绕开消息传递阶段,直接执行方法,这在后面 Cache 中会提到。
你会发现 IMP 指向的方法与 objc_msgSend
函数类型相同,参数都包含 id
和 SEL
类型。每个方法名都对应一个 SEL
类型的方法选择器,而每个实例对象中的 SEL
对应的方法实现肯定是唯一的,通过一组 id
和 SEL
参数就能确定唯一的方法实现地址。
而一个确定的方法也只有唯一的一组 id
和 SEL
参数
Cache
Cache 定义如下:
1 |
|
Cache 为方法调用的性能进行优化,每当实例对象接收到一个消息时,它不会直接在 isa
指针指向的类的方法列表中遍历查找能够响应的方法,因为每次都要查找效率太低了,而是优先在 Cache 中查找。
Runtime 系统会把被调用的方法存到 Cache 中,如果一个方法被调用,那么它有可能今后还会被调用,下次查找的时候就会效率更高。就像计算机组成原理中 CPU 绕过主存先访问 Cache 一样。
Property
1 |
|
可以通过class_copyPropertyList 和 protocol_copyPropertyList 方法获取类和协议中的属性:
1 |
|
返回的是属性列表,列表中每个元素都是一个 objc_property_t 指针
1 |
|
以上是一个 Person 类,有3个属性。让我们用上述方法获取类的运行时属性。
1 |
|
打印结果:
1 |
|
property_getName
用来查找属性的名称,返回 c 字符串。property_getAttributes
函数挖掘属性的真实名称和 @encode 类型,返回 c 字符串。
1 |
|
·class_getProperty
和 protocol_getProperty
通过给出属性名在类和协议中获得属性的引用。
消息
消息发送步骤:
详解:
首先检测这个 selector 是不是要忽略。比如 Mac OS X 开发,有了垃圾回收就不理会 retain,release 这些函数。
检测这个 selector 的 target 是不是 nil,Objc 允许我们对一个 nil 对象执行任何方法不会 Crash,因为运行时会被忽略掉。
如果上面两步都通过了,那么就开始查找这个类的实现 IMP,先从 cache 里查找,如果找到了就运行对应的函数去执行相应的代码。
如果 cache 找不到就找类的方法列表中是否有对应的方法。
如果类的方法列表中找不到就到父类的方法列表中查找,一直找到 NSObject 类为止。
如果还找不到,就要开始进入动态方法解析了,后面会提到
在消息的传递中,编译器会根据情况在 objc_msgSend
, objc_msgSend_stret
, objc_msgSendSuper
, objc_msgSendSuper_stret
这四个方法中选择一个调用。
- 如果消息是传递给父类,那么会调用名字带有 Super 的函数,
- 如果消息返回值是数据结构而不是简单值时,会调用名字带有 stret 的函数。
方法中的隐藏参数
1 |
|
其实,这也是 Runtime 系统的作用,self 实在方法运行时被动态传入的。
当 objc_msgSend
找到方法对应实现时,它将直接调用该方法实现,并将消息中所有参数都传递给方法实现,同时,它还将传递两个隐藏参数:
接受消息的对象(self 所指向的内容,当前方法的对象指针)
方法选择器(_cmd 指向的内容,当前方法的 SEL 指针)
因为在源代码方法的定义中,我们并没有发现这两个参数的声明。它们时在代码被编译时被插入方法实现中的。尽管这些参数没有被明确声明,在源代码中我们仍然可以引用它们。
这两个参数中, self更实用。它是在方法实现中访问消息接收者对象的实例变量的途径。
这时我们可能会想到另一个关键字 super ,实际上 super 关键字接收到消息时,编译器会创建一个 objc_super
结构体:
1 |
|
这个结构体指明了消息应该被传递给特定的父类。 receiver 仍然是 self 本身,当我们想通过 [super class]
获取父类时,编译器其实是将指向 self 的 id 指针和 class 的 SEL 传递给了 objc_msgSendSuper
函数。
只有在 NSObject 类中才能找到 class 方法,然后 class 方法底层被转换为 object_getClass()
, 接着底层编译器将代码转换为 objc_msgSend(objc_super->receiver, @selector(class))
,
传入的第一个参数是指向 self 的 id 指针,与调用 [self class]
相同,所以我们得到的永远都是 self 的类型。因此你会发现:
// 这句话并不能获取父类的类型,只能获取当前类的类型名
1 |
|
获取方法地址
NSObject 类中有一个实例方法:methodForSelector
,你可以用它来获取某个方法选择器对应的 IMP ,举个例子:
1 |
|
当方法被当做函数调用时,两个隐藏参数也必须明确给出,上面的例子调用了1000次函数,你也可以尝试给 target 发送1000次 setFilled: 消息会花多久。
虽然可以更高效的调用方法,但是这种做法很少用,除非时需要持续大量重复调用某个方法的情况,才会选择使用以免消息发送泛滥。
注意:methodForSelector:方法是由 Runtime 系统提供的,而不是 Objc 自身的特性
动态方法解析
你可以动态提供一个方法实现。如果我们使用关键字 @dynamic 在类的实现文件中修饰一个属性,表明我们会为这个属性动态提供存取方法,编译器不会再默认为我们生成这个属性的 setter 和 getter 方法了,需要我们自己提供。
@dynamic propertyName;
这时,我们可以通过分别重载 resolveInstanceMethod:
和 resolveClassMethod:
方法添加实例方法实现和类方法实现。
当 Runtime 系统在 Cache 和类的方法列表(包括父类)中找不到要执行的方法时,Runtime 会调用 resolveInstanceMethod:
或 resolveClassMethod:
来给我们一次动态添加方法实现的机会。我们需要用 class_addMethod
函数完成向特定类添加特定方法实现的操作:
1 |
|
上面的例子为 resolveThisMethodDynamically
方法添加了实现内容,就是 dynamicMethodIMP
方法中的代码。其中 “v@:” 表示返回值和参数,这个符号表示的含义见:Type Encoding
注意:
动态方法解析会在消息转发机制侵入前执行,动态方法解析器将会首先给予提供该方法选择器对应的 IMP 的机会。如果你想让该方法选择器被传送到转发机制,就让 resolveInstanceMethod:
方法返回 NO。
重定向
消息转发机制执行前,Runtime 系统允许我们替换消息的接收者为其他对象。通过 - (id)forwardingTargetForSelector:(SEL)aSelector
方法。
1 |
|
如果此方法返回 nil 或者 self,则会计入消息转发机制(forwardInvocation:
),否则将向返回的对象重新发送消息。
转发
当动态方法解析不做处理返回 NO 时,则会触发消息转发机制。这时 forwardInvocation:
方法会被执行,我们可以重写这个方法来自定义我们的转发逻辑:
1 |
|
唯一参数是个 NSInvocation
类型的对象,该对象封装了原始的消息和消息的参数。我们可以实现 forwardInvocation:
方法来对不能处理的消息做一些处理。也可以将消息转发给其他对象处理,而不抛出错误。
注意:
参数 anInvocation 是从哪来的?
在 forwardInvocation: 消息发送前,Runtime 系统会向对象发送methodSignatureForSelector: 消息,并取到返回的方法签名用于生成 NSInvocation 对象。所以重写
forwardInvocation: 的同时也要重写 methodSignatureForSelector: 方法,否则会抛异常。
消息转发
当一个对象由于没有相应的方法实现而无法相应某消息时,运行时系统将通过 forwardInvocation:
消息通知该对象。每个对象都继承了 forwardInvocation:
方法。但是, NSObject 中的方法实现只是简单的调用了 doesNotRecognizeSelector:
。通过实现自己的 forwardInvocation:
方法,我们可以将消息转发给其他对象。
forwardInvocation:
方法就是一个不能识别消息的分发中心,将这些不能识别的消息转发给不同的接收对象,或者转发给同一
个对象,再或者将消息翻译成另外的消息,亦或者简单的“吃掉”某些消息,因此没有响应也不会报错。这一切都取决于方法的具体实现。
转发和多继承
转发和继承相似,可用于为 Objc 编程添加一些多继承的效果。就像下图那样,一个对象把消息转发出去,就好像它把另一个对象中的方法接过来或者“继承”过来一样。
这使得在不同继承体系分支下的两个类可以实现“继承”对方的方法,在上图中 Warrior
和 Diplomat
没有继承关系,但是 Warrior
将 negotiate
消息转发给了 Diplomat
后,就好似 Diploma
t 是 Warrior
的超类一样。
消息转发弥补了 Objc 不支持多继承的性质,也避免了因为多继承导致单个类变得臃肿复杂
转发与继承
虽然转发可以实现继承的功能,但是 NSObject 还是必须表面上很严谨,像 respondsToSelector:
和 isKindOfClass:
这类方法只会考虑继承体系,不会考虑转发链。
如果上图中的 Warrior 对象被问到是否能响应 negotiate消息:
1 |
|
回答当然是 NO, 尽管它能接受 negotiate 消息而不报错,因为它靠转发消息给 Diplomat 类响应消息。
如果你就是想要让别人以为 Warrior 继承到了 Diplomat 的 negotiate 方法,你得重新实现 respondsToSelector:
和 isKindOfClass:
来加入你的转发算法:
1 |
|
除了 respondsToSelector:
和 isKindOfClass:
之外,instancesRespondToSelector:
中也应该写一份转发算法。如果使用了协议,conformsToProtocol:
同样也要加入到这一行列中。
如果一个对象想要转发它接受的任何远程消息,它得给出一个方法标签来返回准确的方法描述 methodSignatureForSelector:
,这个方法会最终响应被转发的消息。从而生成一个确定的 NSInvocation
对象描述消息和消息参数。这个方法最终响应被转发的消息。它需要像下面这样实现:
1 |
|
健壮的实例变量(Non Fragile ivars)
在 Runtime 的现行版本中,最大的特点就是健壮的实例变量了。当一个类被编译时,实例变量的内存布局就形成了,它表明访问类的实例变量的位置。实例变量一次根据自己所占空间而产生位移
上图左是 NSObject 类的实例变量布局。右边是我们写的类的布局。这样子有一个很大的缺陷,就是缺乏拓展性。哪天苹果更新了 NSObject 类的话,就会出现问题:
我们自定义的类的区域和父类的区域重叠了。只有苹果将父类改为以前的布局才能拯救我们,但这样导致它们不能再拓展它们的框架了,因为成员变量布局被固定住了。在脆弱的实例变量(Fragile ivar)环境下,需要我们重新编译继承自 Apple 的类来恢复兼容。
在健壮的实例变量下,编译器生成的实例变量布局跟以前一样,但是当 Runtime 系统检测到与父类有部分重叠时它会调整你新添加的实例变量的位移,那样你再子类中新添加的成员变量就被保护起来了。
注意:
在健壮的实例变量下,不要使用 siof(SomeClass)
,而是用 class_getInstanceSize([SomeClass class])
代替;也不要使用 offsetof(SomeClass, SomeIvar)
,而要使用 ivar_getOffset(class_getInstanceVariable([SomeClass class], "SomeIvar"))
来代替。
Runtime 在iOS中的应用
KVO
KVO,即:Key-Value Observing
,它提供一种机制,当指定的对象的属性被修改后,则其观察者就会接受到通知。简单的说就是每次指定的被观察的对象的属性被修改后,KVO就会自动通知相应的观察者了。
示例:
1 |
|
打印结果:
1 |
|
实现原理
当 p1.name 的值改变时,p1对象的 isa 指针会指向 NSKVONotifying_Person,意味着,在程序运行时,会动态生成一个 NSKVONotifying_Person 类,该类继承于 Person,而且该类中也有个 -setName: 方法,方法中在设置 name 的同时实现了:
1 |
|
分类
实现原理
无论我们有没有主动引入 Category 的头文件,Category 中的方法都会被添加进主类中。我们可以通过·- performSelector:
等
方式对Category
中的相应方法进行调用,之所以需要在调用的地方引入 Category 的头文件,只是为了“照顾”编译器同学的感受
1 |
|
通过 while 循环,我们遍历所有的 category,也就是参数 cats
中的 list
属性。对于每一个 category,得到它的方法列表 mlist
并存入 mlists
中。
换句话说,我们将所有 category
中的方法拼接到了一个大的二维数组中,数组的每一个元素都是装有一个 category
所有方法的容器
在 while 循环外,我们得到了拼接成的方法,此时需要与类原来的方法合并
1 |
|
这段代码很简单,其实就是先调用 realloc() 函数将原来的空间拓展,然后把原来的数组复制到后面,最后再把新数组复制到前面。
因此 category 中定义的同名方法不会替换类中原有的方法,但是对原方法的调用实际上会调用 category 中的方法。
Objective-C +load vs +initialize
+load
这样的调用方式就使得 +load
方法拥有了一个非常有趣的特性,那就是子类、父类和分类中的 +load
方法的实现是被区别对待的。也就是说如果子类没有实现 +load
方法,那么当它被加载时 runtime 是不会去调用父类的 +load
方法的。同理,当一个类和它的分类都实现了 +load
方法时,两个方法都会被调用。因此,我们常常可以利用这个特性做一些“邪恶”的事情,比如说方法混淆Method Swizzling。
+initialize
+initialize
方法是在类或它的子类收到第一条消息之前被调用的,这里所指的消息包括实例方法和类方法的调用。也就是说 +initialize
方法是以懒加载的方式被调用的,如果程序一直没有给某个类或它的子类发送消息,那么这个类的 +initialize
方法是永远不会被调用的。那这样设计有什么好处呢?好处是显而易见的,那就是节省系统资源,避免浪费。
因此,如果一个子类没有实现 +initialize 方法,那么父类的实现是会被执行多次的。有时候,这可能是你想要的;但如果我们想确保自己的 +initialize 方法只执行一次,避免多次执行可能带来的副作用时,我们可以使用下面的代码来实现:
1 |
|
对比
+load | +initialize | |
---|---|---|
调用时机 | 被添加到runtime时 | 收到第一条消息前,可能永远不调用 |
调用顺序 | 父类->子类->分类 | 父类->子类 |
调用次数 | 1次 | 多次 |
是否需要显式调用父类实现 | 否 | 否 |
是否沿用父类的实现 | 否 | 是 |
分类中的实现 | 类和分类都执行 | 覆盖类中的方法,只执行分类的实现 |
参考文章
iOS开发-Runtime详解(简书)
Runtime源码
KVO
深入理解Objective-C:Category
iOS成员属性和成员变量的区别