Runtime复习整理
Runtime 是一个用 C 语言和编译语言写的API,称为“运行时”,它的应用使 Objective-C 拥有相当多的动态特性。具体怎么实现的呢。我们从下面几个方面探寻 Runtime 的实现机制。
- Runtime 介绍
- Runtime 交互方式
- Runtime 常用术语的数据结构
- Runtime 消息传递
- Runtime 消息转发
- Runtime 应用
Runtime介绍
本章节包括:
- Runtime概述
- 代码到可执行文件过程
- 源码地址
- 版本和平台
Runtime概述
Objective-C 是一门动态语言,它将很多静态语言在编译和链接时期做的事放到了运行时来处理。
Objective-C 扩展了 C 语言,并加入了面向对象特性和 Smalltalk 式的消息传递机制。而这个扩展的核心是一个用 C 和 编译语言 写的 Runtime 库。它是 Objective-C 面向对象和动态机制的基石。
高级编程语言想要成为可执行文件需要先编译为汇编语言再汇编为机器语言,机器语言也是计算机能够识别的唯一语言,但是OC
并不能直接编译为汇编语言,而是要先转写为纯C
语言再进行编译和汇编的操作,从OC
到C
语言的过渡就是由 runtime 来实现的。在Runtime中,对象可以用C语言中的结构体表示,而方法可以用C函数来实现,另外再加上了一些额外的特性。
对于 C 语言,函数的调用会在编译期就已经决定好,在编译完成后直接顺序执行。但是 OC 是一门动态语言,函数调用变成了消息发送,在编译期不能知道要调用哪个函数。所以 Runtime 无非就是去解决如何在运行时期来动态得创建类和对象、进行消息传递和转发。
理解 Objective-C 的 Runtime 机制可以帮我们更好的了解这个语言,适当的时候还能对语言进行扩展,从系统层面解决项目中的一些设计或技术问题。
代码到可执行文件过程
这是《深入理解计算机系统(第2版)》里面的一张截图:
主要过程我们可以简化成三个:
1 | - 编译 |
编译:将代码转换成底层可执行的语言(如汇编),简单来讲,就是把你能看懂的语言,转换成系统底层可以看懂的东西,这中间通常会有优化,先预处理,再编译。
链接:在编译的过程中,如果有调用其他的类的方法等,是不会检查或者报警的,编译的时候会默认你已经实现了。而链接就是去检查调用的方法或者类等是否确实存在。
运行:执行最终的可执行文件
如果是普通的C语言代码,我们使用的是传统的编译运行,那么一个函数的执行内容,在编译阶段其实就确定了,执行的时候只要去执行对应的内存地址的程序就好。
而在runtime
中,编译阶段只能确定最终要执行的函数名,但是具体执行的时候,执行的是什么程序,是在运行的时候才能确定,大大增加了程序的灵活性。
源码地址
Runtime
基本是用 C
和汇编
写的,可见苹果为了动态系统的高效而作出的努力。你可以在这里下到苹果维护的开源代码。在这里先放上runtime的源码和runtime官方api:
版本和平台
Runtime是有个两个版本的: legacy 、 modern
在Objective-C 1.0使用的是legacy,在2.0使用的是modern。这里简单介绍下区别:
- 在legacy runtime,如果你改变了实例变量的设计,需要重新编译它的子类。支持 32bit的OS X 程序
- 在modern runtime,如果你改变了实例变量的设计,不需要重新编译它的子类。支持iphone程序和OS X10.5之后的64bit程序
因为legacy是如此的古老,我们基本可以忽略legacy版本。
Runtime 的交互
Objc 在三种层面上与 Runtime 系统进行交互:
- Objective-C 源代码
- NSObject
- Runtime 库
Objective-C 源代码
多数情况我们只需要编写 OC 代码即可,Runtime 系统自动在幕后搞定一切,编译器会将 OC 代码转换成运行时代码,在运行时确定数据结构和函数。
NSObject
Cocoa 程序中绝大部分类都是 NSObject 类的子类,所以都继承了 NSObject 的行为。(NSProxy 类时个例外,它是个抽象超类)
一些情况下,NSObject 类仅仅定义了完成某件事情的模板,并没有提供所需要的代码。例如 -description
方法,该方法返回类内容的字符串表示,该方法主要用来调试程序。NSObject 类并不知道子类的内容,所以它只是返回类的名字和对象的地址,NSObject 的子类可以重新实现。
还有一些 NSObject 的方法可以从 Runtime 系统中获取信息,允许对象进行自我检查。例如:
-class
方法返回对象的类;-isKindOfClass:
和-isMemberOfClass:
方法检查对象是否存在于指定的类的继承体系中(是否是其子类或者父类或者当前类的成员变量);-respondsToSelector:
检查对象能否响应指定的消息;-conformsToProtocol:
检查对象是否实现了指定协议类的方法;-methodForSelector:
返回指定方法实现的地址。
Runtime 库
Runtime 系统是具有公共接口的动态共享库。头文件存放于/usr/include/objc目录下,这意味着我们使用时只需要引入objc/Runtime.h
头文件即可。
许多函数可以让你使用纯 C 代码来实现 Objc 中同样的功能。除非是写一些 Objc 与其他语言的桥接或是底层的 debug 工作,否则你一般不会直接用到这些函数的,在Objective-C Runtime Reference中有对 Runtime 函数的详细文档。
Runtime 常用术语的数据结构
下面讲讲Runtime用到的一些概念:
- 类对象(objc_class)
- 实例(objc_object)
- 元类(Meta Class)
- Ivar(objc_ivar)
- Method(objc_method)
- SEL(objc_selector)
- IMP
- 类缓存(objc_cache)
- Property(objc_property_t)
- Category(objc_category)
类对象(objc_class)
Objective-C
类是由Class
类型来表示的,它实际上是一个指向objc_class
结构体的指针。
1 | typedef struct objc_class *Class; |
查看objc/runtime.h
中objc_class
结构体的定义如下:
1 | struct object_class{ |
struct objc_class
结构体定义了很多变量,通过命名不难发现,
结构体里保存了指向父类的指针、类的名字、版本、实例大小、实例变量列表、方法列表、缓存、遵守的协议列表等,这些数据称为元数据(metadata
),该结构体的第一个成员变量也是isa
指针,这就说明了Class
本身其实也是一个对象,因此我们称之为类对象,类对象在编译期产生用于创建实例对象,是单例。
实例(objc_object)
objc_object是表示一个类的实例的结构体它的定义如下(objc/objc.h):
1 | struct objc_object{ |
可以看到,这个结构体只有一个字体,即指向其类的isa指针。这样,当我们向一个Objective-C对象发送消息时,运行时库会根据实例对象的isa指针找到这个实例对象所属的类。Runtime库会在类的方法列表及父类的方法列表中去寻找与消息对应的selector指向的方法,找到后即运行这个方法。
元类(Meta Class)
类对象中的元数据存储的都是如何创建一个实例的相关信息,那么类对象和类方法应该从哪里创建呢?
在上面我们提到,所有的类自身也是一个对象,我们可以向这个对象发送消息(即调用类方法)。为了调用类方法,这个类的isa
指针必须指向一个包含这些类方法的一个objc_class
结构体。这就引出了meta-class
的概念为了调用类方法,meta-class中存储着一个类的所有类方法.所以,调用类方法的这个类对象的isa指针指向的就是meta-class。
当我们向一个对象发送消息时,runtime会在这个对象所属的这个类的方法列表中查找方法;而向一个类发送消息时,会在这个类的meta-class的方法列表中查找。再深入一下,meta-class也是一个类,也可以向它发送一个消息,那么它的isa又是指向什么呢?为了不让这种结构无限延伸下去,Objective-C的设计者让所有的meta-class的isa指向基类的meta-class,以此作为它们的所属类。
即,任何NSObject继承体系下的meta-class都使用NSObject的meta-class作为自己的所属类,而基类的meta-class的isa指针是指向它自己。
通过上面的描述,再加上对objc_class结构体中super_class指针的分析,我们就可以描绘出类及相应meta-class类的一个继承体系了,如下图
通过上图我们可以看出整个体系构成了一个自闭环,
实例对象(objc_object) 的isa
指针指向类对象(objc_class),类对象的isa
指针指向了元类(Meta Class),元类的isa
指针指向基类(NSObject)的元类,基类(NSObject)的元类指向它自己。
类对象(objc_class)的super_class
指针指向了父类的类对象
元类(Meta Class))的super_class
指针指向了父类的元类
Ivar(objc_ivar)
Ivar
是表示成员变量的类型。
1 | typedef struct objc_ivar *Ivar; |
其中 ivar_offset
是基地址偏移字节
Method(objc_method)
Method 代表类中某个方法的类型
1 | typedef struct objc_method *Method; |
在这个结构体重,我们已经看到了SEL
和IMP
,说明SEL
和IMP
其实都是Method
的属性。
SEL(objc_selector)
先看下定义
1 | @include "Objc.h" |
首先区分下selector
和SEL
的的关系,我们先看下selector的定义
1 | @property SEL selector; |
可以看到SEL 是一种数据结构,selector
是它的实例。
selector
是方法选择器,可以理解为区分方法的 ID
,可以让我们能够快速找到对应的函数。而这个 ID
的数据结构是SEL
。
在iOS中,runtime
会在运行的时候,通过load
函数,将所有的method
hash然后map到set
中。这样在运行的时候,寻找selector
的速度就会非常快,不会因为runtime
特性牺牲太多的性能。
1 | A method selector is a C string that has been registered (or “mapped“) with the Objective-C runtime. Selectors generated by the compiler are automatically mapped by the runtime when the class is loaded. |
selector
既然是一个string
,我觉得应该是类似className+method
的组合,命名规则有两条:
- 同一个类,selector不能重复
- 不同的类,selector可以重复
这也带来了一个弊端,我们在写C
代码的时候,经常会用到函数重载,就是函数名相同,参数不同,但是这在Objective-C
中是行不通的,因为selector
只记了method
的name
,没有参数,所以没法区分不同的method
。
比如:
1 | - (void)caculate(NSInteger)num; |
是会报错的。
我们只能通过命名来区别:
1 | - (void)caculateWithInt(NSInteger)num; |
在不同类中相同名字的方法所对应的方法选择器是相同的,即使方法名字相同而变量类型不同也会导致它们具有相同的方法选择器。
IMP
看下IMP
的定义
1 | /// A pointer to the function of a method implementation. 指向一个方法实现的指针 |
就是指向最终实现程序的内存地址的指针。
在iOS
的Runtime
中,Method
通过selector
和IMP
两个属性,实现了快速查询方法及实现,相对提高了性能,又保持了灵活性。
Cache(objc_cache)
Cache 定义如下:
1 | typedef struct objc_cache *Cache |
Cache 为方法调用的性能进行优化,每当实例对象接收到一个消息时,它不会直接在 isa 指针指向的类的方法列表中遍历查找能够响应的方法,因为每次都要查找效率太低了,而是优先在 Cache 中查找。
Runtime 系统会把被调用的方法存到 Cache 中,如果一个方法被调用,那么它有可能今后还会被调用,下次查找的时候就会效率更高。就像计算机组成原理中 CPU 绕过主存先访问 Cache 一样。
Property(objc_property_t)
1 | typedef struct objc_property *Property; |
可以通过class_copyPropertyList
和 protocol_copyPropertyList
方法获取类和协议中的属性:
1 | objc_property_t *class_copyPropertyList(Class cls, unsigned int *outCount) |
Category(objc_category)
Category
是表示一个指向分类的结构体的指针,其定义如下:
1 | struct category_t { |
1 | name:是指 class_name 而不是 category_name。 |
从上面的category_t的结构体中可以看出,分类中可以添加实例方法,类方法,甚至可以实现协议,添加属性,不可以添加成员变量。
Runtime 消息传递(dispatch table)
本章节分为以下三个部分:
- 消息传递介绍
- 消息传递流程
- 方法中的隐藏参数
消息传递介绍
基础 Runtime 术语讲完了,接下来就要说到消息了。体会苹果官方文档中的:
messages aren’t bound to method implementations until Runtime。消息直到运行时才会与方法实现进行绑定。
当你写下面这样的代码时
1 | [tableView cellForRowAtIndexPath:indexPath]; |
编译器实际上把它转换成下面这样的C函数调用
1 | objc_msgSend(tableView, @selector(cellForRowAtIndexPath:), indexPath); |
先看看苹果官方关于消息发送方法的说明:
When it encounters a method invocation, the compiler might generate a call to any of several functions to perform the actual message dispatch, depending on the receiver, the return value, and the arguments. You can use these functions to dynamically invoke methods from your own plain C code, or to use argument forms not permitted by NSObject’s
perform...
methods. These functions are declared in/usr/include/objc/objc-runtime.h
.
objc_msgSend
sends a message with a simple return value to an instance of a class.objc_msgSend_stret
sends a message with a data-structure return value to an instance of a class.objc_msgSendSuper
sends a message with a simple return value to the superclass of an instance of a class.objc_msgSendSuper_stret
sends a message with a data-structure return value to the superclass of an instance of a class.
解释下上面的意思:
当遇到方法调用时,编译器可能会生成以下几个函数的调用,以执行实际的消息调度,具体取决于接收者,返回值和参数。 这些函数在/usr/include/objc/objc-runtime.h中声明。具体生成规则如下:
objc_msgSend //将具有简单返回值的消息发送到类的实例。
objc_msgSend_stret //将具有数据结构返回值的消息发送到类的实例。
objc_msgSendSuper //将具有简单返回值的消息发送到类的实例的超类。
objc_msgSendSuper_stret // 将具有数据结构返回值的消息发送到类实例的超类。
举例说明:
1 | UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; |
由于返回值是结构体类型,编译器不会把它转换成 msgSend, 而是调用 objc_msgSend_stret,stret就是 struct return的缩写
消息传递流程
下面我们以objc_msgSend为例,详细叙述消息发送的步骤(如下图):
- 首先检测这个
selector
是不是要忽略。比如 Mac OS X 开发,有了垃圾回收就不理会 retain,release 这些函数。 - 检测这个
selector
的 target 是不是nil
,Objc 允许我们对一个 nil 对象执行任何方法不会 Crash,因为运行时会被忽略掉。 - 如果上面两步都通过了,那么就开始查找这个类的实现
IMP
,先从 cache 里查找,如果找到了就运行对应的函数去执行相应的代码。 - 如果 cache 找不到就找类的方法列表中是否有对应的方法。
- 如果类的方法列表中找不到就到父类的方法列表中查找,一直找到 NSObject 类为止。
- 如果还找不到,就要开始进入动态方法解析了,后面会提到。
方法中的隐藏参数
疑问:
我们经常用到关键字self
,但是self
是如何获取当前方法的对象呢?
其实,这也是 Runtime 系统的作用,self
实在方法运行时被动态传入的。
当 objc_msgSend
找到方法对应实现时,它将直接调用该方法实现,并将消息中所有参数都传递给方法实现,同时,它还将传递两个隐藏参数:
- 接受消息的对象(
self
所指向的内容,当前方法的对象指针) - 方法选择器(
_cmd
指向的内容,当前方法的 SEL 指针)
因为在源代码方法的定义中,我们并没有发现这两个参数的声明。它们时在代码被编译时被插入方法实现中的。尽管这些参数没有被明确声明,在源代码中我们仍然可以引用它们。
这两个参数中, self
更实用。它是在方法实现中访问消息接收者对象的实例变量的途径。
Runtime 消息转发(Message Forwarding)
当一个对象能接收一个消息时,就会走正常的方法调用流程。但如果一个对象无法接收指定消息时,又会发生什么事呢?默认情况下,如果是以[object message]的方式调用方法,如果object无法响应message消息时,编译器会报错。但如果是以perform…的形式来调用,则需要等到运行时才能确定object是否能接收message消息。如果不能,则程序崩溃。
通常,当我们不能确定一个对象是否能接收某个消息时,会先调用respondsToSelector:来判断一下。如下代码所示:
1 | if([self respondsToSelector:@selector(method)]){ |
不过,我们这边想讨论下不使用respondsToSelector:判断的情况。这才是我们这一节的重点。
当一个对象无法接收某一消息时,就会启动所谓“消息转发(message forwarding)”机制,通过这一机制,我们可以告诉对象如何处理未知的消息。默认情况下,对象接收到未知的消息,会导致程序崩溃。不过,我们可以采取一些措施,让我们的程序执行特定的逻辑,而避免程序的崩溃。
消息转发机制基本上分为三个步骤:
- 动态方法解析 - resolveInstanceMethod/resolveClassMethod
- 重定向 - forwardingTargetForSelector
- 转发 - forwardInvocation
动态方法解析 - resolveInstanceMethod
对象在接收到未知的消息时,首先会调用所属类的类方法
+resolveInstanceMethod:(实例方法)或者
+resolveClassMethod:(类方法)。
在这个方法中,我们有机会为该未知消息新增一个“处理方法”,通过运行时class_addMethod函数动态添加到类里面就可以了。这种方案更多的是为了实现@dynamic属性。
举例:如果我们执行ClassA并不存在的foo方法,如果我们什么都不处理在运行时程序会崩溃。
1 | ClassA *a = [ClassA new]; |
如果想要用 resolveInstanceMethod
来补救 ,该怎么做呢?
1 | #include <objc/runtime.h> |
这里的return YES 或者 return NO,是告诉系统是否实现了这个方法,如果return YES,但是并没有增加方法,还是会报错,并且不会走到forward,因为系统默认你已经在这一步做了resolveInstanceMethod这个事情。
重定向 - forwardingTargetForSelector
如果上一步骤的resolveInstanceMethod
return no,系统会走forwardingTargetForSelector
,这一步被称为快速转发,是因为相对下面要介绍的normal fastward,这一步直接转发了消息,而normal fastward生成了NSInvocation,相对直接转发慢一些。
先看下如何实现,比如,我想把消息转发给有能力的classB:
1 | @interface ClassB : NSObject |
A中需要实现forwardingTargetForSelector
方法:
1 | - (id)forwardingTargetForSelector:(SEL)aSelector |
转发 - forwardInvocation
如果你的类没有实现forwardingTargetForSelector
方法,系统会调用methodSignatureForSelector
方法,如果这个方法返回一个函数的签名,则执行forwardInvocation
方法,否则执行doesNotRecognizeSelector
。
如果希望在这一步补救,如何做呢?
1 | - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { |
苹果的文档里,讲述了这一个消息转发的出发点,其实是为了实现类似C多继承的功能。我们知道,在C中如果一个类想要具有多个类的功能,是可以直接继承多个类的。而Objective-C是单继承,如果想实现类似的功能,就用消息转发,将消息转发给有能力处理的类。苹果是这样描述他们的思想的:C的多继承,是加法,在多继承的同时,其实也增加了很多不需要的功能,而苹果通过消息转发,实现了减法的思想,只留有用的方法,而不去增加过多内容。
Runtime应用
Runtime
简直就是做大型框架的利器。它的应用场景非常多,下面就介绍一些常见的应用场景。
- 遍历结构体中数据(Ivar、objc_property_t、Method、Protocol)
- 获取对应列表
- NSCoding的自动归档/解档
- 模型/字典互转
- 动态添加属性
- 动态添加方法与方法转发
- 黑魔法(Method Swizzling)
- KVO实现
遍历结构体中数据
获取对应列表
1 | 1、获取成员变量 |
NSCoding的自动归档/解档
原理描述:用runtime
提供的函数遍历Model
自身所有属性,并对属性进行encode
和decode
操作。
核心方法:在Model
的基类中重写方法:
1 | - (id)initWithCoder:(NSCoder *)aDecoder { |
模型/字典互转(MJExtension)
原理描述:用runtime
提供的函数遍历Model
自身所有属性,如果属性在json
中有对应的值,则将其赋值。
核心方法:在NSObject
的分类中添加方法
1 | - (instancetype)initWithDict:(NSDictionary *)dict { |
动态添加属性
1 | //.h文件 |
动态添加方法与方法转发
关于消息转发,前面已经讲到过了,消息转发分为三级,我们可以在每级实现替换功能,实现消息转发,从而不会造成崩溃。同学们可以自行前往前面章节查看案例。JSPatch 是它成功使用的案例
JSPatch 是一个 iOS 动态更新框架,不仅能够实现消息转发,还可以实现方法添加、替换能一系列功能。只需在项目中引入极小的引擎,就可以使用 JavaScript 调用任何 Objective-C 原生接口,获得脚本语言的优势:为项目动态添加模块,或替换项目原生代码动态修复 bug。
黑魔法(Method Swizzling)
下面实现一个替换ViewController
的viewDidLoad
方法的例子。
1 | @implementation ViewController |
swizzling
应该只在+load
中完成。 在 Objective-C
的运行时中,每个类有两个方法都会自动调用。+load
是在一个类被初始装载时调用,+initialize
是在应用第一次调用该类的类方法或实例方法前调用的。两个方法都是可选的,并且只有在方法被实现的情况下才会被调用。
swizzling
应该只在dispatch_once
中完成,由于swizzling
改变了全局的状态,所以我们需要确保每个预防措施在运行时都是可用的。原子操作就是这样一个用于确保代码只会被执行一次的预防措施,就算是在不同的线程中也能确保代码只执行一次。Grand Central Dispatch 的 dispatch_once
满足了所需要的需求,并且应该被当做使用swizzling
的初始化单例方法的标准。
KVO实现
全称是Key-value observing,翻译成键值观察。提供了一种当其它对象属性被修改的时候能通知当前对象的机制。再MVC大行其道的Cocoa中,KVO机制很适合实现model和controller类之间的通讯。
KVO
的实现依赖于 Objective-C
强大的 Runtime
,当观察某对象 A
时,KVO
机制动态创建一个对象A
当前类的子类,并为这个新的子类重写了被观察属性 keyPath
的 setter
方法。setter
方法随后负责通知观察对象属性的改变状况。
Apple
使用了 isa-swizzling
来实现 KVO
。当观察对象A
时,KVO
机制动态创建一个新的名为:NSKVONotifying_A
的新类,该类继承自对象A的本类,且 KVO
为 NSKVONotifying_A
重写观察属性的 setter
方法,setter
方法会负责在调用原 setter
方法之前和之后,通知所有观察对象属性值的更改情况。
- NSKVONotifying_A 类剖析
1 | NSLog(@"self->isa:%@",self->isa); |
在建立KVO监听前,打印结果为:
1 | self->isa:A |
在建立KVO监听之后,打印结果为:
1 | self->isa:NSKVONotifying_A |
在这个过程,被观察对象的 isa
指针从指向原来的 A
类,被KVO
机制修改为指向系统新创建的子类NSKVONotifying_A
类,来实现当前类属性值改变的监听;
所以当我们从应用层面上看来,完全没有意识到有新的类出现,这是系统“隐瞒”了对 KVO
的底层实现过程,让我们误以为还是原来的类。但是此时如果我们创建一个新的名为“NSKVONotifying_A
”的类,就会发现系统运行到注册 KVO
的那段代码时程序就崩溃,因为系统在注册监听的时候动态创建了名为 NSKVONotifying_A
的中间类,并指向这个中间类了。
- 子类setter方法剖析
KVO
的键值观察通知依赖于 NSObject 的两个方法:willChangeValueForKey:
和 didChangeValueForKey:
,在存取数值的前后分别调用 2 个方法:
被观察属性发生改变之前,willChangeValueForKey:
被调用,通知系统该 keyPath
的属性值即将变更;
当改变发生后, didChangeValueForKey:
被调用,通知系统该keyPath
的属性值已经变更;之后, observeValueForKey:ofObject:change:context:
也会被调用。且重写观察属性的setter
方法这种继承方式的注入是在运行时而不是编译时实现的。
KVO
为子类的观察者属性重写调用存取方法的工作原理在代码中相当于:
1 | - (void)setName:(NSString *)newName { |
总结
我们之所以让自己的类继承NSObject
不仅仅因为苹果帮我们完成了复杂的内存分配问题,更是因为这使得我们能够用上 Runtime 系统带来的便利。可能我们平时写代码时可能很少会考虑一句简单的[receiver message]
背后发生了什么,而只是当做方法或函数调用。深入理解 Runtime 系统的细节更有利于我们利用消息机制写出功能更强大的代码,比如 Method Swizzling 等。