前言
上篇文章介绍了方法调用的本质是消息发送。那如果经过查找后,没有找到方法,系统会怎么处理?这就是本文接下来介绍的方法的动态决议和消息转发。
动态决议
当方法查找一直查到父类为nil之后,有imp
赋值为forward_imp
这个操作
这是方法开始就声明的
通过源码无法找到实现,然后在汇编里找到了:
TailCallFunctionPointer
只是函数调用,没有什么研究价值;
// jop
.macro TailCallFunctionPointer
// $0 = function pointer value
braaz $0
.endmacro
// not jop
.macro TailCallFunctionPointer
// $0 = function pointer value
br $0
.endmacro
再看前面两行汇编代码提到的_objc_forward_handler
:
// Default forward handler halts the process.
__attribute__((noreturn, cold)) void
objc_defaultForwardHandler(id self, SEL sel)
{
_objc_fatal("%c[%s %s]: unrecognized selector sent to instance %p "
"(no message forward handler is installed)",
class_isMetaClass(object_getClass(self)) ? '+' : '-',
object_getClassName(self), sel_getName(sel), self);
}
void *_objc_forward_handler = (void*)objc_defaultForwardHandler;
指针指向的方法objc_defaultForwardHandler
就在上面,熟悉的报错信息:unrecognized selector sent to instance
这里还体现了类方法和实例方法的判断,仅仅是通过class_isMetaClass
(是不是元类)来区分,再次证明底层没有类方法和实例方法的区别。
回到lookUpImpOrForward
方法,这里还没有调用这个imp方法,只是赋值。也就是在报错前,会把for循环当前流程走完。
下面一段逻辑,注释提到执行一次method resolver
;
这个地方的判断相当于一个单例的效果。打个断点跑一下源码:
这里behavior
进来时初始值就是3,
LOOKUP_RESOLVER
= 2; 也就是说if判断是 3 & 2 = 2
,第一次必定进入代码块内部,^
是异或运算,二进制位相同为0,不同为1:
behavior ^= LOOKUP_RESOLVER // 3 ^ 2 = 011 ^ 010 = 001 = 1;
然后传入resolveMethod_locked
方法,会调用一次动态决议方法,稍后再细说,这里先看一下方法的结尾,
来到lookUpImpOrForwardTryCache
方法,实际调用的是_lookUpImpTryCache
方法;
IMP lookUpImpOrForwardTryCache(id inst, SEL sel, Class cls, int behavior)
{
return _lookUpImpTryCache(inst, sel, cls, behavior);
}
进入_lookUpImpTryCache
源码,可以看到这里有cache_getImp
;也就是说在进行一次动态决议之后,还会通过cache_getImp
从cache
里找一遍方法的sel
。
如果还是没找到(imp == NULL
)?也就是无法通过动态添加方法的话,还会执行一次lookUpImpOrForward
,
这时候进lookUpImpOrForward
方法,这里behavior
传的是1了。执行到if (slowpath(behavior & LOOKUP_RESOLVER))
这个判断时,就是 1 & 2 = 0
,不会再进入里面的代码块,这就是为什么说相当于单例。
那么确定第一次执行会进入resolveMethod_locked
(内部包含方法的动态决议),
/***********************************************************************
* resolveMethod_locked
* Call +resolveClassMethod or +resolveInstanceMethod.
*
* Called with the runtimeLock held to avoid pressure in the caller
* Tail calls into lookUpImpOrForward, also to avoid pressure in the callerb
**********************************************************************/
static NEVER_INLINE IMP
resolveMethod_locked(id inst, SEL sel, Class cls, int behavior)
{
runtimeLock.assertLocked();
ASSERT(cls->isRealized());
runtimeLock.unlock();
if (! cls->isMetaClass()) {
// try [cls resolveInstanceMethod:sel]
resolveInstanceMethod(inst, sel, cls);
}
else {
// try [nonMetaClass resolveClassMethod:sel]
// and [cls resolveInstanceMethod:sel]
resolveClassMethod(inst, sel, cls);
if (!lookUpImpOrNilTryCache(inst, sel, cls)) {
resolveInstanceMethod(inst, sel, cls);
}
}
// chances are that calling the resolver have populated the cache
// so attempt using it
return lookUpImpOrForwardTryCache(inst, sel, cls, behavior);
}
可以看到两个方法:resolveInstanceMethod
和resolveClassMethod
。也称为方法的动态决议。
实例方法的动态决议
我们可以在类里面重写这2个方法,为我们没有实现的方法,通过runtime的api进行动态添加方法实现。(对sel动态的添加imp)
接收者cls,说明这是一个类方法,看到这里,总结一下:
当方法找不到的时候,在进行报错之前,还会通过@selector(resolveInstanceMethod:);
调用一次类里的该方法,如果有实现的话,就能找到。尝试一下:
#import <Foundation/Foundation.h>
@interface Goods : NSObject
-(void)introduce;
@end
@implementation Goods
+(BOOL)resolveInstanceMethod:(SEL)sel {
NSLog(@"%s, sel = %@", __func__, NSStringFromSelector(sel));
return [super resolveInstanceMethod:sel];
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
Goods *goods = [[Goods alloc] init];
[goods introduce];
}
return 0;
}
运行
可以看到为什么会有2次执行呢?放到最后再讲。类方法也是如此。
既然是因为找不到imp而崩溃,那么我们可以在这个方法里通过runtime
的class_addMethod
,给sel动态的生成imp。其中第四个参数是返回值类型,用void
用字符串描述:"v@:"
BOOL
class_addMethod(Class cls, SEL name, IMP imp, const char *types)
{
if (!cls) return NO;
mutex_locker_t lock(runtimeLock);
return ! addMethod(cls, name, imp, types ?: "", NO);
}
方法修改:
+(BOOL)resolveInstanceMethod:(SEL)sel {
NSLog(@"%s, sel = %@", __func__, NSStringFromSelector(sel));
if (sel == @selector(introduce)) {
IMP imp = class_getMethodImplementation(self.class, @selector(addMethod));
class_addMethod(self.class, sel, imp, "v@:");
}
return [super resolveInstanceMethod:sel];
}
-(void)addMethod {
NSLog(@"%s", __func__);
}
可以看到运行正常了:
回到决议方法:
动态添加实现之后,还会从cache
里找imp
。试一下能不能找到:
执行实例方法前,正好扩容。(goods.class系统会自动添加2个方法 + init,达到 3/4 扩容条件)
然后LLDB
调试打印出方法:
/*
x/4gx goods.class
p (cache_t *)0x100008930
p *$1
p $2.buckets()
p *$3
p $3+4
p $6.sel()
*/
成功找到:
确实添加进去了。注意,这里sel
虽然是introduce
,但是imp可是addMethod
;
类方法的动态决议
再看这里,当判断是元类的时候,也就是类方法找不到,会调用resolveClassMethod
增加代码验证一下:
+(BOOL)resolveClassMethod:(SEL)sel {
NSLog(@"%s, sel = %@", __func__, NSStringFromSelector(sel));
if (sel == @selector(introduce)) {
IMP imp = class_getMethodImplementation(self.class, @selector(classMethod));
class_addMethod(objc_getMetaClass("Goods"), sel, imp, "v@:");
}
return [super resolveInstanceMethod:sel];
}
-(void)classMethod {
NSLog(@"%s", __func__);
}
运行:
扩展1:如果添加的方法没有实现,并且实例的动态决议也不添加方法。
resolveClassMethod
也是调用了2次,其中第二次进入的sel是_forwardStackInvocation
,这就是文章后面会涉及到的消息转发。
扩展2:如果把if判断都去掉
打印结果竟然去执行了addMethod
实例方法;
注意看源码这里:如果cache
里没有(因为类方法没有找到,就不会添加到cache里),会调用实例方法:
由于去掉了方法名判断,所以最终找到实例方法addMethod
去了;
iOS为什么这么做呢?首先,类方法是去元类找到的,那这个类方法的动态决议,正常应该放到元类里的。
但是我们无法在元类里写代码,如果系统没提供resolveClassMethod
,如何进行动态决议呢?
结合消息发送的流程,以及类的继承链,可以想到,把resolveInstanceMethod
方法写到NSObject分类里面。因为子类没有就会从父类找,直到找到NSObject分类里,所以也是能够解析到的。
不过这个消息的查找流程比较长,影响效率。所以才有了resolveClassMethod
,来给类方法提供动态实现,目的是简化类方法的查找流程,直接在当前类里实现。
进一步理解动态决议
如果方法实现改成从元类里获取?结果死循环。
梳理一下流程:
- 调用类方法
allGoods
,因为没有实现,所以调用resolveClassMethod
; - 从元类里查找
classMethod
,元类里自然没有实例方法的实现,所以找不到。进行动态决议; - 又回到
resolveClassMethod
; 如此循环;
上一个例子能找到实例方法的实现,因为传的不是元类。
这些机制有什么应用场景呢?
AOP埋点的思路
将代码粘贴到NSObject
分类里。
再也不会出现方法找不到的崩溃了,resolveClassMethod
方法也不用了。因为最终会找到NSObject
这个分类里。有点AOP
(面向切面编程)的意思,常用于埋点。应用场景:在该分类提示/上报没有实现的imp。
对效率有什么影响?这是系统提供的防止崩溃的手段,主要为了保证系统的稳定性,非必要不使用。
写一个demo,记录页面停留时间。记录单个页面:
如果页面非常多呢?给父类ViewController
加一个分类
通过方法交换,所有的控制器就添加了埋点。注意保证方法只被交换一次,还需要借助dispatch_once
。
能走到这个分类的原因是什么?原方法里的super
:
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
}
本质是还是通过消息发送,从父类里找方法实现,才能找到分类里交换的方法。所以重载这个方法的时候,一定记得调用父类的方法 。
消息转发
如果系统在动态决议阶段没有找到实现,就会进入消息转发阶段。
消息的快速转发
方法找到后会执行done
代码块
进入log_and_fill_cache
方法,插入cache
前还有个判断
/***********************************************************************
* log_and_fill_cache
* Log this method call. If the logger permits it, fill the method cache.
* cls is the method whose cache should be filled.
* implementer is the class that owns the implementation in question.
**********************************************************************/
static void
log_and_fill_cache(Class cls, IMP imp, SEL sel, id receiver, Class implementer)
{
#if SUPPORT_MESSAGE_LOGGING
if (slowpath(objcMsgLogEnabled && implementer)) {
bool cacheIt = logMessageSend(implementer->isMetaClass(),
cls->nameForLogging(),
implementer->nameForLogging(),
sel);
if (!cacheIt) return;
}
#endif
cls->cache.insert(sel, imp, receiver);
}
是往文件里写入信息
前面的判断if (slowpath(objcMsgLogEnabled && implementer))
, 入参implementer
是上个方法的curClass,所以必定有值;那么看看objcMsgLogEnabled
默认值是false,接着搜索一下哪里使用到;发现在instrumentObjcMessageSends
,方法里进行赋值
搞个demo试一下,通过extern
关键字导出这个方法
#import <Foundation/Foundation.h>
@interface Goods : NSObject
-(void)introduce;
@end
@implementation Goods
-(void)introduce {
}
@end
extern void instrumentObjcMessageSends(BOOL flag);
int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...
NSLog(@"Hello, World!");
Goods *goods = [Goods alloc];
instrumentObjcMessageSends(YES);
[goods introduce];
instrumentObjcMessageSends(NO);
}
return 0;
}
运行后,来到logMessageSend
方法提到的目录tmp
打开文件
如果把方法实现注释掉,在运行看看log文件里多出了什么
在方法崩溃前调用的方法栈记录。可以看到,如果没有实现方法,以及没有重写动态决议,系统会进行了上面两个方法的调用,这就是消息快速转发。
示例:
#import <Foundation/Foundation.h>
@interface FFAnimal : NSObject
- (void)func1;
+ (void)func2;
@end
@interface FFTiger : NSObject
- (void)func1;
+ (void)func2;
@end
@implementation FFAnimal
-(id)forwardingTargetForSelector:(SEL)aSelector {
NSLog(@"%s, aSelector = %@",__func__, NSStringFromSelector(aSelector));
if (aSelector == @selector(func1)) {
return [FFTiger alloc];
}
return [super forwardingTargetForSelector:aSelector];
}
@end
@implementation FFTiger
- (void)func1 {
NSLog(@"%s",__func__);
}
+ (void)func2 {
NSLog(@"%s",__func__);
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
FFAnimal *animal = [FFAnimal alloc];
[animal func1];
}
return 0;
}
运行:
转发的作用在于,如果当前对象无法响应消息,就将它转发给能响应的对象。
这时候方法缓存在哪?接收转发消息的对象
应用场景:专门搞一个类,来处理这些无法响应的消息。方法找不到时的crash收集。
演示的是实例方法,如果是类方法,只需要将 -
改成 +
;修改完运行:
消息的慢速转发
如果消息的快速转发也没有找到方法;回看日志,后面还有个methodSignatureForSelector
方法,作用是方法有效性签名。
修改代码再运行看看
-(NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
NSLog(@"%s, aSelector = %@",__func__, NSStringFromSelector(aSelector));
return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
直接崩溃了。
因为方法签名需要搭配另一个方法:
- (void)forwardInvocation:(NSInvocation *)anInvocation;
再运行,就不奔溃了;
在调用func1
时,虽然没有提供方法实现,但是在了方法的慢速转发里提供了有效签名(只要格式正确,和实际返回类型不同也行),代码就不崩溃了。
防止系统崩溃的三个救命稻草:动态解析、快速转发、慢速转发。
forwardInvocation
方法提供了一个入参,类型是NSInvocation
;它提供了target
和selector
用于指定目标里查找方法实现。
NS_SWIFT_UNAVAILABLE("NSInvocation and related APIs not available") // swift不能用
@interface NSInvocation : NSObject
+ (NSInvocation *)invocationWithMethodSignature:(NSMethodSignature *)sig;
@property (readonly, retain) NSMethodSignature *methodSignature;
- (void)retainArguments;
@property (readonly) BOOL argumentsRetained;
@property (nullable, assign) id target;
@property SEL selector;
- (void)getReturnValue:(void *)retLoc;
- (void)setReturnValue:(void *)retLoc;
- (void)getArgument:(void *)argumentLocation atIndex:(NSInteger)idx;
- (void)setArgument:(void *)argumentLocation atIndex:(NSInteger)idx;
- (void)invoke;
- (void)invokeWithTarget:(id)target;
@end
补充一些代码:
- (void)forwardInvocation:(NSInvocation *)anInvocation {
FFTiger *t = [FFTiger alloc];
// 如果自己能响应
if ([self respondsToSelector:anInvocation.selector]) {
[anInvocation invokeWithTarget:self];
}
// 实例能响应
else if ([t respondsToSelector:anInvocation.selector] ) {
[anInvocation invokeWithTarget:t];
}
// 都无法响应
else {
NSLog(@"功能开发中,敬请期待");
}
}
应用场景:统一处理没实现的方法,进行提示。你也可以不做任何处理,这样消息找不到的崩溃就不会出现了。
不过救命稻草不能解决实际问题,只是为了app稳定性的一种手段。
流程图:
如果每个流程走到最后,就是日志里的doesNotRecognizeSelector
方法:
触发后面打印的崩溃信息。
这个救命稻草一般写在哪?NSObject的分类里,这样只要写一次。
两次动态决议的原因
还是前面的demo,然后注释方法实现。断点看一下:(方法最好不要放在NSObject分类里,放到本类里比较方便)
第一次因为uncache进入;第二次是消息转发:
lldb输入指令bt可以看到打印的信息,里面调用了___forwarding___
符号。
上一行是熟悉的慢速转发methodSignatureForSelector
方法;在这些CoreFoundation
框架的方法之后,第一个调用的方法是class_getInstanceMethod
,源码里找一下实现:
梳理一下:在消息的第一次动态决议和快速转发都没找到方法后,进入到慢速转发。过程中,runtime
还会调用一次lookUpImpOrForward
,这个方法里包含了动态决议,这才造成了二次动态决议。
总结
动态决议
通过消息发送机制也找不到方法,系统在进入消息转发前,还会进行动态决议。
实例方法的动态决议
+ (BOOL)resolveInstanceMethod:(SEL)sel;
// 系统通过该方法调用上面OC类里的实现
static void resolveInstanceMethod(id inst, SEL sel, Class cls)
类方法的动态决议
+ (BOOL)resolveClassMethod:(SEL)sel;
消息转发
动态决议也找不到方法,才真正进入消息转发环节。
动态决议、快速转发、慢速转发合称为三个救命稻草,用于防止方法查找导致的系统崩溃。
消息快速转发
- (id)forwardingTargetForSelector:(SEL)aSelector;
消息慢速转发
// 方法签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
// 正向调用
- (void)forwardInvocation:(NSInvocation *)anInvocation;
AOP与埋点
面向切面编程(AOP)在不修改源代码的情况下,通过运行时给程序添加统一功能的技术。 埋点就是在应用中特定的流程收集一些信息,用来跟踪应用使用的状况,然后精准分析用户数据。 比如⻚面停留时间、点击按钮、浏览内容等等。
动态决议二次调用
慢速转发过程中,通过runtime
又调用了一次lookUpImpOrForward
方法。