阅读 304

iOS底层学习 - Runtime之砖厂面试答疑

通过对类,Runtime等底层的相关探索,对原理已经有了掌握,本章通过几个面试题来加深印象,会保持持续更新。

先上几篇原理文章,对面试题的理解会更深刻

传送门☞iOS底层学习 - Runtime之方法消息的前世今生(一)

传送门☞iOS底层学习 - Runtime之方法消息的前世今生(二)

1.什么是Runtime?

答:是由C 和C++ 汇编 实现的⼀套API,为OC语⾔加⼊了⾯向对象,运⾏时的功能。平时编写的OC代码,在程序运⾏过程中,其实最终会转换成Runtime的C语⾔代 码,RuntimeObjective-C 的幕后⼯作者。

比如:将数据类型的确定由编译时推迟到了运⾏时,比较典型的就是类的rorw属性。ro在编译期就确定好(read-only),而rw是运行时才确定,可以进行修改(read-write)。像下面的例子就比较典型👇

2.方法的本质是什么?

方法的本质就是消息的发送,就底层_objc_msgSennd方法寻找方法IMP的过程,主要经历了以下几个步骤:

  1. 快速查找流程:通过汇编(objc_msgSend)查找cache_t中缓存的消息
  2. 慢速查找流程:通过C代码函数lookUpImpOrForward递归查找当前类和父类的rwmethodlist的方法
  3. 动态方法解析:查找不到方法后进入此流程,通过调用和实现resolveInstanceMethod方法,来实现消息动态处理
  4. 消息快速转发:无方法无动态解析进入此流程,通过CoreFoundation框架来触发消息转发流程,forwardingTargetForSelector实现快速转发,其他类可实现处理方法
  5. 消息慢速转发:通过实现methodSignatureForSelector方法,来获取到方法的签名,从在生成相对应的invocation,通过forwardInvocation来对invocation进行处理,一般处置崩溃都在此处理
  6. 未找到消息:无法找到IMP,造成崩溃,打印log

3.sel是什么?IMP是什么?两者之间的关系⼜是什么?

SEL是方法编号,也是方法名,在dyld加载镜像带内存时,通过_read_image方法加载到内存的表中了

IMP 就是我们函数实现指针 ,找IMP就是找函数的过程

SEL就相当于书本的⽬录 tittle,

IMP 就是书本的⻚码,

函数就是具体页码对应的实现内容

查找具体的函数就是想看这本书⾥⾯具体篇章的内容

  1. 我们⾸先知道想看什么 ~ tittle (sel)
  2. 根据⽬录对应的⻚码 (imp)
  3. 翻到具体的内容

4.能否向运⾏时创建的类中添加实例变量?

不能。

因为我们编译好的实例变量存储的位置在ro,⼀旦编译完成,内存结构就完全确定 就⽆法修改,只能修改rw中的方法或者可以通过关联对象的方式来添加属性

关联对象添加的主要步骤如下:

  1. objc_setAssociatedObject设置set方法:找到关联对象的总哈希表,然后通过指针地址找到该类的哈希表,然后通过key值进行存储
  2. objc_getAssociatedObject设置get方法:和set方法一样查询表,找到值
  3. 在类的dealloc会清除关联对象的哈希表

5.isKindOfClassisMemberOfClass区别

原理探究

先上例子🌰

1.第一个

BOOL re1 = [(id)[NSObject class] isKindOfClass:[NSObject class]];       
BOOL re2 = [(id)[NSObject class] isMemberOfClass:[NSObject class]];     
BOOL re3 = [(id)[LGPerson class] isKindOfClass:[LGPerson class]];       
BOOL re4 = [(id)[LGPerson class] isMemberOfClass:[LGPerson class]];     
NSLog(@" re1 :%hhd\n re2 :%hhd\n re3 :%hhd\n re4 :%hhd\n",re1,re2,re3,re4);
复制代码

👆上述Log的打印结果为1,0,0,0

2.第二个

BOOL re5 = [(id)[NSObject alloc] isKindOfClass:[NSObject class]];       // 1
BOOL re6 = [(id)[NSObject alloc] isMemberOfClass:[NSObject class]];     // 1
BOOL re7 = [(id)[LGPerson alloc] isKindOfClass:[LGPerson class]];       // 1
BOOL re8 = [(id)[LGPerson alloc] isMemberOfClass:[LGPerson class]];     // 1
NSLog(@" re5 :%hhd\n re6 :%hhd\n re7 :%hhd\n re8 :%hhd\n",re5,re6,re7,re8);
复制代码

👆上述Log的打印结果为1,1,1,1

相信对于第二个例子,大家都使用的非常熟练。我们发现两个例子的主要区别在于消息接受者是实例对象还是类对象,打印结果,我们看源码探究

既然[NSObject class]类也可以调用这两个方法,说明这两个方法是有对应的类方法和实例方法的,只不过我们平时不适用类方法而已😂

