runtime的那些事(一)——runtime基础介绍

最近计划重新巩固一下iOS开发的底层知识。面对当下环境,作为一名合格的开发者,只注重工具的使用是行不通的,修炼好底层系统知识的内功才是硬道理

该文章目录:

一、 什么是runtime?
二、 runtime 版本
三、 与 runtime 的三种交互方式
四、 消息机制的基本原理与执行流程
五、 动态解析与消息转发


一、什么是 runtime?

都说 Objective-C 是一门动态语言。首先,动态与静态语言最大的区别,就是动态语言将数据类型的检查等决策尽可能地从程序编译时推迟到了运行时。只要有可能,runtime 就会动态地完成任务。这意味着 Objective-C 语言不仅需要编译器,还需要 runtime 来执行编译代码。
runtime 是一套用C语言提供的 API,Objective-C 代码最终都会被编译器转化为运行时代码,通过消息机制决定了不同函数调用或转发方式,因此 runtime成为了 Objective-C 作为动态语言使用的基础。


二、runtime 版本

runtime 目前共有两个版本, Legacy 与 Modern 版本,与之相对应的编程接口分别是 Objective-C 1.0 与 2.0。Legacy 版本主要用于32位的Mac OS X平台上开发,而 Modern 版本用于 iPhone 程序与 Mac OS X 10.5以及更新版本系统中的64位程序。
两个版本最典型的区别,就是 Modern 版本中若更改类中实例变量的布局,则不必重新编译继承自该类的类。对于 iOS 开发者来说,我们只需要关注 Modern 版本即现行版本的runtime 即可。


三、与 runtime 交互方式

接下来会梳理当 NSObject 类与 runtime 交互时,runtime 是如何动态加载新类以及将消息转发给其它对象的。

1. Objective-C 源代码

平时开发中编写的 Objective-C 代码,其背后是 runtime 的运行工作。类、方法、协议等都由 runtime 转化成C语言后用数据结构来定义。

2. Foundation 框架下 NSObject 类的方法

在 iOS 类体系中,绝大部分Objective-C 类继承根类是 NSObject 类(NSProxy类除外,NSProxy定位更适合作为消息转发的代理抽象类),其本身就提供了一些具有动态特性的api。

- (NSString *)description  //返回当前类的描述信息

+ (Class)class  //方法返回对象的类;

- (BOOL)isKindOfClass:(Class)aClass  //判断对象是否属于指定类以及其派生类

- (BOOL)isMemberOfClass:(Class)aClass  //检查对象是否属于指定类

- (BOOL)respondsToSelector:(SEL)aSelector    //检查对象是否响应指定的消息;

+ (BOOL)conformsToProtocol:(Protocol *)protocol   //检查对象是否实现了指定协议类;

- (IMP)methodForSelector:(SEL)aSelector    //返回指定方法实现IMP的地址。

3. runtime 系统提供的函数

若要直接使用 runtime 提供的函数,必须先引入#import
通过一个最简单的例子来看下 Objective-C 代码是如何转化成 runtime 的C函数。

Class testClass = [TestClass class];
//等价于:Class testClass = objc_getClass("TestClass");
    
TestClass *test = [TestClass alloc];
//等价于:TestClass *test = ((id (*)(id, SEL))(void *)objc_msgSend)((id)testClass, sel_registerName("alloc"));
//简化后:TestClass *test = objc_msgSend(testClass, sel_registerName("alloc"))

TestClass *testInstance = [test init];
//等价于:TestClass *testInstance = ((id (*)(id, SEL))(void *)objc_msgSend)((id)test, sel_registerName("init"));
//简化后:TestClass *testInstance = objc_msgSend(test, sel_registerName("init")

[testInstance testMethod];
//等价于:objc_msgSend(testInstance, @selector(testMethod));
//等价于:objc_msgSend(testInstance, sel_registerName("testMethod"));

四、 消息机制的基本原理与执行流程

在上述最简单的Objective-C代码通过 runtime 的C函数转化后,可以发现:

  • 所有的 Objective-C 方法调用都会在编译时转化成C函数 objc_msgSend 的调用
  • objc_msgSend 方法一定会有两个参数:消息接收者消息方法名称

runtime 的核心是消息机制,其执行过程大致可分为三个部分:消息发送动态方法解析消息转发

  1. 编译阶段:
    以上全都为不带参数的方法编译后的C函数结构:objc_msgSend(receiver,selector)
    带参数的方法被编译成C函数的结构:objc_msgSend(receiver,selector,org1,org2,…)
  2. 运行阶段:
    在 recevier(消息接收者)寻找对应的 selector(消息方法名称)时,
    1. 首先会检测 selector 是否要忽略
    2. 其次,检查 receiver 是否为 nil 对象,Objective-C 中是允许一个 nil 对象执行任何一个方法而不会 Crash,究其原因在于会被直接 return 忽略掉
    3. 当以上两步没问题后,将开始查找该类的 IMP,默认先从 cache 中寻找,若命中则执行对应的方法
    4. 若 cache 中无法命中,则会尝试从方法列表 methodLists 中寻找
    5. 若方法列表也未找到,则会到向上查找,从父类的方法列表里寻找,一直找到 NSObject 类为止,正如下图中类关系

