Objective-C的运行时Runtime

Objective-C Runtime顾名思义就是Objective-C的一个运行时库,它提供对Objective-C语言的动态特性的支持。

我们知道在比如C/C++/Swift这种静态语言一大特征就是在编译阶段就确定了对象的结构,而动态语言则在运行时还能够修改对象的结构,其中Objective-C正是利用Runtime这个技术来表现它的动态特性。

可以说Runtime是整个Objective-C编程的基石,每一个类/对象的发送消息都通过这一套机制来完成最终的处理实现。在Wiki上是这么评价OC这门语言的:

Objective-C最大的特色是承自Smalltalk的消息传递模型(message passing),此机制与今日C++式之主流风格差异甚大。Objective-C里,与其说对象互相调用方法,不如说对象之间互相传递消息更为精确。此二种风格的主要差异在于调用方法/消息传递这个动作。

C++里类别与方法的关系严格清楚,一个方法必定属于一个类别,而且在编译时(compile time)就已经紧密绑定,不可能调用一个不存在类别里的方法。但在Objective-C,类别与消息的关系比较松散,调用方法视为对对象发送消息,所有方法都被视为对消息的回应。所有消息处理直到运行时(runtime)才会动态决定,并交由类别自行决定如何处理收到的消息。也就是说,一个类别不保证一定会回应收到的消息,如果类别收到了一个无法处理的消息,程序只会抛出异常,不会出错或崩溃。

ps:同样的,Swift中也有类似的运行时特性。

Objective-C Runtime Programming相关话题

根据官方文档中介绍利用Runtime进行编程有以下七个相关话题:

  • Runtime的版本和平台(Runtime Versions and Platforms)
  • 与运行时交互(Interacting with the Runtime)
  • 消息(Messaging)
  • 动态方法解析(Dynamic Method Resolution)
  • 消息转发(Message Forwarding)
  • 类型编码(Type Encodings)
  • 声明的属性(Declared Properties)

接下去就直接跟着官方文档学习Runtime的基本运行原理(消息,动态方法解析,消息转发这三个话题是Runtime的原理核心)以及如何利用它强大的动态特性来编程。

ps: 对于OC2.0 Runtime实现可以在Apple Open Source中找到。

Runtime的版本和平台

在不同平台上有不同版本的Objective-C Runtime。这里我们只需要知道由于历史发展问题Objective-C的Runtime被认为有两个版本:”modern”和”legacy”。

  • 在legacy runtime中,如果更改类中实例变量的布局,则必须重新编译从其继承的类。
  • 在modern runtime中,如果更改类中实例变量的布局,则不必重新编译从其继承的类。

到了9102年的iOS开发也没有人关心Lagacy的Runtime问题了,而且网上也有很多相关的优秀资料从代码层面去解析它的实现细节,接下来我们都基于Objective-C 2.0的版本特性来学习它的基本原理和相关应用场景。

ps:在对于Runtime实现细节网上有挺多误解的文章——objc_class深深的误解

与Runtime交互

Objective-C程序中在三个层级与Runtime交互:

  • 通过Objective-C编写的代码
  • 调用Foundation框架的NSObject及其子类中相关方法
  • 直接通过Runtime库(<objc/runtime.h>)中调用的代码

可以看到,纯粹使用OC编写的程序基本上就没有绕开Runtime运行机制的(NSProxy类除外)。因为我们所熟知的[receiver message]调用方式背后正是基于Runtime机制实现的。

消息

在文档这一章介绍在Objective-C编程中

  • 消息传递是如何实现的
  • 如何直接利用objc_msgSend
  • 如何绕过动态绑定(Dynamic Binding)

Key1:消息(SEL)直到运行时才绑定到对应的方法实现(IMP)

编译器会将消息表达式[receiver message]转换成objc_msgSend(receiver, selector, arg1, arg2, ...)函数。这个函数会完成动态绑定(Dynamic Binding),首先它会找到这个消息(SEL)所对应的实现(IMP),然后它将调用该IMP并将接收对象(Receiver)和相关的参数传递给IMP,最后返回IMP的返回值,从而实现整个消息传递流程。

Key2:消息传递的关键在于每个类的结构和它的实例对象之间的联系

