Objective-C之Runtime浅析

avatar
@比心

1 前言

1.1 什么是Objective-C?

Objective-C(简称OC)是 C 的超集,也是一门面向对象编程的语言。

与 C 语言不同的是,虽然 OC 关于 C 的部分是静态的,但是关于面向对象的部分是动态的。所谓的静态,指的是在编译期间所有的函数、结构体等都确定好了内存地址,调用行为都被解析为内存地址+偏移量。而动态指的是,代码的合法性会延迟到运行的过程中校验,调用行为会被解析为调用底层语言的接口。

而与其他面向对象的高级语言相比,OC 的动态特性则更为明显,它支持在程序运行时动态的创建类、添加方法、替换方法。

OC 代码经过 Xcode( Apple 官方 IDE)编译后,会成为直接与 Runtime 交互的运行时代码(C/C++),再进行之后的编译过程。

下面看一个简单例子: 图片

通过 clang -rewrite-objc main.m 命令将图左的 OC 代码文件 main.m 编译为图右的main.cpp 文件。

可以看到 main 方法中实例对象 p 的创建过程变成了:通过 objc_msgSend 函数向 Person 类发送消息的方式来调用类方法 new,同样实例对象 p 的 setAge 方法调用也变成了向 p 发送消息的方式。其中 objc_msgSend、objc_getClass、sel_registerName 都是 Runtime 中的 Api。

1.2 什么是OC中的Runtime?

OC 中的 Runtime 是 OC 这门语言为了支持语言的动态特性而催生出的底层动态链接库(libobjc),由C、C++、汇编共同实现,它充当着 OC 语言的操作系统,将尽可能多的决策从编译时推迟到运行时,使 OC 具有动态性和灵活性。本文抛砖引玉,结合源码(版本为objc4-866.9)从 OC 面向对象的实现以及消息机制两方面对 Runtime 进行浅析,更详细的原理大家可以阅读源码探索。Runtime 源码地址:opensource.apple.com/releases/ 搜索相应系统版本 objc 源码(注:无法直接编译,须下载补全其他缺失文件)。

2 面向对象的实现

2.1 类的结构

凡面向对象大都离不开类,类是描述一个对象规格的模板,我们先从类的结构入手分析。

打开 Runtime 源码找到 Class 的定义:

图片

继续看 objc_class 这个结构体:(截取关键代码)

图片objc_class 继承 objc_object,含有成员变量:superclass、bits。bits 为 class_data_bits_t 结构体类型,含有成员变量:data、safe_ro。

  • data:class_rw_t 结构体类型,rw 表示可读写;
  • safe_ro:class_ro_t 结构体类型,ro 表示只读。

class_rw_t 与 class_ro_t 结构体的定义如下:(截取关键代码)

图片

class_ro_t 内的方法、协议、成员变量、属性列表在编译时就确定了,不可修改。而 class_rw_t 可在运行时动态修改内容。可以看出 Class 的本质为结构体,并且其中描述了类的成员变量、属性、方法、协议列表等信息。

2.2 对象的创建

在探索对象创建流程之前,我们先梳理一下 OC 中的对象:

  • 实例对象(instance):通过alloc方法基于类实例化出来的数据结构,每个实例对象存储自己属性的值。
  • 类对象(class):在 OC 中类本身也是一个对象,在编译时被创建,每个类在内存中只有一个类对象,类对象中保存了该类的信息,比如类名、父类、成员变量列表、属性列表、实例方法列表。
  • 元类对象(meta-class):系统在编译时创建,同样每个类在内存中有且只有一个元类对象,元类对象中保存了类属性列表、类方法列表。可以将类对象看作是元类对象的实例。

注意区分一点,成员变量的值是存储在实例对象中的,因为只有当我们创建实例对象的时候才为成员变赋值。但是成员变量叫什么名字,是什么类型,只需要有一份就可以了,所以存储在类对象中。

在开发过程中最常见的即是实例对象,我们通常会使用 alloc init 或者 new 方法来创建一个实例对象,那么方法内部是如何实现的呢?我们接着看源码。

图片

如上图,无论是 alloc 还是 new(相当于 alloc + init) 最终都会进入 callAlloc 方法,这里传入的参数 self 即方法的接收者—类对象。

我们日常中使用的 self 实际上不是什么特殊的关键字,而是翻译后的静态函数的第一个入参。所有的 OC 方法都存在两个隐藏入参— self 和 _cmd。当通过 OC 方法调用的方式进行方法调用时,第一个入参 self 会被赋值为接收者(receiver),第二个入参_cmd会被赋值为消息(message、selector)。

图片

slowpath 与 fastpath 是编译器优化的操作,直接去掉完全不影响代码逻辑,hasCustomAWZ 是判断是否自定义实现了 allocWithZone 方法。

正常情况下进入 _objc_rootAllocWithZone 方法,最后进入 _class_createInstanceFromZone 方法如下图(截取关键代码)。

图片

第一步通过 calloc 为实例对象开辟内存空间,第二步通过 initIsa 方法给实例对象的 isa 指针赋值,使其指向类对象。至此一个实例对象诞生,现在可以使用该对象的属性以及调用实例方法了。

2.3 isa的作用

类的结构以及对象的诞生都已经清楚了,那么如何确定一个对象所属的类呢?在OC中,可以使用 id 类型来表示任何对象(id 为objc_object 结构体指针类型,objc_object 结构体中含有 isa 成员变量),所以每个对象都有 isa 指针,isa 在对象被创建时赋值,用于指向它所属的类(类对象或元类对象),获取对应的信息。

