一、OC对象的本质
知识点
- 我们平时编写的OC代码,底层实现其实都是C\C++代码,OC的对象、类底层都是由C\C++结构体实现的
- 可以通过以下命令,可以将OC代码转换成C++代码,这种转换并不是准确的,因为从Clang新版本开始会将iOS代码转换成中间代码,而不是C++代码,所以转成C++的代码仅供参考(PS:大部分情况下还是准确的,想要特别精准就需要通过查看汇编代码来实现了)
将Objective-C代码转换为C\C++代码:
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc OC源文件 -o 输出的CPP文件
如果需要链接其他框架,使用-framework参数。比如-framework UIKit
- 凡是继承自
NSObject
的对象,都会自带一个类型是Class
的isa
成员变量,将其转成C++,就可以看到NSObject本质上是一个叫做NSObject_IMPL
的结构体,其成员变量isa
本质上也是一个指向objc_class
结构体的指针,如下所示:
- 凡是继承自
- 一个
NSObject
对象在内存中的布局如下所示,堆空间不但会存放子类的成员变量,还会存放类对象的isa指针
- 一个
- 系统会给一个
NSObject对象
分配16个字节的内存,而NSObject对象实际只占用了8个字节的内存,占用的这8个字节的就是isa指针
,剩下8个字节是系统为了内存对齐而分配的,如下所示:
- 系统会给一个
创建一个实例对象,至少需要多少内存?
#import <objc/runtime.h>
class_getInstanceSize([NSObject class]);
创建一个实例对象,实际上分配了多少内存?
#import <malloc/malloc.h>
malloc_size((__bridge const void *)obj);
- OC对象主要可以分为三类:
instance实例对象、class类对象、meta-class元类对象
,他们之间的关系,可以用一张经典的图来表示,如下所示:
- OC对象主要可以分为三类:
上图中的关系用文字表示是这样的:
- instance的isa指向class
- class的isa指向meta-class
- meta-class的isa指向基类的meta-class
- class的superclass指向父类的class,如果没有父类,superclass指针为nil
- meta-class的superclass指向父类的meta-class,基类的meta-class的superclass指向基类的class
- instance调用对象方法的轨迹:isa找到class,方法不存在,就通过superclass找父类
- class调用类方法的轨迹:isa找meta-class,方法不存在,就通过superclass找父类
- 从
64bit
开始,也就是采用了ARM64处理器之后,OC的isa
被改进成了union共用体
,其isa指针
并不是直接指向类对象或者元类对象的,而是要通过一个&ISA_MASK
的位运算,才能获取到真正的类对象或者元类对象的地址;优化之后的isa指针
,每一位都有其含义,虽然这么优化节省的内存不多,但是对于使用频率如此之高的isa指针来说,还是非常有意义的,至于为什么要经过&ISA_MASK位运算
才能拿到类对象的真正地址,我们后续再说
- 从
-
isa & ISA_MASK
是指向objc_class
结构体的,我们从objc4源码摘出来objc_class
的结构如下所示,我们可以看出objc_class
结构体内部,保存了isa、superclass、方法缓存、class_rw_t
可读写的类信息(方法列表 、属性列表 、协议列表) 、class_ro_t
只读的类信息(类名、成员变量列表)等信息
-
PS: 这里的方法列表、属性列表、协议列表都归属于class_rw_t
可读写的类信息中,而成员变量列表归属于class_ro_t
只读的类信息中,所以方法列表、属性列表、协议列表都是可以通过RunTime
动态增加的,而成员变量列表就不能动态增加!!!这就是Category为什么只能增加方法,不能增加成员变量的核心原因(通过关联方式增加的成员变量是通过全局变量来存储的)
面试题
-
- 一个NSObject对象占用多少字节的内存?
答:一个NSObject实际占用8个字节,是用来存放isa指针的,而系统分配了16个字节,额外多的8个字节是为了内存对齐而分配的
-
- 对象的isa指针指向哪里?
答:实例对象的isa指向类对象,类对象的isa指向元类对象,元类对象的isa指向基类的元类对象
-
- OC对象的类信息存放在哪里?
答:OC对象的实例方法列表、属性列表、协议列表等信息存放在类对象中;OC对象的类方法列表存放在元类中;成员变量具体的值存放在实例对象中。
二、KVO
知识点
-
KVO
用于监听某个对象属性的值是否改变,未使用KVO监听对象时,NSObject对象
的实例对象和类对象的内存布局如下:
-
-
使用了KVO监听的对象,其实例对象和类对象的内存布局如下所示:
-
使用了KVO监听的对象,其实例对象和类对象的内存布局如下所示:
- KVO本质上会生成一个
NSKVONotifying_XXX
的派生类,如上图所示,MJPerson的实例对象
的isa指针
会指向该派生类,该派生类的superClass指针
会指向MJPerson的类对象
,KVO的机制还会在派生类中重写此属性的set方法
,在set方法中,先调用willChangeValueForKey
方法,再调用原来的setter实现
,再调用didChangeValueForKey
方法
- KVO本质上会生成一个
以监听age属性为例,KVO的触发流程:
[self willChangeValueForKey:@"age"];
super.age = age; //原来的方法实现
[self didChangeValueForKey:@"age"];
-----didChangeValueForKey方法内部会调用observer的observeValueForKeyPath:ofObject:change:context:方法
面试题
-
- KVO的本质是什么?
答:利用RuntimeAPI动态实现了一个子类,并且让实例对象的isa指向这个全新的子类,当修改实例对象的属性时,会调用Foundation的
_NSSetXXXValueAndNotify
函数,其内部的调用顺序是这样的:willChangesValueForKey、父类原来的setter、didChangeValueForKey
-
- 如何手动触发KVO?
答:手动调用
willChangeValueForKey:
和didChangeVableForKey:
方法 -
- 直接修改成员变量会触发KVO吗?
答:不会触发KVO
-
- 通过KVC修改属性会触发KVO吗?
答:会触发KVO
三、Category
知识点
- Category编译之后的底层结构是
struct category_t
,每一个分类都会对应一个category_t
结构体,通过阅读objc4源码我们可以得知,struct category_t
里面存储着分类的对象方法、类方法、属性、协议信息,如下所示:
- Category编译之后的底层结构是
- 从源码基本可以看出,在
category_t
结构体中,对象方法,类方法,协议,和属性都可以找到对应的存储方式。并且我们发现分类结构体中是不存在成员变量的,因此分类中是不允许添加成员变量的。分类中添加的属性并不会帮助我们自动生成成员变量,只会生成get set方法的声明,需要我们自己去实现。
- 从源码基本可以看出,在
-
- 某个类的
Category
的加载过程是这样的:
(1). 首先会通过
Runtime
加载这个类的所有分类的数据,包括这个类的所有分类中的方法列表、属性列表、协议列表-
(2). 然后把这个类的所有分类的方法列表、属性列表、协议列表,分别合并到一个各自的大数组中,这里对分类采用了
while(i--)倒叙遍历
,所以后面参与编译的分类,其数据会放在数组的前面,然后会将合并后的大数组,插入到这个类的class_rw_t结构体
中,整个流程的源码如下图所示:
-
(3). 将分类数据(方法列表、属性列表、协议列表),插入到类
class_rw_t
结构体时,调用了attachLists()方法
,在这个方法内部,会将分类的方法,属性,协议列表放在了类对象中原本存储的方法,属性,协议列表的前面,如下面源码所示,这样做的目的是为了保证分类方法优先调用,我们知道当分类重写本类的方法时,会覆盖本类的方法,其实本质上并不是覆盖,而是优先调用,本类原来的方法依然是存在的。
- 某个类的
-
- 以
调用 A 对象的 B 方法
为例,梳理一下整个调用流程:
首先从A对象的内存中拿到
isa指针
,进行一次位运算isa & ISA_MASK
后,拿到真正的A对象的类地址然后从方法缓存
cache
中查找B方法,找到就调用,找不到的话就从class_rw_t
的方法列表methods
中查找方法,此时Runtime早已经帮我们把分类的方法列表,插入到了方法列表methods
中了,所以我们只需要按顺序在methods
中查找,找到就调用,并将其放入方法缓存cache
中找不到就通过
superClass指针
找到父类,将上述步骤再走一遍,然后一直重复,直到superclass为空为止,如果一直没找到B方法,就进入动态方法解析和消息转发
- 以
-
-
+load方法
会在runtime
加载类、分类时调用,每个类、分类的+load
方法在程序运行过程中只调用一次,调用顺序如下:
(1). 先调用类的
+load方法
,按照编译顺序调用,先编译的先调用,调用子类的.+load方法
之前会先调用父类的+load方法
-
(2). 再调用分类的
+load方法
,按照编译顺序调用,先编译的先调用PS:
+load方法
是根据方法地址直接调用的,不会走objc_msgSend消息发送流程
-
-
-
+initialize方法
会在类第一次接受到消息时调用,调用顺序是:先调用父类的+initialize方法
,在调用子类的+initialize方法
,需要注意的是:
如果子类没有实现
+initialize方法
,就会调用父类的+initialize方法
(所以父类的+initialize方法
可能会被调多次)如果分类实现
+initialize方法
,就会覆盖类本身的+initialize方法
-
- 我们知道由于类对象底层结构的限制,不能将成员变量动态插入到类中,但可以通过关联对象来间接实现,关联对象的原理是:将成员变量存储在全局统一的
AssociationsManager
中,而不是存储在对象本身的内存中,由AssociationsHashMap来管理所有被添加到对象中的关联对象,其流程如下所示:
- 我们知道由于类对象底层结构的限制,不能将成员变量动态插入到类中,但可以通过关联对象来间接实现,关联对象的原理是:将成员变量存储在全局统一的
面试题
-
- Category中有load方法嘛?load方法什么时候调用的?load方法能被继承吗?
答:有load方法,load方法在runtime加载类、分类时调用,load方法可以继承,一般情况下不会主动调用load方法,都是让系统自动调用
- load、initialize方法的区别是什么?
(1). 调用时机不同:
1>. load是runtime加载类和分类的时候调用,只会被调用一次
2>. initialize是类第一次接收到消息的时候调用,当子类没有initialize方法时,就会调用父类的,所以可能会被调用多次
(2). 调用方式不同:
1>.load是通过函数地址直接调用的
2>.initialize是通过objc_msgSend调用的
(3). 调用顺序不同:
1>.load:先调用类的load方法,先编译的先调用,在调用load之前会先调用父类的load方法;然后在调用分类的load方法,也是先编译的先调用;分类的load方法不会覆盖本类的load方法
2>.initialize:先初始化父类,之后再初始化子类。如果子类没有实现+initialize,会调用父类的+initialize(所以父类的+initialize可能会被调用多次),如果分类实现了+initialize,就覆盖类本身的+initialize调用。
四、Block
知识点
-
Block
本质上也是一个OC对象,内部也有一个isa指针
,Block是封装了函数调用和函数调用环境的OC对象
-
- 为了保证
Block
内部能够正常访问外部变量,Block有个变量捕获机制,用auto修饰的局部变量是值捕获,用static修饰的局部变量是指针捕获,全局变量不会捕获(局部变量不加修饰符的话,默认是用auto修饰的),规则和示例如下所示:
- 为了保证
-
self
是调用函数时传进来的参数,也是属于局部变量,所以捕获的时候是值捕获
-
-
Block
根据存放的内存区域的不同,有三种类型:存放在全局区的叫做NSGlobalBlock-全局Block
、存放在栈区的叫做NSStackBlcok-栈Block
、存放在堆区的叫做NSMallocBlock-堆Block
,如下图所示,可以通过调用Class方法或者isa指针来查看Block的具体类型
-
-
在
MRC
下,如果Block内部没有访问用auto修饰
的变量,那么Block就是全局Block;如果Block访问了用auto修饰
的变量,那么Block就是栈Block;如果给栈Block使用了Copy
,那么就会将栈Block复制到堆上,从而变成了堆Block,如下图所示:
-
在
-
由于栈空间的内存随时都会被释放,为了保证数据安全,ARC在以下情况下,会自动将Block拷贝到堆上,当copy到堆上时,会自动调用Block内部的
copy函数
,copy函数内部会调用_Block_object_assign函数,_Block_object_assign
会根据捕获的对象的修饰符(__strong、__weak、__unsafe__unretained
)来做出相应的操作,形成强引用或者弱引用
-
由于栈空间的内存随时都会被释放,为了保证数据安全,ARC在以下情况下,会自动将Block拷贝到堆上,当copy到堆上时,会自动调用Block内部的
- 当
block
从堆中移除时,会调用block内部的dispose函数
,dispose函数内部会调用_Block_object_disposeh函数,_Block_object_disposeh函数
会自动释放捕获的变量
- 当
-
- 当Block内部访问了
auto修饰的变量
时,会根据Block的类型的不同,而出现不同的情况:
当block在栈上时,如果block内部访问了auto变量,将始终不会对auto变量产生强引用
当block在堆上时,如果内部
访问了auto变量
,将会调用block内部的copy函数,copy函数会根据auto变量的修饰符来决定对auto变量产生强引用还是弱引用(如果auto变量用__strong修饰,就会产生强引用;如果用__weak、__unsafe_unretained修饰就会产生弱引用)
- 当Block内部访问了
-
__block修饰符
可以解决block内部无法修改auto修饰的变量的值
的问题,__block修饰符
不能修饰全局变量和static静态变量,编译器会将__block变量
包装成一个对象,如下所示:
-
-
- 当Block内部访问了
__block修饰的变量
时,会根据Block的类型的不同,而出现不同的情况:
当block在栈上时,如果block内部访问了__block变量,将始终不会对__block变量产生强引用
当block在堆上时,如果内部
访问了__block变量
,将会调用block内部的copy函数,copy函数会对__block变量
形成强引用
- 当Block内部访问了
- 学会了以上知识点之后,再来思考一下经典的循环引用问题,是不是就感觉很简单了呢?我们来一起看一下,所谓循环引用就是对象持有Block,而Block也持有了self,导致双方都无法释放,从而导致内存泄漏,如下面代码所示:
self.block = ^{
NSLog(@"%@",self);
};
-
循环引用分析:在ARC环境下,
block
被赋值给了Block类型的self.block
成员变量,所以这个block是堆block
;block内部访问了self变量,我们知道self是局部变量,不加修饰符的话默认是用auto和__strong
修饰的;既然block在堆上,并且内部访问了auto修饰的变量,那么将会在block内部调用copy函数,copy函数会根据修饰符进行强/弱引用,此处使用__strong
修饰的,所以会对self
进行一次强引用,而self对block产生的也是强引用,所以产生了循环引用,如下图所示:
解决方案:解决起来也很简单,只需要将self换成
__weak修饰
的就可以了,这样在调用block内部的copy方法时,对self
产生的就是弱引用了,如以下代码所示:
__weak typeof(self) weakSelf = self;
self.block = ^{
NSLog(@"%@",weakSelf);
};
面试题
- 1. Block的本质是什么?
答: Block是封装了函数调用以及函数调用环境的OC对象
- 2. __block的作用是什么?
答:__block可以解决block内部无法修改auto修饰的变量的问题,编译器会将__block修饰的变量包装成一个对象
- 3. block的属性修饰词为什么是copy?
答: block使用copy其实是MRC留下来的一个传统,在MRC下,block创建在栈区, 使用copy就能把它放到堆区, 这样在作用域外调用该block程序就不会崩溃;而在ARC下block的属性修饰词是copy和strong都可以,ARC会在需要的时候,自动帮我们把block从栈上拷贝到堆上
- 4. block中修改NSMutableArray中的元素,需不需要添加__block?
答: 不需要,仅仅修改数组中的元素是不需要加__block的,如果是给array重新赋值新的数组,这时候才需要加__block