每个类的结构有两个关键元素:

  • 指向父类的指针
  • 类的调度表(这个表中存储着SEL和IMP的关联结构列表method_list_t
    当一个对象被实例化之后,它的内存结构中包含它的实例变量以及指向其类结构的指针(isa)。消息传递的基本过程(除此之外还有消息转发的流程之后会讲到)如下图所示:

消息传递的流程可以总结为当一个对象接收一个消息的时候,从这个对象的类及其父类结构的调度表中递归找到相应的选择器(SEL),直到它到达NSObject类。一旦通过method_t找到相应的选择器,objc_msgSend就会调用method_t中和这个选择器(SEL)所对应的方法(IMP)并将接收对象(Reciver)的数据结构传递给它。

为了加速消息传递过程,每个类都有一个单独的缓存,它包含继承方法的选择器(SEL)以及类中定义的方法(IMP),Runtime会先通过高速缓存去加速消息传递流程。

Key3:可通过Self_cmd传递消息

上面介绍了Runtime的消息传递核心就是objc_msgSend这个函数,它输入参数接收者Reciver和方法选择器SEL是通过编译器隐式传入的。而在一般代码层次我们通过Self访问当前的接收者,通过_cmd访问当前的方法选择器。

1
2
3
4
5
6
7
8
- (void)strange {
id target = getTheReceiver();
SEL method = getTheMethod();

if ( target == self || method == _cmd )
return nil;
return [target performSelector:method];
}

Key4:规避动态绑定(Dynamic Binding)的唯一方法是获取方法的地址并直接调用它

唯一绕过objc_msgSend流程的方法就是通过[NSObject methodForSelector:]去直接获取SEL所对应IMP的地址并调用它,这么做的唯一好处就是能提高重复调用函数的性能。

1
2
3
4
5
6
7
8
void (*setter)(id, SEL, BOOL);
int i;

setter = (void (*)(id, SEL, BOOL))[target methodForSelector:@selector(setFilled:)];

for ( i = 0 ; i < 1000 ; i++ ) {
setter(targetList[i], @selector(setFilled:), YES);
}

动态方法解析

上面一个话题中主要介绍了一个消息及其它的选择器SEL是如何找到方法实现IMP从而实现整个消息传递的流程,接下来了解如何动态添加一个方法实现IMP到类结构中。

这里我们利用重载+resolveInstanceMethod:或者+resolveInstanceMethod:动态注册一个类/对象的方法实现IMP到类结构中。

当我们利用@dynamic propertyName标记动态生成一个属性的方法的这种让Runtime无法正常在类结构中找到该消息的IMP的时候首先就会触发这两个方法调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
void dynamicMethodIMP(id self, SEL _cmd) {
// implementation ....
}

@implementation MyClass
+ (BOOL)resolveInstanceMethod:(SEL)aSEL {
if (aSEL == @selector(resolveThisMethodDynamically)) {
class_addMethod([self class], aSEL, (IMP) dynamicMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:aSEL];
}
@end

这个步骤常常被当做进入 消息转发 的前置步骤,当一个消息无法找到它的方法实现IMP的时候会触发unrecognized selector异常。这时候Runtime系统在抛出这个异常之前会进入下面流程尝试解决这个异常:

  1. 动态方法解析(动态注册SEL的IMP,如果返回YES表示不进入下一步消息转发流程)
  2. 消息转发给备用接收者(快速消息转发,只需要重定向消息的接收者)
  3. 完整消息转发(同时可在利用它模拟多继承特性)

总结这三个流程:

  1. 在本类中动态实现IMP(动态方法解析),如果没有定义这种操作则进入消息转发流程。
  2. 把消息转给我们已知具有这个SEL的类当做备胎(快速消息转发),如果假如这个备胎的IMP可能也是未存在的话则进入更完整的消息转发流程。
  3. 这一步比上一步的区别在于它提供校验这个备胎的Method是否有实现IMP(完整消息转发)。

消息转发

消息转发流程一般被认为有两个流程:

  • 快速消息转发
  • 完整消息转发

快速消息转发阶段

快速消息转发通过重载NSObject的-(void)forwardingTargetForSelector:这个方法实现在一个消息进入完整转发流程前给这个消息提供一个备用接收对象(Recvier)。

1
2
3
4
5
6
7
8
// 显然你要是把self传入会导致死循环
- (id)forwardingTargetForSelector:(SEL)aSelector {
if (aSelector == @selector(test)) {
MyTest *myObject = [[MyTest alloc] init];
return myObject;
}
return [super forwardingTargetForSelector:aSelector];
}

当快速消息转发流程也失败的时候,Runtime就会进入完整消息转发流程。

完整消息转发阶段

当一个消息没有对应的IMP的时候在抛出doesNotRecognizeSelector异常之前,这个流程提供最后的挽救机会,这个流程所有的具体实现细节都被封装好了在上层接口中只提供了两个Hook方法来介入它,首先需要通过重载methodSignatureForSelector:提供一个方法实现签名以生成NSInvocation对象然后传递到forwardInvocation:中进行处理。

官网使用一个很简单的示例来描述这个流程,假设有两个类Warrior和Diplomat,当给Warrior发送一个negotiate消息的时候通过这套流程转发给Diplomat的实例对象去处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (NSMethodSignature*)methodSignatureForSelector:(SEL)selector {
NSMethodSignature* signature = [super methodSignatureForSelector:selector];
if (!signature) {
// 能够处理这个消息的委托对象的方法实现IMP签名
Diplomat *diplomat = [Diplomat new];
signature = [diplomat methodSignatureForSelector:selector];
}
return signature;
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
Diplomat *diplomat = [Diplomat new];
if ([diplomat respondsToSelector:[anInvocation selector]]) {
[anInvocation invokeWithTarget:diplomat];
}
else {
[super forwardInvocation:anInvocation];
}
}

模拟多继承特性

面向对象编程中的多重继承(英语:multiple inheritance,缩写:MI)指的是一个类别可以同时从多于一个父类继承行为与特征的功能。与单一继承相对,单一继承指一个类别只可以继承自一个父类。

Objective-C语言层级是不存在多继承特性的,原因也很简单,因为OC中方法选择器SEL和方法实现IMP是动态绑定的,所以难以避开来自多个基类对象的多个方法命名同名问题。但想要实现它有多种方式,这里介绍两种:

  1. 上述的完整消息转发阶段
  2. 使用NSProxy类直接定义消息分发机制

一般的OC对象都是继承于NSObject的,而Apple在Foundation中为我们提供NSProxy类用来做纯粹的消息分发。NSProxy主要有几点特征:

  • NSProxy没有父类,是顶级类(根类),跟NSObject同等地位
  • NSProxy和NSObject一样都实现了协议
  • NSProxy被设计成一个”抽象类”,专门用于转发消息

在NSProxy中同样是通过重载 - (NSMethodSignature*)methodSignatureForSelector:(SEL)selector- (void)forwardInvocation:(NSInvocation *)anInvocation这两个方法实现消息转发流程。

类型编码

从官方文档上介绍,编译器利用类型编码将每个方法Method(IMP + SEL)的返回值,参数类型,这些信息保存在一个字符串里,从而利用这个字符串和方法选择器SEL关联起来。而更加深层次的原因可以看这篇文章的介绍——重识 Objective-C Runtime - 看透 Type 与 Value

在Runtime中使用这项技术应该是用来优化方法选择器SEL的访问速度,同时我们在其他应用场景也可以通过@encode()这个关键词来进行编码,下面是通过类型编码对应的结果字符串。

声明的属性

我们知道在Obejctive-C中可以通过@property为一个类/协议/类别添加一个属性(默认编译器会帮你生成ivar和getter/setter方法),这个过程中Runtime又做了什么工作呢?

Runtime将为每个属性生成与封闭类,类别或协议相关的描述性元数据

  • 你可以在类或协议上按名称查找属性
  • 属性的元数据实际上就是通过@encode()编码出来的字符串

    1
    const char *property_getAttributes(objc_property_t property)
  • 每个类和协议都有一个声明的属性列表,你可以遍历属性列表并通过property_getAttributes(objc_property_t)来访问属性的元数据信息

    1
    2
    3
    4
    5
    6
    7
    id LenderClass = objc_getClass("Lender");
    unsigned int outCount, i;
    objc_property_t *properties = class_copyPropertyList(LenderClass, &outCount);
    for (i = 0; i < outCount; i++) {
    objc_property_t property = properties[i];
    fprintf(stdout, "%s %s\n", property_getName(property), property_getAttributes(property));
    }

Runtime应用之Method Swizzling

Method Swizzling本质上就是利用Runtime的工作流程实现类/实例对象内部结构的修改,是iOS AOP编程中常用到的技术。Method Swizzling顾明思议就是实现把Objective-C中的某个方法(SEL + IMP)中的方法实现(IMP)和另外一个方法的IMP进行交换。

ps:美图开源的MTHawkeye里面有很大一部分特性就是通过这项技术实现非代码入侵式的性能监控。

合适的Method Swizzling时机

根据官方文档的描述,在Objective-C Runtime框架中为我们预留了+ (void)load方法以实现在类、类别加载到Runtime系统时自定义一些特定行为。当一个Objective-C程序镜像运行时初始化会经历以下四个过程:

  1. 镜像所链接的Framework的全部构造器
  2. 镜像中所有的+load方法
  3. 镜像中C++静态初始化器和C/C++__attribute__(constructor)函数
  4. 镜像中链接的所有构造器

在iOS中一个程序镜像的加载过程如下图所示:

而我们在+load中动态修改类的结构可以保证它还未进行其他的函数调用,从而不会造成其他的副作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import "NSObject+Swizzle.h"
#import <objc/runtime.h>

@implementation NSObject (Swizzle)

+ (void)load{
//调换IMP
Method originalMethod = class_getInstanceMethod([NSObject class], @selector(description));
Method myMethod = class_getInstanceMethod([NSObject class], @selector(swizzle_description));
method_exchangeImplementations(originalMethod, myMethod);
}

- (void)swizzle_description {
NSLog(@"description 被 Swizzle 了");
return [self swizzle_description];
}
@end

更完善的Method Swizzling

直接在+load中使用Runtime的method_exchangeImplementations函数进行方法交换在简单的场景下是完全可行的,但是在实际业务场景中会存在种种问题(具体可参考What are the Dangers of Method Swizzling in Objective C?)。

  • Method swizzling 并不是原子操作
  • 改变了不是我们自己代码的行为
  • 有可能出现命名冲突
  • Swizzling 改变方法的参数
  • Swizzles 顺序问题
  • 难于理解
  • 难于Debug

这里介绍使用RSSwizzle来更安全的实现方法交换。

上述提到一个使用经典swizzle方式出错场景:在swizzletouchesBegan:withEvent:这个touch方法的时候会发现由于它内部使用_cmd来获取当前SEL会导致swizzle异常崩溃,而现在改用RSSwizzle来修改就可以完美避开这个问题。

1
2
3
4
5
[RSSwizzle swizzleInstanceMethod:@selector(touchesBegan:withEvent:) inClass:[ViewController class] newImpFactory:^id(RSSwizzleInfo *swizzleInfo) {
return ^(__unsafe_unretained id self,NSSet* touches,UIEvent* event){
NSLog(@"touchesBegan:withEvent:被Swizzle了");
};
} mode:RSSwizzleModeAlways key:NULL];

Runtime应用之KVO

参考官网文档Key-Value-Observing中的定义,KVO是一种观察者模式,能够通知观察者被观察属性的变化。本质上它就是通过Runtime实现运行时动态生成一个被观察者子类对象并为这个新的子类重写被观察属性keyPath的Setter方法,在Setter方法中实现通知观察对象属性的改变状况。

1
2
3
4
5
6
当观察一个叫做name的属性时,KVO所生成的子类对象内部实际上就是做了这么一件事
- (void)setName:(NSString *)newName {
[self willChangeValueForKey:@"name"]; //KVO 在调用存取方法之前总调用
[super setValue:newName forKey:@"name"]; //调用父类的存取方法
[self didChangeValueForKey:@"name"]; //KVO 在调用存取方法之后总调用
}

参考

Objective-C 消息发送与转发机制原理
结合category 工作原理分析 OC2.0 中的Runtime
Objective-C Runtime分析
iOS Runtime详解
iOS底层原理总结 - 探寻Runtime本质(二)
NSProxy——少见却神奇的类
Swift中的动态特性
Swift学习笔记-动态特性

C++ Static initialization is powerful but dangerous
计算Load耗时
iOS中的load方法
如何精确度量 iOS App 的启动时间

Method Swizzling 的正确途径