值得注意的是 isa 并不仅仅是一个指针,它是一个 isa_t 类型的联合体,在 OC 中因为内存优化的原因,isa 中还存储了其他额外信息,我们这里不做过多探讨,仅列一下当前版本 isa 的主要内容结构如下图。

图片

我们继续向下捋清对象 isa 指针的指向关系,下面是 Apple 官方的对象模型图: 

图片

这里的Root Class(根类) 就是 NSObject。

  • 子类的实例对象—isa—>子类的类对象—isa—>子类的元类对象—isa—>根类的元类对象,根类的元类对象—isa—>自己。
  • 父类的实例对象—isa—>父类的类对象—isa—>父类的元类对象—isa—>根类的元类对象,根类的元类对象—isa—>自己。
  • 子类的类对象—superclass—>父类的类对象—superclass—>根类的类对象—superclass—>nil。
  • 子类的元类对象—superclass—>父类的元类对象—superclass—>根类的元类对象—superclass—>根类的类对象。

以上的指向关系可以通过 Runtime 中的三个 Api 进行验证:

  • objc_getClass:参数为类名的字符串,返回这个类的类对象。
  • objc_getMetaClass:参数为类名的字符串,返回这个类的元类对象。
  • object_getClass:参数是 id 类型,返回这个 id 的 isa 指针的指向者。

2.4 为什么要设计元类?

  1. 符合设计原则中的单⼀职责,实例对象只负责存储属性值的事,类对象存储实例⽅法列表,元类对象存储类⽅法列表。
  2. 复用消息机制,OC 中方法的调用都是通过 objc_msgSend 函数传入消息的接收者和消息的⽅法名来查找对应⽅法的实现,但是+号方法(类方法)和-号方法(实例方法)是可以同名的,所以只能通过传入不同的消息接收者(类对象或元类对象)来实现。

3 消息机制

消息机制是OC运行时特性之一,指通过向对象发送消息来调用方法的一种机制,它使得方法的调用在运行时决定,而不是在编译时静态决定。

在介绍Runtime时,我们提到了消息机制的核心函数:objc_msgSend(id _Nullable self, SEL _Nonnull op, ...),其中self 表示消息的接收者对象,第二个SEL类型的参数表示方法名对应的选择器(每个方法在编译时被分配一个唯一的SEL和对应的IMP实现),第三个参数...表示方法的参数列表。OC中所有的方法调用都会转换成objc_msgSend,接下来就对该函数的运行流程进行探讨。

3.1 查找流程

图片

  1. 当 objc_msgSend 被调用时,首先判断消息接收者对象是否为 nil,若为 nil 则直接返回,所以在 OC 中对一个为 nil 的对象发送消息什么都不会发生。
  2. 获取接收者对象的 isa 指针,查找 isa 指向的类(类对象或元类对象)的缓存是否存在该方法,有则返回方法的 IMP,否则向下继续执行。
  3. 查找 isa 指向的类(类对象或元类对象)对应的方法列表(Class 中 class_rw_t 结构体的成员变量 methods)是否存在该方法,有则返回方法的 IMP 且加入缓存列表中,否则沿着继承链遍历查找。
  4. 最后还没找到对应的方法时会进入消息的转发流程。

上述步骤1和2在汇编文件 objc-msg-arm64.s 中实现,步骤3以及之后的转发流程则在objc-runtime-new.mm 文件中的 lookUpImpOrForward 方法中实现。

现在可以确定,OC 的方法调用并不是编译时静态决定,而是通过运行时动态查找的,在这个前提下我们可以在运行时增加对象的方法、替换对象的方法,这两个操作 Runtime 都提供了对应的Api:

  • addMethods:在 class_rw_t 的 methods 中添加 method。
  • method_exchangeImplementations:替换方法 method_t 结构体中SEL对应的IMP。

3.2 转发流程

当类以及所有的父类中都未找到对应的方法时,则进入消息的转发流程,若最后还无法处理该消息,即会调用NSObject 类中的 doesNotRecognizeSelector: 方法抛出异常。

下面简单描述一下消息转发流程中各步骤的作用。

图片

如上图所述消息的转发流程包括三个步骤:通过在重载方法 +resolveInstanceMethod:(实例方法重载)或者 +resolveClassMethod:(类方法重载)中动态添加(addMethods)被调用的方法,例如:

图片

通过在重定向方法- (id)forwardingTargetForSelector:(实例方法重定向)或者+ (id)forwardingTargetForSelector:(类方法重定向)中将消息转发给其他对象来处理,例如:

图片

通过+ (void)forwardInvocation:(NSInvocation *)anInvocation方法手动构造一个 NSInvocation 对象来进行进一步处理,例如:

首先实现获取方法签名方法

图片

转发调用图片

4 总结

经过本次学习,相信大家对 Runtime  有了更多的认识,也能理解 OC 的动态特性带来的巨大优势。不过也正因为过于动态会导致开发过程中 bug 排查难度增加,而且,由于它在方法调用上,需要经过漫长的消息发送以及消息转发链路,所以往往性能比不上C++、Swift 等静态语言。知己知彼,方能百战不殆,当疑难问题出现时,底层知识的牢固掌握往往能使你保持镇定和拥有足够的应变能力,希望这篇文章对你有所帮助。


wxg.JPG