此处关于消息发送流程,引用一张已被用烂的类关系图:


6. 若 recevier 最终无法找到对应的 selector ,则执行消息动态解析,由负责动态的添加方法实现
7. 若 receiver 没有实现消息动态解析,则会执行消息重定向,将消息转发给可以处理消息的接收者
8. 若消息转发也没有实现,则会报错消息无法识别、方法找不到错误unrecognzied selector sent to instance并程序 Crash


五、动态解析与消息转发

之前让我能够快速理解动态解析与消息转发流程,最常用的,就是对象、类去调用一个未添加 IMP 实现的方法,去查看消息机制转发执行的过程
借助 runtime 提供的一个消息打印函数extern void instrumentObjcMessageSends(BOOL);
其打印结果会输出到 /private/tmp/msgSend-XXX

runtime 消息打印

图中 testClass 类继承自 NSObject 类,其中 walks 方法只在头文件中进行了声明,但未实现 IMP。

此处需留意一个知识点:

对象方法:存在于与类的实例方法列表
类方法:存在于元类的实例方法列表中,即类方法是以实例方法的形式存放在元类中
一图胜千言

1. 动态解析

当一个对象或类尝试去执行一个未实现 IMP 的方法,消息最终无法正常执行时,会触发 + (BOOL)resolveInstanceMethod:(SEL)sel+ (BOOL)resolveClassMethod:(SEL)sel
这是系统为我们提供的第一次解决 IMP 未命中机会,可以为对象动态添加 IMP 方法解析。
最终通过runtime中的class_addMethod(Class _Nullable cls, SEL _Nonnull name, IMP _Nonnull imp, const char * _Nullable types)函数实现。

/**
 运行时方法:向指定类中添加特定方法实现的操作
 @param cls 被添加方法的类
 @param name selector方法名
 @param imp 指向实现方法的函数指针
 @param types imp函数实现的返回值与参数类型
 @return 添加方法是否成功
 */
BOOL class_addMethod(Class _Nullable cls,
                     SEL _Nonnull name,
                     IMP _Nonnull imp,
                     const char * _Nullable types)

以下分别是对象实例、类动态解析方法的用法

//对象动态解析方法
+ (BOOL)resolveInstanceMethod:(SEL)sel {
  NSLog(@"执行了实例动态方法解析:%s", __func__);
  if (sel == @selector(walks)) {
    Method runMethod = class_getInstanceMethod(self, @selector(runInstance));
    IMP runIMP = method_getImplementation(runMethod);
    const char* types = method_getTypeEncoding(runMethod);
    NSLog(@"%s", types);
    return class_addMethod(self, sel, runIMP, types);
  }
  return [super resolveInstanceMethod:sel];
}
//类动态解析方法
+ (BOOL)resolveClassMethod:(SEL)sel {
  NSLog(@"执行了类动态方法解析:%@----->%s", NSStringFromClass(self), __func__);
  if (sel == @selector(walk)) {
        Class originClass = objc_getMetaClass([NSStringFromClass(self) UTF8String]);
        Method runMethod = class_getInstanceMethod(originClass, @selector(run));
        IMP runIMP = method_getImplementation(runMethod);
        const char* types = method_getTypeEncoding(runMethod);
        NSLog(@"%s", types);
        return class_addMethod(originClass, sel, runIMP, types);
  }
  return [super resolveClassMethod:sel];
}

上述实现动态解析中,若要使其成功执行就必须存在已经实现了的方法,比如上面用到的对象方法- (void)runInstance类方法+ (void)run
关于types参数,即 IMP 函数实现的返回值与参数类型,可以参考官方说明文档Objective-C Runtime Programming Guide
在动态解析方法过程中
对象方法 执行的顺序为


类方法 执行的顺序为

关于消息转发暂且放在一边,在类方法动态解析过程中,发现执行了两次+ (BOOL)resolveClassMethod:(SEL)sel解析;而在对象方法动态解析过程中,+ (BOOL)resolveInstanceMethod:(SEL)sel方法却只执行了一次。
通过 LLDB 的bt分解每一步,在+ (BOOL)resolveClassMethod:(SEL)sel中添加断点。
两次执行类方法动态解析分析

第一次,上面红色边框中,先执行了方法_objc_msgSend_uncached,然后走方法lookUpImpOrForward,再执行到方法_class_resolveMethod,这个流程其实是寻找 IMP 的过程;若没有找到,就会进入动态解析流程;
第二次,下面红色边框中的信息,发现了消息转发相关方法的执行动作,也就是说第二次时从消息转发过来的,意味着第一次动态解析失败了。在消息转发过来之后,接着会去执行class_getInstanceMethod方法,而这个方法却是实例方法动态解析所用到的。而关于类方法的存放位置,首先它是类的类方法,其次也是元类的实例方法,按照消息执行向上传递的规则,在寻找类方法 IMP 过程中多执行了一次,也就是我们看到的两次类方法动态解析执行。
通过下面这张图可以更好地理解 isa指针在类中向上传递查找顺序,也正好佐证了上述类方法在动态解析过程为什么执行了两次。
isa指针查找顺序图

