Runtime
一直被一些开发者津津乐道,它强大的API可以帮助你更好的理解OC的运行时机制,也是项目中不可缺少的“黑魔法”。本篇将结合一些优秀的Runtime
文章,简单介绍Runtime
原理以及其应用。
一、Runtime
运行时的机制
对于C语言,函数的调用在编译的时候会决定调用哪个函数。 在编译阶段,C语言调用未实现的函数就会报错。
对于
Objective-C
的函数,属于动态调用过程,在编译的时候并不能决定真正调用哪个函数,只有在真正运行的时候才会根据函数的名称找到对应的函数来调用。在编译阶段,Objective-C
可以调用任何函数,即使这个函数并未实现,只要声明过就不会报错。Objective-C
语言是一门动态语言,它将很多静态语言在编译和链接时期做的事放到了运行时来处理。这种动态语言的优势在于:我们写代码时更具灵活性,如我们可以把消息转发给我们想要的对象,或者随意交换一个方法的实现等。这种特性意味着
Objective-C
不仅需要一个编译器,还需要一个运行时系统来执行编译的代码。对于Objective-C
来说,这个运行时系统就像一个操作系统一样:它让所有的工作可以正常的运行。这个运行时系统即Objc Runtime
。Objc Runtime
其实是一个Runtime
库,它基本上是用C和汇编写的,这个库使得C语言有了面向对象的能力。
二、Runtime
运行时的作用
- 能获得某个类的所有成员变量
- 能获得某个类的所有属性
- 能获得某个类的所有方法
- 交换方法实现
- 能动态添加一个成员变量
- 能动态添加一个属性
- 字典转模型
Runtime
归档/反归档
三、Runtime
运行时的优点
- 实现多继承
Multiple Inheritance
Method Swizzling
- 面向切面编程
Aspect Oriented Programming
(在运行时,动态地将代码切入到类的指定方法、指定位置上的编程思想就是面向切面的编程。) Isa Swizzling
- 使用
Associated Object
关联对象 - 动态的添加方法
NSCoding
的自动归档和自动解档- 字典和模型互相转换
Runtime
的术语
先明白
Runtime
的一些常用术语
objc_object
:Objective-C
对象的定义,根据其isa
指针就可以顺藤摸瓜找到对象所属的类objc_class
:Objective-C
类的定义,类也是一种对象,类方法可以理解成类对象的实例方法。每个类仅有一个类对象,而每个类对象仅有一个与之相关的元类isa指针
:Objective-C
中,类和类的实例在本质上没有区别,都是对象,任何对象都有isa
指针,它指向类或元类(元类后面会讲解)。SEL
:SEL
方法选择器,是方法名selector
的指针。方法的selector
表示运行时方法的名字。Objective-C
在编译时,会依据每一个方法的名字、参数,生成一个唯一的整型标识(Int类型的地址),这个标识就是SEL
。IMP
:IMP
是一个函数指针,指向方法最终实现的首地址。SEL
就是为了查找方法的最终实现IMP
。Method
:用于表示类定义中的方法,它的结构体中包含一个SEL
和IMP
,相当于在SEL
和IMP
之间作了一个映射。- 消息机制:任何方法的调用本质就是发送一个消息。编译器会将消息表达式
[receiver message]
转化为一个消息函数objc_msgSend(receiver, selector)
。
objc_object
常见的id
它是一个指向类实例(objc_object
类型)的指针 typedef struct objc_object *id;
而objc_object
类型的结构如下
1 | struct objc_object { |
根据 isa
就可以顺藤摸瓜找到对象所属的类;
由此可见,所有的Objective-C
类和对象,在Runtime
层都是用struct
结构表示。
objc_class
类的定义
1 | /// An opaque type that represents an Objective-C class. |
objc_class
的定义
1 | struct objc_class { |
在版本objc4-680
的Runtime
源码中的数据结构定义中
1 | struct objc_class : objc_object { |
可以看到objc_class
是继承objc_object
的,说明ObjC
类本身同时也是一个对象。为了处理类和对象的关系,Runtime
库创建了一种叫做元类 (Meta Class
) 的概念,类对象所属类型就叫做元类,它用来表述类对象本身所具备的元数据(类方法等)
类方法可以理解成类对象的实例方法。每个类仅有一个类对象,而每个类对象仅有一个与之相关的元类
当你发出一个类似 [NSObject alloc]
的消息时,你事实上是把这个消息发给了一个类对象 (Class Object
) ,这个类对象必须是一个元类的实例,而这个元类同时也是一个根元类 (root meta class
) 的实例。所有的元类最终都指向根元类为其超类。
所有的元类的方法列表都有能够响应消息的类方法。所以当 [NSObject alloc]
这条消息发给类对象的时候,objc_msgSend()
会去它的元类里面去查找能够响应消息的方法,如果找到了,然后对这个类对象执行方法调用。
isa
根据 isa
就可以顺藤摸瓜找到对象所属的类
PS: isa
指针不总是指向实例对象所属的类,不能依靠它来确定类型,而是应该用 class
方法来确定实例对象的类。因为KVO
的实现机理就是将被观察对象的 isa 指针指向一个中间类而不是真实的类
SEL
SEL
区分方法的 ID
,而这个 ID
的数据结构是SEL
,其实它就是个映射到方法的C字符串,你可以用 Objc
编译器命令 @selector()
或者 Runtime
系统的 sel_registerName
函数来获得一个 SEL
类型的方法选择器。
它是一个模仿C的构造指针类型的对象,可以定义很多方法指针。 常作为形参。 用于运行时或者多类之间隔文件 传递方法。
@selector
是查找当前类的实例方法,而[object @selector(方法名:方法参数..) ]
;是取object所属类的实例方法.- 查找类方法时,除了方法名,方法参数也查询条件之一.
- 可以运行中用
SEL
变量反向查出方法名字字符串
- 方法的存储位置
- 每个类的方法列表都存储在类对象中 (
struct objc_method_list **methodLists
) - 每个方法都有一个与之对应的
SEL
类型的对象(方法名的指针)根据SEL
对象就可以找到对应方法的地址,进而调用该方法。
SEL
对象的创建
1 | SEL sel = @selector(testMethodName); |
SEL
对象转NSString
1 | NSString *testSelStr = NSStringFromSelector(sel2); |
IMP
IMP
实际上是一个函数指针,指向方法实现的首地址。其定义如下:
1 | if !OBJC_OLD_DISPATCH_PROTOTYPES |
这个函数使用当前 CPU 架构实现的标准的 C 调用约定。第一个参数是指向 self
的指针(如果是实例方法,则是类实例的内存地址;如果是类方法,则是指向元类的指针),第二个参数是方法选择器(selector
),接下来是方法的实际参数列表。
前面介绍过的 SEL
就是为了查找方法的最终实现 IMP
的。由于每个方法对应唯一的 SEL
,因此我们可以通过 SEL
方便快速准确地获得它所对应的 IMP
,查找过程将在下面讨论。取得 IMP
后,我们就获得了执行这个方法代码的入口点,此时,我们就可以像调用普通的 C 语言函数一样来使用这个函数指针了。
通过取得 IMP
,我们可以跳过 Runtime
的消息传递机制,直接执行 IMP
指向的函数实现,这样省去了 Runtime
消息传递过程中所做的一系列查找操作,会比直接向对象发送消息高效一些。
Method
介绍完 SEL
和 IMP
,我们就可以来讲讲 Method
了。Method
用于表示类定义中的方法,则定义如下:
1 | typedef struct objc_method *Method; |
我们可以看到该结构体中包含一个 SEL
和 IMP
,实际上相当于在 SEL
和 IMP
之间作了一个映射。有了 SEL
,我们便可以找到对应的 IMP
,从而调用方法的实现代码。
objc_method_description
定义了一个Objective-C
方法,其定义如下:
1 | struct objc_method_description { |
四、消息机制
消息发送(
Messaging
)是Runtime
通过selector
快速查找IMP
的过程,有了函数指针就可以执行对应的方法实现;**消息转发(Message Forwarding
)是在查找IMP
失败后执行一系列转发流程的慢速通道,如果不作转发处理,则会打日志和抛出异常。
**
当执行了[receiver message]
的时候,相当于向receiver
发送一条消息message
。Runtime
会根据reveiver
能否处理这条message
,从而做出不同的反应。
方法的调用流程
消息直到运行时才绑定到方法的实现上。编译器会将消息表达式[receiver message]
转化为一个消息函数,即objc_msgSend(receiver, selector)
。
objc_msgSend
Objective-C 方法的调用,会转换成消息发送的代码,如 id objc_msgSend(id self, SEL op, …);
1 | MyClass *myObject = [[MyClass alloc] initWithString:@"someString"]; |
上述代码会被编译器转换成:
1 | class myClass = objc_getClass("MyClass"); |
objc_msgSend
做了如下事情:
- 检测这个
selector
是不是要忽略的,或者是不是 nil 对象,是则忽略。 - 如果满足查找条件,通过对象的
isa
指针获取类的结构体。开始查找这个类的IMP
,先从cache
里面找,完了找得到就跳到对应的函数去执行。 - 如果
cache
找不到就在类的方法分发表objc_method_list
中查找 - 如果没有找到
selector
,则通过objc_msgSend
结构体中指向父类的指针找到父类,并在父类的方法表里查找方法的selector
。 - 依次会一直找到
NSObject
。 - 一旦找到
selector
,就会获取到方法实现IMP
。 - 传入相应的参数来执行方法的具体实现。
- 如果最终没有定位到
selector
,就会走消息转发流程。
重定向
在消息转发机制执行前,Runtime
系统会再给我们一次偷梁换柱的机会,即通过重载- (id)forwardingTargetForSelector:(SEL)aSelector
方法替换消息的接受者为其他对象
消息转发机制
以 [receiver message]
的方式调用方法,如果receiver
无法响应message
,编译器会报错。但如果是以performSelector
来调用,则需要等到运行时才能确定object
是否能接收message
消息。如果不能,则程序崩溃。
当我们不能确定一个对象是否能接收某个消息时,会先调用respondsToSelector:
来判断一下
respondsToSelector
- 如果不使用
respondsToSelector:
来判断,那么这就可以用到“消息转发”机制。 - 当对象无法接收消息,就会启动消息转发机制,通过这一机制,告诉对象如何处理未知的消息。
这样就可以采取一些措施,让程序执行特定的逻辑,从而避免崩溃。措施分为三个步骤。
1. 动态方法解析
对象接收到未知的消息时,首先会调用所属类的类方法+resolveInstanceMethod:(实例方法)
或 者+resolveClassMethod:(类方法)
。
在这个方法中,我们有机会为该未知消息新增一个”处理方法”。使用该“处理方法”的前提是已经实现,只需要在运行时通过class_addMethod函数,动态的添加到类里面就可以了。代码如下。
1 | class_addMethod |
2. 备用接收者
如果在上一步无法处理消息,则Runtime会继续调下面的方法。
forwardingTargetForSelector
如果这个方法返回一个对象,则这个对象会作为消息的新接收者。注意这个对象不能是self自身,否则就是出现无限循环。如果没有指定对象来处理aSelector,则应该 return [super forwardingTargetForSelector:aSelector]。
但是我们只将消息转发到另一个能处理该消息的对象上,无法对消息进行处理,例如操作消息的参数和返回值。
3. 完整消息转发
如果在上一步还是不能处理未知消息,则唯一能做的就是启用完整的消息转发机制。此时会调用以下方法:
forwardInvocation
这是最后一次机会将消息转发给其它对象。创建一个表示消息的NSInvocation对象,把与消息的有关全部细节封装在anInvocation中,包括selector,目标(target)和参数。在forwardInvocation 方法中将消息转发给其它对象。forwardInvocation:
方法的实现有两个任务:
1 | a. 定位可以响应封装在anInvocation中的消息的对象。 |
在这个方法中我们可以实现一些更复杂的功能,我们可以对消息的内容进行修改。另外,若发现消息不应由本类处理,则应调用父类的同名方法,以便继承体系中的每个类都有机会处理。
另外,必须重写下面的方法:
1 | methodSignatureForSelector |
消息转发机制从这个方法中获取信息来创建NSInvocation对象。完整的示例如下:
NSObject
的forwardInvocation
方法只是调用了doesNotRecognizeSelector
方法,它不会转发任何消息。如果不在以上所述的三个步骤中处理未知消息,则会引发异常。forwardInvocation
就像一个未知消息的分发中心,将这些未知的消息转发给其它对象。或者也可以像一个运输站一样将所有未知消息都发送给同一个接收对象,取决于具体的实现。
消息的转发机制可以用下图来帮助理解。
消息的转发机制
=======================================================================
可能很多童鞋初学 Objective-C
时会把[receiver message]
当成简单的方法调用,而无视了“发送消息”这句话的深刻含义。其实 [receiver message]
会被编译器转化为:
1 | objc_msgSend(receiver, selector) |
如果消息含有参数,则为:
1 | objc_msgSend(receiver, selector, arg1, arg2, ...) |
如果消息的接收者能够找到对应的 selector
,那么就相当于直接执行了接收者这个对象的特定方法;否则,消息要么被转发,或是临时向接收者动态添加这个 selector
对应的实现内容,要么就干脆玩完崩溃掉。
现在可以看出 [receiver message]
真的不是一个简简单单的方法调用。因为这只是在编译阶段确定了要向接收者发送 message 这条消息,而 receive 将要如何响应这条消息,那就要看运行时发生的情况来决定了。
从上述代码中可以看到,objc_msgSend
(就arm平台而言)的消息分发分为以下几个步骤:
- 判断
receiver
是否为nil,也就是objc_msgSend的第一个参数self,也就是要调用的那个方法所属对象 - 从缓存里寻找,找到了则分发,否则
- 利用objc-class.mm中
_class_lookupMethodAndLoadCache3
(为什么有个这么奇怪的方法。本文末尾会解释)方法去寻找selector
- 如果支持GC,忽略掉非GC环境的方法(retain等)
- 从本
class
的method list
寻找selector
,如果找到,填充到缓存中,并返回selector
,否则 - 寻找父类的
method list
,并依次往上寻找,直到找到selector,填充到缓存中,并返回selector,否则 - 调用
_class_resolveMethod
,如果可以动态resolve为一个selector
,不缓存,方法返回,否则 - 转发这个selector,否则
- 报错,抛出异常
当一个方法在比较“上层”的类中,用比较“下层”(继承关系上的上下层)对象去调用的时候,如果没有缓存,那么整个查找链是相当长的。就算方法是在这个类里面,当方法比较多的时候,每次都查找也是费事费力的一件事情。
考虑下面的一个调用过程:
1 | for ( int i = 0; i < 100000; ++i) { |
当我们需要去调用一个方法数十万次甚至更多地时候,查找方法的消耗会变的非常显著。
就算我们平常的非大规模调用,除非一个方法只会调用一次,否则缓存都是有用的。在运行时,那么多对象,那么多方法调用,节省下来的时间也是非常可观的。
追本溯源,何为方法缓存
本着源码面前,了无秘密的原则,我们看下源码中的方法缓存到底是什么,在objc-cache.mm
中,objc_cache
的定义如下:
1 | struct objc_cache { |
嗯,objc_cache
的定义看起来很简单,它包含了下面三个变量:
1)、mask
:可以认为是当前能达到的最大index(从0开始的),所以缓存的size(total)是mask+1
2)、occupied
:被占用的槽位,因为缓存是以散列表的形式存在的,所以会有空槽,而occupied表示当前被占用的数目
3)、buckets
:用数组表示的hash表,cache_entry
类型,每一个cache_entry
代表一个方法缓存
(buckets
定义在objc_cache
的最后,说明这是一个可变长度的数组)
而cache_entry
的定义如下:
1 | typedef struct { |
cache_entry
定义也包含了三个字段,分别是:
1)、name,被缓存的方法名字
2)、unused,保留字段,还没被使用。
3)、imp,方法实现
Runtime实战
我们知道App在项目开发过程中。由于不断迭代的业务逻辑和增加的模块,由于网络性能或者代码质量的或者项目Bug等问题,会出现App报出异常,出现崩溃的问题,如果次数多了会非常影响用户体验,在关键的模块,比如支付,登录等等,需要写很多校验就是防止出现异常。那么如何使用一种有效的手段来减少异常呢?
其实Runtime
就可以做到这点,在OC中,方法的调用在运行时会被编译成一个消息,在这个消息中不断去顺着isa指针在类或父类的元类的方法列表methodLists中寻找接受者,如果没有找到方法,就会开启消息转发机制。直接调用[reciever methodName]
method_invoke
Calls the implementation of a specified method.
1 | Method method= class_getInstanceMethod([Son class], @selector(getNameWithfamily:)); |
Runtime
应用举例
设置按钮的快速点击的时间间隔
建一个UIControl
的分类,使用属性关联添加属性,并且交换sendAction:to:forEvent:
的方法实现,
1 | // |
Runtime的使用
Runtime
的使用:获取属性列表,获取成员变量列表,获得方法列表,获取协议列表,方法交换(黑魔法),动态的添加方法,调用私有方法,为分类添加属性。
类在Runtime
中的表示
1 | //类在runtime中的表示 |
获取方法/属性等列表
有时候会有这样的需求,我们需要知道当前类中每个属性的名字(比如字典转模型,字典的Key和模型对象的属性名字不匹配)。
我们可以通过Runtime
的一系列方法获取类的一些信息(包括属性列表,方法列表,成员变量列表,和遵循的协议列表)。
1 | unsigned int count; |
注意:class_copyPropertyList
返回的仅仅是对象类的属性(@property声明的属性),而class_copyIvarList
返回类的所有属性和变量(包括在@interface
大括号中声明的变量)
可以另建一个NSObject
的分类把这些方法写在分类里面,以后需要的话直接把文件拖进项目里就可以直接使用了
1 |
|