iOS之武功秘籍⑥:Runtime之方法与消息
写在前面
上文说到cache_t
缓存的是方法,我们分析了cache
的写入流程,在写入流程之前,还有一个cache
读取流程,即objc_msgSend
和 cache_getImp
.那么方法又是什么呢?这一切都要从Runtime
开始说起...
一、Runtime
① 什么是Runtime?
Runtime
是一套API
,由c、c++、汇编
一起写成的,为OC
提供了运行时.
- 运行时:代码跑起来,将可执行文件装载到内存
- 编译时:正在编译的时间——翻译源代码将高级语言(
OC、Swift
)翻译成机器语言(汇编等),最后变成二进制
② Runtime版本
Runtime
有两个版本——Legacy
和Modern
,苹果开发者文档都写得清清楚楚
源码中-old
、__OBJC__
代表Legacy
版本,-new
、__OBJC2__
代表Modern
版本,以此做兼容
③ Runtime的作用及调用
Runtime
底层经过编译会提供一套API
和供FrameWork
、Service
使用
Runtime
调用方式:
-
Runtime API
,如sel_registerName()
,class_getInstanceSize
-
NSObject API
,如isKindOf()
-
OC
上层方式,如@selector()
原来平常在用的这么多方法都是Runtime
啊,那么方法究竟是什么呢?
二、方法的本质
① 研究方法
通过clang
编译成cpp文件
可以看到底层代码,得到方法的本质
- 兼容编译(代码少):
clang -rewrite-objc main.m -o main.cpp
- 完整编译(不报错):
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp
或xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp
② 代码转换
-
((TCJPerson *(*)(id, SEL))(void *)
是类型强转 -
(id)objc_getClass("TCJPerson")
获取TCJPerson
类对象 -
sel_registerName("alloc")
等同于@selector()
即可以理解为((类型强转)objc_msgSend)(对象, 方法调用)
③ 方法的本质
方法的本质是通过objc_msgSend
发送消息,id
是消息接收者,SEL
是方法编号.
注意:如果外部定义了C函数
并调用如void sayHello() {}
,在clang
编译之后还是sayHello()
而不是通过objc_msgSend
去调用.因为发送消息就是找函数实现的过程,而C函数
可以通过函数名
——指针
就可以找到.
为了验证,通过objc_msgSend
方法来完成[person sayHello]
的调用,查看其打印是否是一致.
其打印结果如下,发现是一致的,所以 [person sayHello]
等价于objc_msgSend(person,sel_registerName("sayHello"))
这其中需要注意两点:
- 1、直接调用
objc_msgSend
,需要导入头文件#import <objc/message.h>
- 2、需要将
target --> Build Setting -->搜索msg -- 将enable strict checking of obc_msgSend calls由YES 改为NO
,将严厉的检查机制关掉,否则objc_msgSend
的参数会报错
④ 向不同对象发送消息
子类TCJTeacher
有实例方法sayHello
、sayNB
, 类方法sayNC
父类TCJPerson
有实例方法sayHello
、sayCode
, 类方法sayNA
① 发送实例方法
消息接收者——实例对象
② 发送类方法
③ 对象方法调用-实际执行是父类的实现
注意前面的细节:父类TCJPerson
中实现了sayHello
方法,而子类TCJTeacher
没有实现sayHello
方法.现在我们可以尝试让teacher
调用sayHello
执行父类中实现,通过objc_msgSendSuper
实现.
因为objc_msgSend
不能向父类发送消息,需要使用objc_msgSendSuper
,并给objc_super
结构体赋值(在objc2
中只需要赋值receiver
、super_class
)
receiver——实例对象
;super_class——父类类对象
发现不论是[teacher sayHello]
还是objc_msgSendSuper
都执行的是父类中sayHello
的实现,所以这里,我们可以作一个猜测:方法调用,首先是在类中查找,如果类中没有找到,会到类的父类中查找
.
④ 向父类发送实例方法
receiver——实例对象
;super_class——父类类对象
⑤ 向父类发送类方法
receiver——类对象
;super_class——父类元类对象
三、消息查找流程
消息查找流程其实是通过上层的方法编号sel
发送消息objc_msgSend
找到具体实现imp
的过程
objc_msgSend
是用汇编写成的,至于为什么不用C而
是用汇编写
,是因为:
-
C语言
不能通过写一个函数,保留未知的参数,跳转到任意的指针,而汇编有寄存器 - 对于一些调用频率太高的函数或操作,使用汇编来实现能够提高效率和性能,容易被机器来识别
① 开始查找
打开objc4
源码,由于主要研究arm64结构
的汇编实现,来到objc-msg-arm64.s
,先附上其汇编整体执行的流程图
p0表示0寄存器的指针,x0表示它的值,w0表示低32位的值(不用过多在意)
- ①开始
objc_msgSend
- ②判断
消息接收者
是否为空,为空直接返回 - ③判断
tagged_pointers
(之后会讲到) - ④取得对象中的
isa
存一份到p13
中 - ⑤根据
isa
进行mask
地址偏移得到对应的上级对象
(类、元类)
查看GetClassFromIsa_p16
定义,主要就是进行isa & mask
得到class
操作
- ⑥开始在缓存中查找
imp
——开始了快速流程
② 快速查找流程
从CacheLookup
开始了快速查找流程(此时x1
是sel
,x16
是class
)
- ①通过
cache
首地址平移16字节
(因为在objc_class
中,首地址距离cache
正好16
字节,即isa
首地址 占8
字节,superClass
占8
字节),获取cahce
,cache
中高16
位存mask
,低48
位存buckets
,即p11 = cache
- ②从
cache
中分别取出buckets
和mask
,并由mask
根据哈希算法计算出哈希下标- 通过
cache
和掩码(即0x0000ffffffffffff)
的&
运算,将高16位mask抹零
,得到buckets
指针地址,即p10 = buckets
- 将
cache
右移48
位,得到mask
,即p11 = mask
- 将
objc_msgSend
的参数p1
(即第二个参数_cmd)& msak
,通过哈希算法
,得到需要查找存储sel-imp
的bucket
下标index
,即p12 = index = _cmd & mask
,为什么通过这种方式呢?因为在存储sel-imp
时,也是通过同样哈希算法计算哈希下标进行存储
,所以读取
也需要通过同样的方式读取
,如下所示
- 通过
- ③根据所得的哈希下标
index
和buckets
首地址,取出哈希下标对应的bucket
- 其中
PTRSHIFT
等于3
,左移4位
(即2^4 = 16
字节)的目的是计算出一个bucket
实际占用的大小,结构体bucket_t
中sel
占8
字节,imp
占8
字节 - 根据计算的哈希下标
index
乘以 单个bucket占用的内存大小
,得到buckets
首地址在实际内存
中的偏移量
- 通过
首地址 + 实际偏移量
,获取哈希下标index
对应的bucket
- 其中
- ④根据获取的bucket,取出其中的imp存入p17,即p17 = imp,取出sel存入p9,即p9 = sel
- ⑤第一次递归循环
- 比较获取的
bucket
中sel
与objc_msgSend
的第二个参数的_cmd
(即p1)是否相等 - 如果相等,则直接跳转至
CacheHit
,即缓存命中,返回imp
- 如果不相等,有以下两种情况
- 如果一直都找不到,直接跳转至
CheckMiss
,因为$0
是normal
,会跳转至__objc_msgSend_uncached
,即进入慢速查找流程
- 如果根据
index
获取的bucket
等于buckets
的第一个元素,则人为的将当前bucket
设置为buckets
的最后一个元素(通过buckets首地址+mask右移44位
(等同于左移4位)直接定位到bucker的最后一个元素
),然后继续进行递归循环(第一个
递归循环嵌套第二个
递归循环),即⑥ - 如果当前
bucket
不等于buckets
的第一个元素,则继续向前查找
,进入第一次递归循环
- 如果一直都找不到,直接跳转至
- 比较获取的
- ⑥第二次递归循环:重复⑤的操作,与⑤中唯一区别是,如果当前的
bucket
还是等于buckets
的第一个元素,则直接跳转至JumpMiss
,此时的$0
是normal
,也是直接跳转至__objc_msgSend_uncached
,即进入慢速查找流程
以下是整个快速查找
过程值的变化
过程流程图
③ 慢速查找流程
① 慢速查找-汇编部分
在快速查找流程中,如果没有找到方法实现,无论是走到CheckMiss
还是JumpMiss
,最终都会走到__objc_msgSend_uncached
汇编函数
- 在
objc-msg-arm64.s
文件中查找__objc_msgSend_uncached
的汇编实现,其中的核心是MethodTableLookup
(即查询方法列表),其源码如下
- 搜索
MethodTableLookup
的汇编实现,其中的核心是_lookUpImpOrForward
,汇编源码实现如下
验证
上述汇编的过程,可以通过汇编调试
来验证
-
在
main
中,例如[person sayHello]
对象方法调用处加一个断点,并且开启汇编调试【Debug -- Debug worlflow -- 勾选Always show Disassembly】
,运行程序 -
汇编中
objc_msgSend
加一个断点,执行断住,按住control + stepinto
,进入objc_msgSend
的汇编 -
在
_objc_msgSend_uncached
加一个断点,执行断住,按住control + stepinto
,进入汇编
从上可以看出最后走到的就是lookUpImpOrForward
,此时并不是汇编实现.
注意
- 1、
C/C++
中调用汇编
,去查找汇编时
,C/C++调用
的方法需要多加一个下划线
- 2、
汇编
中调用C/C++
方法时,去查找C/C++
方法,需要将汇编调用的方法去掉一个下划线
② 慢速查找-C/C++部分
根据汇编部分的提示,全局续搜索lookUpImpOrForward
,最后在objc-runtime-new.mm
文件中找到了源码实现,这是一个c
实现的函数
慢速流程主要分为几个步骤:
- ①
cache
缓存中进行查找,即快速查找
,找到则直接返回imp
,反之,则进入② - ②判断cls
- 是否是
已知类
,如果不是,则报错
- 类是否
实现
,如果没有,则需要先实现,确定其父类链,此时实例化的目的是为了确定父类链、ro、以及rw等,方便后续数据的读取以及查找的循环 - 是否
初始化
,如果没有,则初始化
- 是否是
- ③
for
循环,按照类继承链
或者元类继承链
的顺序查找- 当前
cls
的方法列表中使用二分查找算法
查找方法,如果找到,则进入cache写入流程
(在iOS之武功秘籍⑤:cache_t分析文章中已经详述过),并返回imp
,如果没有找到,则返回nil
- 当前
cls
被赋值为父类
,如果父类等于nil
,则imp = 消息转发
,并终止递归,进入④ - 如果
父类链
中存在循环
,则报错,终止循环
-
父类缓存
中查找方法- 如果
未找到
,则直接返回nil
,继续循环查找
- 如果
找到
,则直接返回imp
,执行cache写入
流程
- 如果
- 当前
- ④
判断
是否执行过动态方法解析
- 如果
没有
,执行动态方法解析
- 如果
执行过一次
动态方法解析,则走到消息转发流程
- 如果
以上就是方法的慢速查找流程
,下面在分别详细解释二分查找原理
以及 父类缓存查找
详细步骤
getMethodNoSuper_nolock方法:二分查找方法列表
查找方法列表的流程如下所示 其二分查找核心的源码实现如下算法原理
简述为:从第一次查找开始,每次都取中间位置
,与想查找的key的value值
作比较,如果相等
,则需要排除分类方法
,然后将查询到的位置的方法实现返回,如果不相等
,则需要继续二分查找
,如果循环至count = 0
还是没有找到,则直接返回nil
,如下所示:
以查找TCJPerson
类的sayHello
实例方法为例,其二分查找过程如下
cache_getImp方法:父类缓存查找
cache_getImp
方法是通过汇编_cache_getImp
实现,传入的$0
是 GETIMP
,如下所示
- 如果
父类缓存
中找到了方法实现,则跳转至CacheHit
即命中,则直接返回imp
- 如果在
父类缓存
中,没有找到
方法实现,则跳转至CheckMiss
或者JumpMiss
,通过判断$0
跳转至LGetImpMiss
,直接返回nil
.
总结
- 对于
对象方法(即实例方法)
,即在类中查找
,其慢速查找的父类链
是:类--父类--根类--nil
- 对于
类方法
,即在元类中查找
,其慢速查找的父类链
是:元类--根元类--根类--nil
- 如果
快速查找、慢速查找
也没有找到方法实现,则尝试动态方法决议
- 如果
动态方法决议
仍然没有找到,则进行消息转发
常见方法未实现报错源码
如果在快速查找、慢速查找、方法解析流程中,均没有找到实现,则使用消息转发,其流程如下消息转发会实现
- 其中
_objc_msgForward_impcache
是汇编实现,会跳转至__objc_msgForward
,其核心是__objc_forward_handler
- 汇编实现中查找
__objc_forward_handler
,并没有找到,在源码中去掉一个下划线
进行全局搜索_objc_forward_handler
,有如下实现,本质是调用的objc_defaultForwardHandler
方法
看着objc_defaultForwardHandler
有没有很眼熟,这就是我们在日常开发中最常见的错误:没有实现函数,运行程序,崩溃时报的错误提示
.
🌰:定义TCJPerson
父类,其中有sayNB
实例方法 和 sayHappay
类方法
定义子类:TCJStudent
类,有实例方法sayHello
和sayMaster
,类方法sayObjc
,其中实例方法sayMaster
未实现.
在main
中 调用TCJStudend
的实例方法sayMaster
,运行程序报错,提示方法未实现,如下所示
下面,我们来讲讲如何在崩溃前,如何操作,可以防止方法未实现的崩溃.
四、动态方法解析
在慢速查找
流程未找到
方法实现时,首先会尝试一次动态方法决议
,其源码实现如下:
主要分为以下几步
- 判断
类是否是元类
- 如果是
类
,执行实例方法
的动态方法决议resolveInstanceMethod
- 如果是
元类
,执行类方法
的动态方法决议resolveClassMethod
,如果在元类中没有找到
或者为空
,则在元类
的实例方法
的动态方法决议resolveInstanceMethod
中查找,主要是因为类方法在元类中是实例方法
,所以还需要查找元类中实例方法的动态方法决议
- 如果是
- 如果
动态方法决议
中,将其实现指向了其他方法
,则继续查找指定的imp
,即继续慢速查找lookUpImpOrForward
流程
① 实例方法
针对实例方法
调用,在快速-慢速查找均没有找到实例方法的实现时,我们有一次挽救的机会,即尝试一次动态方法决议
,由于是实例方法
,所以会走到resolveInstanceMethod
方法,其源码如下
主要分为以下几个步骤:
- 在发送
resolveInstanceMethod
消息前,需要查找cls
类中是否有该方法的实现,即通过lookUpImpOrNil
方法又会进入lookUpImpOrForward
慢速查找流程查找resolveInstanceMethod
方法- 如果没有,则直接返回
- 如果有,则发送
resolveInstanceMethod
消息
- 再次慢速查找实例方法的实现,即通过
lookUpImpOrNil
方法又会进入lookUpImpOrForward
慢速查找流程查找实例方法
② 崩溃修改--动态方法决议
针对实例方法say666
未实现的报错崩溃,可以通过在类
中重写resolveInstanceMethod
类方法,并将其指向其他方法的实现,即在TCJPerson
中重写resolveInstanceMethod类方法
,将实例方法say666
的实现指向sayMaster
方法实现,如下所示
假如我们在resolveInstanceMethod
类方法中,不指向其他方法的实现,它会来两次,为什么会这样呢?我们在后面在解释...
③ 类方法
针对类方法
,与实例方法类似,同样可以通过重写resolveClassMethod
类方法来解决前文的崩溃问题,即在TCJPerson
类中重写该方法,并将sayNB
类方法的实现指向类方法sayHappy
resolveClassMethod
类方法的重写需要注意一点,传入的cls不再是类
,而是元类
,可以通过objc_getMetaClass
方法获取类的元类,原因是因为类方法在元类中是实例方法
.
④ 优化方案
上面的这种方式是单独在每个类中重写,有没有更好的,一劳永逸的方法呢?其实通过方法慢速查找流程可以发现其查找路径有两条
- 实例方法:类 -- 父类 -- 根类 -- nil
- 类方法:元类 -- 根元类 -- 根类 -- nil
它们的共同点是如果前面没找到,都会来到根类即NSObject中查找
,所以我们是否可以将上述的两个方法统一整合在一起呢?答案是可以的,可以通过NSObject添加分类
的方式来实现统一处理
,而且由于类方法的查找,在其继承链,查找的也是实例方法,所以可以将实例方法 和 类方法的统一处理放在resolveInstanceMethod
方法中,如下所示
这种方式的实现,正好与源码中针对类方法的处理逻辑是一致的,即完美阐述为什么调用了类方法动态方法决议,还要调用对象方法动态方法决议,其根本原因还是类方法在元类中是实例方法.
当然,上面这种写法还是会有其他的问题,比如系统方法也会被更改
,针对这一点,是可以优化的,即我们可以针对自定义类中方法统一方法名的前缀,根据前缀来判断是否是自定义方法,然后统一处理自定义方法
,例如可以在崩溃前pop到首页,主要是用于app线上防崩溃的处理,提升用户的体验.
⑤ 动态方法决议总结
-
实例方法
可以重写resolveInstanceMethod
添加imp
-
类方法
可以在本类重写resolveClassMethod
往元类
添加imp
,或者在NSObject分类
重写resolveInstanceMethod
添加imp
-
动态方法解析
只要在任意一步lookUpImpOrNil
查找到imp
就不会查找下去——即本类
做了动态方法决议,不会走到NSObjct分类
的动态方法决议 - 所有方法都可以通过在
NSObject分类
重写resolveInstanceMethod
添加imp
解决崩溃
那么把所有崩溃都在NSObjct分类
中处理,加以前缀区分业务逻辑,岂不是美滋滋?错!
- 统一处理起来耦合度高
- 逻辑判断多
- 可能在
NSObjct分类
动态方法决议之前已经做了处理 -
SDK
封装的时候需要给一个容错空间
因此前面的 ④ 优化方案
也不是一个最完美的解决方案.那么,这也不行,那也不行,那该怎么办?放心,苹果爸爸已经给我们准备好后路了!
五、消息转发机制
在慢速查找的流程(lookUpImpOrForward
)中,我们了解到,如果快速+慢速没有找到方法实现,动态方法决议也不行,就使用消息转发
,但是,我们找遍了源码也没有发现消息转发的相关源码,可以通过以下方式来了解,方法调用崩溃前都走了哪些方法
- 通过
instrumentObjcMessageSends
方式打印发送消息的日志
instrumentObjcMessageSends
通过lookUpImpOrForward --> log_and_fill_cache --> logMessageSend
,在logMessageSend
源码下方找到instrumentObjcMessageSends
的源码实现,所以,在main
中调用instrumentObjcMessageSends
打印方法调用的日志信息,有以下两点准备工作
-
1、打开
objcMsgLogEnabled
开关,即调用instrumentObjcMessageSends
方法时,传入YES
-
2、在
main
中通过extern
声明instrumentObjcMessageSends
方法[图片上传失败...(image-1b897a-1614008219381)] -
通过
logMessageSend
源码,了解到消息发送打印信息存储在/tmp/msgSends
目录,如下所示
- 运行代码,并前往
/tmp/msgSends
目录,发现有msgSends
开头的日志文件,打开发现在崩溃前,执行了以下方法- 两次动态方法决议:
resolveInstanceMethod
方法 - 两次消息快速转发:
forwardingTargetForSelector
方法 - 两次消息慢速转发:
methodSignatureForSelector + resolveInvocation
- 两次动态方法决议:
快速转发流程
forwardingTargetForSelector
在源码中只有一个声明,并没有其它描述,好在帮助文档中提到了关于它的解释:
- 该方法的返回对象是执行
sel
的新对象,也就是自己处理不了会将消息转发给别的对象进行相关方法的处理,但是不能返回self
,否则会一直找不到 - 该方法的效率较高,如果不实现,会走到
forwardInvocation:
方法进行处理 - 底层会调用
objc_msgSend(forwardingTarget, sel, ...);
来实现消息的发送 - 被转发消息的接受者参数、返回值等应和原方法相同
快速转发流程解决崩溃
如下代码就是通过快速转发解决崩溃——即TCJPerson
实现不了的方法,转发给TCJStudent
去实现(转发给已经实现该方法的对象)
也可以直接不指定消息接收者,直接调用父类的该方法
,如果还是没有找到,则直接报错
慢速转发流程
在快速转发流程找不到转发的对象后,会来到慢速转发流程methodSignatureForSelector
依葫芦画瓢,在帮助文档中找到methodSignatureForSelector
点击查看forwardInvocation
-
forwardInvocation
和methodSignatureForSelector
必须是同时存在的,底层会通过方法签名,生成一个NSInvocation
,将其作为参数传递调用 - 查找可以响应
NSInvocation
中编码的消息的对象(对于所有消息,此对象不必相同) - 使用
anInvocation
将消息发送到该对象.anInvocation
将保存结果,运行时系统将提取结果并将其传递给原始发送者
慢速转发流程解决崩溃
慢速转发流程
就是先methodSignatureForSelector
提供一个方法签名,然后forwardInvocation
通过对NSInvocation
来实现消息的转发
其实也可以对forwardInvocation
方法中的invocation
不进行处理,也不会崩溃报错
所以,由上述可知,无论在forwardInvocation
方法中是否处理invocation
事务,程序都不会崩溃.
通过hopper/IDA反汇编消息转发机制
Hopper和IDA是一个可以帮助我们静态分析可视性文件的工具,可以将可执行文件反汇编成伪代码、控制流程图等,下面以Hopper为例.
- 运行程序崩溃,查看堆栈信息
- 发现
___forwarding___
来自CoreFoundation
- 通过
image list
,读取整个镜像文件,然后搜索CoreFoundation
,查看其可执行文件的路径
- 通过文件路径,找到
CoreFoundation
的可执行文件
- 打开
hopper
,选择Try the Demo
,然后将上一步的可执行文件拖入hopper
进行反汇编,选择x86(64 bits)
- 以下是反汇编后的界面,主要使用上面的三个功能,分别是 汇编、流程图、伪代码
-
通过左侧的搜索框搜索
__forwarding_prep_0___
,然后选择伪代码
-
以下是
__forwarding_prep_0___
的汇编伪代码,跳转至___forwarding___
-
以下是
___forwarding___
的伪代码实现,首先是查看是否实现forwardingTargetForSelector
方法,如果没有响应,跳转至loc_6459b
即快速转发没有响应,进入慢速转发流程
-
跳转至
loc_6459b
,在其下方判断是否响应methodSignatureForSelector
方法 -
如果没有响应,跳转至
loc_6490b
,则直接报错 -
如果获取
methodSignatureForSelector
的方法签名为nil
,也是直接报错 -
如果
methodSignatureForSelector
返回值不为空,则在forwardInvocation
方法中对invocation
进行处理
通过上面两种查找方式可以验证,消息转发的方法有3个
- 【快速转发】forwardingTargetForSelector
- 【慢速转发】
- methodSignatureForSelector
- forwardInvocation
消息转发整体的流程如下!](https://img.haomeiwen.com/i2340353/0630f3b4f1f7b6ec.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
消息转发的处理主要分为两部分:
- 【快速转发】当慢速查找,以及动态方法决议均没有找到实现时,进行消息转发,首先是进行快速消息转发,即走到
forwardingTargetForSelector
方法- 如果返回
消息接收者
,在消息接收者中还是没有找到方法实现,则进入另一个方法的查找流程 - 如果返回nil,则进入慢速消息转发
- 如果返回
- 【慢速转发】执行到
methodSignatureForSelector
方法- 如果返回的
方法签名
为nil
,则直接崩溃报错
- 如果返回的
方法签名
不为nil
,走到forwardInvocation
方法中,对invocation
事务进行处理,如果不处理也不会报错
- 如果返回的
六、动态方法决议为什么执行两次?
在前文中提及了动态方法决议方法执行了两次,有以下两种分析方式
启用上帝视角的探索
在慢速查找流程中,我们了解到resolveInstanceMethod
方法的执行是通过lookUpImpOrForward --> resolveMethod_locked --> resolveInstanceMethod
来到resolveInstanceMethod
源码,在源码中通过发送resolve_sel
消息触发,如下所示
所以可以在resolveInstanceMethod
方法中IMP imp = lookUpImpOrNil(inst, sel, cls);
处加一个断点,通过bt
打印堆栈信息
来看到底发生了什么
- 在
resolveInstanceMethod
方法中IMP imp = lookUpImpOrNil(inst, sel, cls);
处加一个断点,运行程序,直到第一次“来了”,通过bt
查看第一次动态方法决议
的堆栈信息,此时的sel
是say666
- 继续往下执行,直到第二次“来了”打印,查看堆栈信息,在第二次中,我们可以看到是通过
CoreFoundation
的-[NSObject(NSObject) methodSignatureForSelector:]
方法,然后通过class_getInstanceMethod
再次进入动态方法决议
- 通过上一步的堆栈信息,我们需要去看看
CoreFoundation
中到底做了什么?通过Hopper
反汇编CoreFoundation
的可执行文件,查看methodSignatureForSelector
方法的伪代码
- 通过
methodSignatureForSelector
伪代码进入___methodDescriptionForSelector
的实现
- 进入
___methodDescriptionForSelector
的伪代码实现,结合汇编的堆栈打印,可以看到,在___methodDescriptionForSelector
这个方法中调用了objc4源码
的class_getInstanceMethod
- 在
objc4
源码中搜索class_getInstanceMethod
,其源码实现如下所示
这一点可以通过代码调试来验证,如下所示,在class_getInstanceMethod
方法处加一个断点,在执行了methodSignatureForSelector
方法后,返回了签名,说明方法签名是生效的,苹果在走到invocation
之前,给了开发者一次机会再去查询,所以走到class_getInstanceMethod
这里,又去走了一遍方法查询say666
,然后会再次走到动态方法决议
所以,上述的分析也印证了前文中resolveInstanceMethod
方法执行了两次的原因
无上帝视角的探索
如果在没有上帝视角的情况下,我们也可以通过代码
来推导在哪里再次调用了动态方法决议
-
TCJPerson
类中重写resolveInstanceMethod
方法,并加上class_addMethod
操作即赋值IMP
,此时resolveInstanceMethod
会走两次吗?
通过运行发现,如果赋值了IMP,动态方法决议只会走一次
,说明不是在这里走第二次动态方法决议
继续往下探索
- 去掉
resolveInstanceMethod
方法中的赋值IMP
,在TCJPerson
类中重写forwardingTargetForSelector
方法,并指定返回值为[TCJStudent alloc]
,重新运行,如果resolveInstanceMethod
打印了两次,说明是在forwardingTargetForSelector
方法之前执行了动态方法决议,反之,在forwardingTargetForSelector
方法之后
结果发现resolveInstanceMethod
中的打印还是只打印了一次,数排名第二次动态方法决议 在forwardingTargetForSelector
方法后
- 在
TCJPerson
类中重写methodSignatureForSelector
和forwardInvocation
,运行
结果发现第二次动态方法决议在 methodSignatureForSelector
和 forwardInvocation
方法之间.
第二种分析同样可以论证前文中resolveInstanceMethod
执行了两次的原因.
经过上面的论证,我们了解到其实在慢速消息转发流程中,在methodSignatureForSelector
和 forwardInvocation
方法之间还有一次动态方法决议,即苹果再次给的一个机会,如下图所示
写在后面
到目前为止,objc_msgSend
发送消息的流程就分析完成了,在这里简单总结下
-
【快速查找流程】
首先,在类的缓存cache
中查找指定方法的实现 -
【慢速查找流程
】如果缓存中没有找到,则在类的方法列表
中查找,如果还是没找到,则去父类链的缓存和方法列表
中查找 -
【动态方法决议】
如果慢速查找还是没有找到时,第一次补救机会
就是尝试一次动态方法决议
,即重写resolveInstanceMethod/resolveClassMethod
方法 -
【消息转发】
如果动态方法决议还是没有找到,则进行消息转发
,消息转发中有两次补救机会:快速转发+慢速转发
- 如果转发之后也没有,则程序直接报错崩溃
unrecognized selector sent to instance
最后,和谐学习,不急不躁.我还是我,颜色不一样的烟火.