2. 消息转发

当动态解析失败,并没有获取到有效的 IMP 时,系统会做第二次补救措施——消息转发。
消息转发提供了三个方法函数:

  1. - (id)forwardingTargetForSelector:(SEL)aSelector
  2. - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
  3. - (void)forwardInvocation:(NSInvocation *)anInvocation
    当动态解析失败后,进入消息转发流程
    首先,会执行函数- (id)forwardingTargetForSelector:(SEL)aSelector。该函数目的在于,通过该函数系统会将 SEL 尝试转发给其它对象,而且此对象不能是 self 与 nil
- (id)forwardingTargetForSelector:(SEL)aSelector {
  //若没有添加新函数时,系统会提供机会将该 SEL 转发给其它对象。
  NSLog(@"消息尝试转发给其它对象:%s", __func__);
  if (aSelector == @selector(walk)) {
    return [testTwo new];
  }
  return [super forwardingTargetForSelector:aSelector];
}

但该函数返回了 nil 或者 self 时,此时系统会提供最后一次寻找 IMP 的机会。接下来会执行- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector函数,去询问该消息是否有效,并尝试让其生成一个函数的签名,若签名无效返回 nil 并抛出异常;若不是 nil ,再由函数符号执行器- (void)forwardInvocation:(NSInvocation *)anInvocation去执行。

// 函数签名生成,告诉系统该消息是有效的
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
  NSLog(@"消息重定向:%s", __func__);
  NSString *selString = NSStringFromSelector(aSelector);
  if ([selString isEqualToString:@"walks"]) {
    return [NSMethodSignature signatureWithObjCTypes:"v@:"];
  }
  return [super methodSignatureForSelector:aSelector];
}

当函数生成签名后,系统会尝试执行方法- (void)forwardInvocation:(NSInvocation *)anInvocation

- (void)forwardInvocation:(NSInvocation *)anInvocation {
  NSLog(@"消息重定向执行函数:%s", __func__);
  testTwo *twoObj = [testTwo new];
  SEL selector = [anInvocation selector];
  if ([twoObj respondsToSelector:selector]) {
    [anInvocation invokeWithTarget:twoObj];
  } else {
    return [super forwardInvocation:anInvocation];\
  }
}

上述代码中,尝试将此类中的消息转发给了由 testTwo 类创建的对象实例——twoObj 并去执行。

消息转发流程是把未识别的消息分发给了其他不同接收对象,又或者是将所有未识别消息发送给同一个接收对象,其具体实现方式完全可以自由控制。而这一切的前提,是消息接收对象不能有指定方法的实现,才能有机会去执行消息转发。

函数签名

关于消息转发中,出现了一个名词:函数签名,其对应类为 NSMethodSignature
问题来了,何为函数签名?

函数签名中具体实现的方案被称为 类型编码(Type Encoding) 。是为了协助 runtime 系统,编译器将存储记录每个函数方法的返回值类型、参数类型编码信息,以字符串形式与对应方法 selector 进行关联的一种编码方案。NSMethodSignature 对象正是用于管理类型编码的存在。
当我们想要获取某个方法的类型编码,可以使用 @encode 编译器指令来获取某个指定类型的字符串编码。这些类型既可以是基本类型,也可以是结构体、类等类型,任何可作为 sizeof() 操作参数的类型都可用于 @encode 。
Objective-C 中所有类型编码参照Type Encoding

但是,Objective-C 不支持long double 类型。@encode(long double) 返回d,和double编码一样

NSObject 类声明一个实例变量、isa,是作为类型类。
通过 @encode 指令获取时并不会返回,但在协议中去声明方法时, runtime 会使用下面额外的编码列表来限定类型。


NSInvocation

NSInvocation类的对象是调用函数的另一种表现形式,将对象、方法选择器、参数以及返回值等各种信息,都封装到此类的对象中,再通过 invoke 函数去执行被调用函数,其思想本质是 命令者模式 的展现。


消息转发小扩展——实现Objective-C 多继承

利用消息转发可以实现 Objective-C 语言编程的多继承效果。两个没有继承关系的类,当一个类执行了未能实现的方法时,可以将该方法转发给另一个可执行该方法的类去执行,这样就可以灵活的弥补 Objective-C 本身不支持多继承的特性,也避免因为层层继承导致类文件结构臃肿、逻辑复杂。


以上就是对 Objective-C 语言的幕后工作者——runtime的基础介绍。篇幅有限,许多 runtime 中用到的数据结构与类定义直接略过,后面会专门用一篇文章详细说明,这样更有助于理解 runtime 的底层逻辑实现。


该文章首次发表在 :我只不过是出来写写代码 博客,并自动同步至 腾讯云:我只不过是出来写写iOS 博客

你可能感兴趣的:(runtime的那些事(一)——runtime基础介绍)