/***********************************************************************
* object_getClass.
* Locking: None. If you add locking, tell gdb (rdar://7516456).
**********************************************************************/
Class object_getClass(id obj)
{
    if (obj) return obj->getIsa();
    else return Nil;
}
复制代码
✅//object_getClass()取得的是对象的isa指针指向的对象,也就是判断传入的类对象的元类对象是否与传入的这个对象相等,所以这个cls应该是元类对象才有可能相等
 + (BOOL)isMemberOfClass:(Class)cls {
 return object_getClass((id)self) == cls;
 }

 ✅//判断传入的实例对象的类对象是否与传入的对象相等,所以cls只有可能是类对象才有可能相等
 - (BOOL)isMemberOfClass:(Class)cls {
 return [self class] == cls;
 }

✅//循环判断传入的类对象的元类对象及其父类的元类对象是否等于传入的cls
 + (BOOL)isKindOfClass:(Class)cls {
 for (Class tcls = object_getClass((id)self); tcls; tcls = tcls->superclass) {
 if (tcls == cls) return YES;
 }
 return NO;
 }

✅//循环判断实例对象的父类的类对象是否等于传入的对象cls,也就是判断实例对象是否是cls及其子类的一种
 - (BOOL)isKindOfClass:(Class)cls {
 for (Class tcls = [self class]; tcls; tcls = tcls->superclass) {
 if (tcls == cls) return YES;
 }
 return NO;
 }
复制代码

通过这个两个方法的源码我们可以知道,

  • isMemberOfClass:是检测方法调用者对象的类是否等于传入的这个类。
  • isKindOfClass:是判断方法调用者对象的类是否等于传入的这个类或者其子类。

小结

还有一个适用于这四个方法的一点是,如果方法调用者是实例对象,那么传入的就应该是类对象;如果方法调用者是类对象,那么传入的就应该是元类对象。

分析BOOL re1 = [(id)[NSObject class] isKindOfClass:[NSObject class]];为啥打印是1呢?

答:[NSObject class]类对象调用isKindOfClass表明其元类会递归判断是否等于当前类或者其父类,我们知道NSObject的元类为根元类,根据继承链关系根元类的父类即为NSObject类对象,即[NSObject class],所以相等

6.[self class]和[super class]的区别以及原理分析

原理探究

创建一个Student类继承子Person类,下面代码打印出什么

NSLog(@"[self class] = %@", [self class]);
NSLog(@"[super class] = %@", [super class]);
NSLog(@"[self superclass] = %@", [self superclass]);
NSLog(@"[super superclass] = %@", [super superclass]);
复制代码

先上正确答案:

2020-01-17 15:54:02.224686+0800 TEST[8409:174143] [self class] = Student
2020-01-17 15:54:02.224922+0800 TEST[8409:174143] [super class] = Student
2020-01-17 15:54:02.225040+0800 TEST[8409:174143] [self superclass] = Person
2020-01-17 15:54:02.225922+0800 TEST[8409:174143] [super superclass] = Person
复制代码

我们发现第二个和第四个和我们猜想的貌似不太一样,并不是Person,先上源码看一下第一个和第三个的理解

/*******************************************************
✅ //通过对象的isa指针获取类的类对象
Class object_getClass(id obj)
 {
 if (obj) return obj->getIsa();
 else return Nil;
 }

 + (Class)class {
 return self;
 }

 - (Class)class {
 return object_getClass(self);
 }

 + (Class)superclass {
 return self->superclass;
 }

 - (Class)superclass {
 return [self class]->superclass;
 }

Class class_getSuperclass(Class cls)
{
    if (!cls) return nil;
    return cls->superclass;
}
******************************************************/
复制代码

我们知道,这里方法中的self都是指消息的接受者,在问题中就表示Student类,根据以上源码,第一个和第三个没啥疑问,都是寻找Studentisa和父类,那么super调用时,有啥不同,我们需要先知道super的本质

通过clang编译代码,我们可以发现,底层调用时super调用的方法不是magSend,而是objc_msgSendSuper,传入了一个super的结构体和方法,而super的结构体如下

objc_msgSendSuper(object ,superclass, @selector(class))
复制代码
struct objc_super {
    __unsafe_unretained _Nonnull id receiver;
    __unsafe_unretained _Nonnull Class super_class;
};
复制代码

两个参数分别为消息的接受者和父类,在这里Student即为消息接受者,Person为父类.

我们知道消息发送的时候,慢速查找流程是需要从自身递归查找到NSObject的,而objc_msgSendSuper就表示直接从消息接受者的父类开始递归查找,跳过了本身的方法列表,这样查找的速度可以更快

所以调用[super class][super superclass]本质上消息的接受者还是self,即Student类,由于class方法的实现,其实是在基类NSObject中的,所以不管是从Student类方法列表开始查询,还是从父类Person方法列表查询,最终都会走到基类中的class方法,而此方法根据源码就是寻找消息接受者的isasuperclass,所以出现上述的打印结果

小结

[self class] 就是发送消息objc_msgSend,消息接受者是 self,⽅法编号:class

[super class] 本质就是objc_msgSendSuper, 消息的接受者还是 self ⽅法编号:class

