本文简述iOS中分类的底层实现和load方法、initialize方法在类和分类中的调用特性,还有如何通过关联对象的方式给分类添加属性,以及关联对象的底层实现原理
分类(Category)
先写一个Demo
新建一个命令行工程,在main.m中写几个类
父类:写一个run方法
1 | @interface Person : NSObject |
父类的分类:写一个eat方法
1 | @interface Person(Test) |
根据经验可知道,现在Peron对象拥有了run方法和eat方法
分类的底层结构
输入命令行
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m
接下来同级目录下就会多出一个编译后的文件main.cpp
,查看后发现Person(Test)
分类被编译成了如下变量
1 | static struct _category_t _OBJC_$_CATEGORY_Person_$_Test __attribute__ ((used, section ("__DATA,__objc_const"))) = |
这个变量的类型是static struct _category_t
,名字是_OBJC_$_CATEGORY_Person_$_Test
,等于号后面是一个初始化结构体的过程,可以看到要实例化这个结构体需要6个参数,所以查看_category_t
的结构如下
1 | struct _category_t { |
由此可知,分类可以定义实例方法,可以定义类方法,可以遵循协议,可以添加属性,但是不能添加成员变量!因为分类的结构里面没有存储成员变量的地方.
程序通过runtime动态将分类合并到类对象、元类对象中
分类中的方法是在运行时才添加到类对象和元类对象中的,而不是编译的时候添加的,编译之后只是多了几个类型为_category_t
的结构体变量。
合并的过程可以在runtime源码(objc4-818.2)中objc-runtime-new.mm
的load_categories_nolock
函数中看到,这里不展开流程,直接说结论。
合并过程是这样子的,首先我们知道原来的类(我们就叫他主类吧)对象是存放着成员方法的,主类的元类对象是放着类方法的,因为类对象和元类对象结构是一样的,所以我们就讨论成员方法就好了。
其次呢,runtime先根据分类方法的数量在数组里面开辟空间,然后把分类方法塞到原来的成员方法数组的前端,这样子合并就完成了,其他类方法、协议、属性等数组,也是同样的过程。
所以最后在类对象的方法列表数组里面,排在前面的是分类方法,后面才是主类的方法。如果有多个分类的话,那么后编译的分类的成员方法会插在数组的前面(因为插入数组的时候是倒序插入)。
有了上述结论 我们就可以解释很多事情了。
如果分类实现了主类的方法会怎么样
根据上述结论,系统在找对应的调用方法的时候,会先找到分类的方法,所以主类的方法没有机会被调用到。
如果多个分类都实现了同个主类的方法
根据上述结论,后编译的分类的成员方法会插在方法列表的前面,所以谁后编译,就调用谁
如果子类或者子类的分类实现了父类的分类方法
根据上述结论和结合我们以前所学知识,最后子类实例对象会去子类的类对象里面寻找方法并调用,使用super
关键字调用方法的话,则会去到父类的类对象内找方法。
Category跟Class Extension的区别
- Class Extension是编译的时候,它的数据就已经包含在类信息中
- Category 是在运行时才会讲数据合并到类信息中
load方法
先写一个Demo
父类:实现load方法
1 | @implementation Person |
父类的分类:实现load方法
1 | @implementation Person(Test) |
子类:实现load方法
1 | @implementation Student |
子类的分类:实现load方法
1 | @implementation Student(Test) |
load方法的调用时机
通过runtime源码(objc4-818.2)objc-runtime-new.mm
第3233行得知,load方法是在加载镜像(load_images)的时候调用的。
1 | void load_images(const char *path __unused, const struct mach_header *mh) |
补充小细节:从
load_images
函数可以看到,加载分类loadAllCategories()
早于调用load方法call_load_methods()
,也就是元类对象中的类方法列表内,分类的load方法会在主类的load方法之前
在调用load方法之前,首先要通过prepare_load_methods
函数整理出一个数组,这个数组会决定主类的load方法的调用顺序
从prepare_load_methods
函数中调用的schedule_class_load
函数的内部实现我们可以知道,父类会先被加入到数组中,其次才是主类。
1 | /*********************************************************************** |
然后我们回到最开始的地方(load_images),通过objc-loadmethod.mm
第337行call_load_methods
得知,先调用主类的load方法(call_class_loads()),再调用分类的load方法call_category_loads();
补充一个小细节:在整理数组的时候,这个数组里面存放的是一个个结构体,结构体长这样
1 | struct loadable_class { |
注意:runtime代码内有记录类的load方法是否曾经被加入到load数组过(RW_LOADED),如果被调用过了,就会跳过,这就是load方法只会执行一次的原因(但是你要是非要手动调用load方法那还是会执行的)
重要:系统调用load方法不通过消息发送机制,可以查看objc-loadmethod.mm
第177行如下
1 | /*********************************************************************** |
load方法的调用顺序
综上所述,分类也有load方法。调用顺序是:先调用父类load方法,再调用子类load方法,最后调用分类的load方法,如果有多个分类,那么先编译的,先调用。
所以上述Demo代码执行后输出结果为
1 | Person load |
这里Person比Student先调用是因为他是父类,Person(Test)比Student(Test)先调用是因为它先编译
综上所述
于是我们可以解答下面的问题
- 类和分类都有+load方法
- 根据编译顺序,先调用父类+load,再调用子类+load,再调用分类+load(看编译顺序)
- 因为系统调用load方法时不通过消息发送机制,所以不存在子类load方法覆盖父类load方法的情况,但是,如果手动调用load方法(即通过消息发送机制调用方法),那么这时候就有继承的现象发生了,也就是子类会覆盖父类方法的实现。
搞点复杂的事情
- 父类实现,父类分类实现,子类不实现,子类分类实现
顺序:父类load,子类分类load、父类分类load
原因:本来应该调用子类load的,无奈子类load没实现,但是找到了子类分类,那么就调用子类分类的load,最后在调用父类分类load(因为分类要最晚调用,由于刚才子类分类被调用过了,所以这里没它事了)
所以:不一定主类的load方法总比分类的load方法早调用,存在特殊情况 - 父类不实现,父类分类实现,子类实现,子类分类实现
顺序:父类分类load,子类load,子类分类load
原因:跟上面的理由是一样的,父类没实现但是父类分类找到了那么就调用
所以:父类的分类的load也可以比子类load方法早调用
initialize方法
先写个Demo
父类:实现initialize方法
1 | @implementation Person |
父类的分类:实现initialize方法
1 | @implementation Person(Test) |
子类:实现initialize方法
1 | @implementation Student |
子类的分类:实现initialize方法
1 | @implementation Student(Test) |
initialize方法的调用时机
+initialize
会在类第一次接受到消息的时候调用,即objc_msgSend()
被触发的时候调用,这部分是汇编实现runtime源码里面有一个
class_getInstanceMethod()
函数,用于查找方法,当找到要调用的方法之后,就会调用initialize
方法,class_getInstanceMethod()
内的主要实现为lookUpImpOrForward()
函数的调用
1 | /*********************************************************************** |
lookUpImpOrForward()
函数实现内,有一判断条件为if(slowpath(!cls->isInitialized())) { ... }
,若该类已经调用过+initialize
,那么就不会再调用,这就是+initialize
只被系统调用一次的原因经过一层层点击方法实现(流程见补充),最终我们可以在
objc-initialize.mm
内发现函数callInitialize(Class cls)
,内部实现是objc_msgSend
,如下
1 | void callInitialize(Class cls) |
补充:函数调用路径为
lookUpImpOrForward
->realizeAndInitializeIfNeeded_locked
->initializeAndLeaveLocked
->initializeAndMaybeRelock
->initializeNonMetaClass
->callInitialize
其中:initializeNonMetaClass
函数实现内有一个递归,不断地传入superclass
指针,去调用父类的initialize
方法,直到superclass
指针为空(或已经调用过initialize
)为止,这就是先调用父类的initialize
方法的原因
initialize方法的调用顺序
- 先调用父类的
initialize
方法,再调用子类的initialize
综上所述
initialize
是通过objc_msgSend
方式调用的,所以会受分类、继承等等因素影响调用- 如果子类没有实现
+initialize
,会调用父类的+initiailize
(所以父类的+initialize
可能会被调用多次) - 如果分类实现了
+initialize
,就会覆盖主类的+initialize
调用
关联对象
假如我们要给一个类添加一个属性如下:@property (copy, nonatomic) NSString *test;
存值
1 | OBJC_EXPORT void |
参数名 | 解释 |
---|---|
object | 要关联的对象,如果是在分类内给主类添加属性,那么这里就写self |
key | 要用来标记这个属性的key,取值的时候也得靠这个key取 |
value | 存储的内容 |
policy | 存储策略(见下表) |
objc_AssociationPolicy 存储策略
策略枚举 | 对应修饰符 |
---|---|
OBJC_ASSOCIATION_ASSIGN | assign |
OBJC_ASSOCIATION_RETAIN_NONATOMIC | strong, nonatomic |
OBJC_ASSOCIATION_COPY_NONATOMIC | copy,nonatomic |
OBJC_ASSOCIATION_RETAIN | strong, atomic |
OBJC_ASSOCIATION_COPY | copy,atomic |
如果要存weak类型的对象怎么办?
创建一个类,用OBJC_ASSOCIATION_RETAIN_NONATOMIC
标记存在主类中,然后在这个新建的类里面再存放一个weak引用的属性
key有多少种填写形式
- 声明全局变量
static const void *TestKey = &TestKey;
,将TestKey
的地址作为key的内容,不能直接写const void *TestKey
,因为这么写等共同于const void *TestKey = NULL
,要是以后添加别的属性,就会冲突,使用例子:
1 | objc_setAssociatedObject(self,TestKey,test,OBJC_ASSOCIATION_COPY_NONATOMIC) |
- 声明全局变量
static const char TestKey
,同样将TestKey
的地址作为key,好处是char
只占用一个字节的内存大小,使用例子:
1 | objc_setAssociatedObject(self,&TestKey,test,OBJC_ASSOCIATION_COPY_NONATOMIC) |
- 因为OC内的字符串都存在常量区,所以通过字面量创建的相同字符串都是同个地址,所以我们也可以随便定义一个字符串去做key,使用例子:
1 | objc_setAssociatedObject(self,@"Test",test,OBJC_ASSOCIATION_COPY_NONATOMIC) |
- 我们也可以通过使用getter方法的方法编号去做key,使用例子:
1 | objc_setAssociatedObject(self,@selector(test),test,OBJC_ASSOCIATION_COPY_NONATOMIC) |
取值
1 | OBJC_EXPORT id _Nullable |
- object同上,一般分类里面就填self
- key属性需要和上面设值的key属性一致
- 如果使用getter方法的方法编号去做key,有两种写法
objc_getAssociatedObject(self, @selector(test))
objc_getAssociatedObject(self, _cmd)
,_cmd
表示当前方法的方法地址
底层实现
- runtime维护着一个名为
AssociationsManager
的类 - 类里面存放着一个字典,键是传入的对象(object参数),值是
ObjectAssociationMap
类的实例对象 ObjectAssociationMap
是一个字典,键是传入的key参数,值是ObjcAssociation
类的实例对象ObjcAssociation
有两个成员变量,_policy
存放着传入的policy参数,_value
存放着传入的value参数
综上所述
- 关联对象并不是存储在被关联对象的内存中
- 关联对象存储在全局统一的
AssociationsManager
中 - 设置关联对象(object参数)为nil,就相当于是移除关联对象