只是objc_msgSendSuper 会更快 直接跳过 self 的查找,但是都会走到NSObject基类的实现方法中,但是都是以self为接受者

7.Runtime是如何实现weak的,为什么可以⾃动置nil?

主要总结如下:

  1. 通过SideTable找到我们的weak_table

  2. weak_table 根据referent 找到或者创建 weak_entry_t

  3. 然后append_referrer(entry, referrer)将新弱引⽤的对象加进去entry

  4. 最后weak_entry_insertentry加⼊到我们的weak_table

  5. 在类dealloc时,会根据插入的步骤找到对应的弱引用,并置为nil

关于weak的相关知识做了单独的总结,详情可以看下方文章👇

iOS底层学习 - 内存管理之weak原理探究

8.Method Swizzling的坑与应⽤

关于Method Swizzling方法交换的总结,详情可以看下面的文章:

iOS底层学习 - Runtime之Method Swizzling黑魔法

9.压轴大题:下列代码能否运行,打印结果是什么?

题目

***********************LGPerson***************************
@interface LGPerson : NSObject
@property (nonatomic, copy)NSString *name;
//@property (nonatomic, copy)NSString *subject;
//@property (nonatomic)int age;

- (void)print;
@end

@implementation LGPerson
- (void)print{
    NSLog(@"NB %s - %@",__func__,self.name);
}
@end

**************************调用*****************************
- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSString *tem = @"WY";
    id cls = [LGPerson class];
    void *obj= &cls;
    [(__bridge id)obj print];
    
    //    LGPerson *person = [LGPerson alloc];
    //    [person print];
    
}

复制代码

运行结果

2020-01-20 17:40:15.322404+0800 LGTest[86411:20872420] NB -[LGPerson saySomething] - KC
复制代码

相信看到这个结果大家都是和我一样一脸懵逼。下面就分为2个问题,我们来研究一下

问题1: 能否运行

通过结果也看到了,这段代码是可以运行的。并且成功调用了[LGPerson saySomething]方法,我们首先看为何能成功调用,它和我们注释的普通的实例对象调用为何能一样。

首先来看一下普通方法调用的时序:

  • 实例对象p的指针 --> 实例对象p的isa --> [LGPerson class]类地址

如图所示:

接着来分析代码中的执行时序:

  1. id cls = [LGPerson class];获取到类对象指针
  2. void *obj= &cls;获取到指向该类对象cls的对象obj
  3. [(__bridge id)obj print];消息发送

按照上面的时序的结构总结一下,就是:

  • 指针obj --> 指针cls --> [LGPerson class]类地址

所以从本质上来说,最后消息发送的时候,都是根据isa获取到类的内存空间,然后再方法列表中查找IMP。而obj指向的是一个cls,但是cls正巧也是指向类的内存空间的,所以同样也可以找到方法列表中的print方法,进行调用

问题2: 打印内容及为什么会打印这个结果

通过运行结果我们可以看到,方法调用self.name竟然打印出了VC中的NSString的临时变量的值,那么为什么会出现这个结果呢?

我们知道任何OC方法的底层都是一个C函数,并且函数头两个参数是默认参数id selfSEL _cmd,那么self是谁呢

首先我们还是来看一下正常的调用self.name是如何找到的

  • 此时,我们知道消息发送时的self(消息接受者)指的就是我们实例化的对象person,而person指向的是实例对象的内存空间首地址,而内存空间首地址是第一个元素isa,占用8字节,name是第二个元素,也是占用8个字节。寻找self.name的过程就是指针偏移的过程,因为isa占用了8个,所以找到name的值时,只需要向后便宜8个字节即可。如图所示:

那么我们再来看一下代码中时如何找到self.name

通过上面的时序,我们知道这两者在调用方法上是对等的。这里消息发送时的self(消息接受者)指的就是objcls指针相当于person指针所指向的实例对象里面的isa指针,同理,此时指向的类的首地址,但是要找的是name,向下指针偏移8个后,找到了生命的临时变量的值,所以会打印出来

函数的栈空间简介

这里就涉及到了为什么会找到临时变量的问题,如果去掉了临时变量,又会打印什么呢,这里就有了函数栈空间的作用

栈空间的作用,是用来存放被调用函数其内部所定义的局部变量的。我们都知道栈的特点是先入后出,所以先存进栈的在底层。由于方法的调用和super的调用,都会产生局部变量,所以viewdidload的栈示意图如下:

此时代码指向的就是图中橘色obj的首地址,如果没有临时变量,便宜8位后,就会指向self,即当前的viewcontroller

如果加了一个临时变量NSString,栈的结构就会变成如下所示,所以会打印它的值

可以看出此时对象的实例变量获取即为void *ivar = &obj + offset(N)

但是我们指针偏移的offset不正确,获取不到对应变量的首地址,此时就会出现野指针的情况,所以千万不能像代码中那么用

参考

iOS底层学习 - OC对象前世今生

Runtime笔记(八)—— 面试题中的Runtime

神经病院 Objective-C

神经病院objc runtime入院考试