iOS Category的本质(一)

2020-01-10  本文已影响0人  木子雨廷t

Category在平时的工作中也是经常用到,是开发中不可缺少的一个利器,简单介绍。

优点:

不需要通过增加子类而增加现有类的行为(方法),且分类中的方法与原始类方法基本没有区别;在日常开发中通过分类可以将庞大一个类的方法按照功能模块进行划分,从而便于代码的日后的维护、更新以及提高代码的阅读性

缺点:

1.无法向类目添加实例变量,如果需要添加实例变量,只能通过定义子类的方式。
2.类目中的方法与原始类以及父类方法相比具有更高优先级,如果覆盖父类的方法,可能导致super消息的断裂。因此,最好不要覆盖原始类中的方法。

底层探索:

写一段代码,新建Presen类 然后为Presen增加两个分类,分别是Presen+Text,Preson+Eat,代码如下

Presen类 
// Presen.h
#import <Foundation/Foundation.h>
@interface Preson : NSObject
{
    int _age;
}
- (void)text;
@end

// Presen.m
#import "Preson.h"
@implementation Preson
- (void) text
{
    NSLog(@"Person - text");
}
@end

Presen扩展1
// Presen+Test.h
#import "Preson.h"
@interface Preson (Test) <NSCopying>
- (void)test;
+ (void)test2;
@end

// Presen+Test.m
#import "Preson+Test.h"
@implementation Preson (Test)
- (void)test
{
}
+ (void)test2
{
}
@end

Presen分类2
// Preson+Eat.h
#import "Preson.h"
@interface Preson (Eat)
@end

// Preson+Eat.m
#import "Preson+Eat.h"
@implementation Preson (Test2)
- (void)text
{
    NSLog(@"Person (Test2) - text");
}
@end

iOS OC对象的本质窥探讲到过实例对象的isa指针指向类对象,类对象的isa指针指向元类对象,当p调用text方法时,通过实例对象的isa指针找到类对象,然后在类对象中查找对象方法,如果没有找到,就通过类对象的superclass指针找到父类对象,接着去寻找text方法。

那么当调用分类的方法时,步骤是否和调用对象方法一样呢?
分类中的对象方法依然是存储在类对象中的,同本类对象方法在同一个地方,调用步骤也同调用对象方法一样。如果是类方法的话,也同样是存储在元类对象中。
那么分类方法是如何存储在类对象中的? 如果一个类含有多个分类那么存储顺序是怎么样的呢?我们来通过源码看一下分类的底层结构。
源码下载地址

分类的底层结构

通过查看分类的源码我们可以找到category_t 结构体。

struct category_t {
    const char *name;
    classref_t cls;
    struct method_list_t *instanceMethods; // 对象方法
    struct method_list_t *classMethods; // 类方法
    struct protocol_list_t *protocols; // 协议
    struct property_list_t *instanceProperties; // 属性
    // Fields below this point are not always present on disk.
    struct property_list_t *_classProperties;

    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }

    property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
};

从源码基本可以看出我们平时使用categroy的方式,对象方法,类方法,协议,和属性都可以找到对应的存储方式。并且我们发现分类结构体中是不存在成员变量的,因此分类中是不允许添加成员变量的。分类中添加的属性并不会帮助我们自动生成成员变量,只会生成get set方法的声明,需要我们自己去实现。

通过源码我们发现,分类的方法,协议,属性等好像确实是存放在categroy结构体里面的,那么他又是如何存储在类对象中的呢?
我们来看一下底层的内部方法探寻其中的原理。
首先我们通过命令行将Preson+Test.m文件转化为c++文件,查看其中的编译过程。

Preson+Test转化成c++文件结构体.png
在分类转化为c++文件中可以看出_category_t结构体中,存放着类名,对象方法列表,类方法列表,协议列表,以及属性列表。

往下寻找,我们可以看到_method_list_t类型的结构体,如下图所示

对象方法列表结构体.png
上图中我们发现这个结构体_OBJC_$_CATEGORY_INSTANCE_METHODS_Preson_$_Test从名称可以看出是INSTANCE_METHODS对象方法,并且一一对应为上面结构体内赋值。我们可以看到结构体中存储了方法占用的内存,方法数量,以及方法列表。并且从上图中找到分类中我们实现对应的对象方法,test个方法

接下来我们发现同样的_method_list_t类型的类方法结构体,如下图所示

类方法列表
同上面对象方法列表一样,这个我们可以看出是类方法列表结构体 _OBJC_$_CATEGORY_CLASS_METHODS_Preson_$_Test,同对象方法结构体相同,同样可以看到我们实现的类方法,abc。
往下寻找看到定义了_OBJC_$_CATEGORY_Preson_$_Test结构体,并且将我们上面着重分析的结构体一一赋值,我们通过两张图片对照一下。
Preson+Test转化成c++文件结构体.png
企业微信截图_80cfebb9-8d90-4f00-b818-70ccbbf534ed.png

上下两张图一一对应,并且我们看到定义_class_t类型的OBJC_CLASS_$_Preson结构体,最后将_OBJC_$_CATEGORY_Preson_$_Test的cls指针指向OBJC_CLASS_$_Preson结构体地址。我们这里可以看出,cls指针指向的应该是分类的主类类对象的地址。

通过以上分析我们发现。分类源码中确实是将我们定义的对象方法,类方法,属性等都存放在catagory_t结构体中。接下来我们在回到runtime源码查看catagory_t存储的方法,属性,协议等是如何存储在类对象中的。

首先来到runtime初始化函数

runtime初始化函数.png
接着我们来到 &map_images读取模块(images这里代表模块),来到map_images_nolock函数中找到_read_images函数,在_read_images函数中我们找到分类相关代码 Discover categories代码.png

从上述代码中我们可以知道这段代码是用来查找有没有分类的。通过_getObjc2CategoryList函数获取到分类列表之后,进行遍历,获取其中的方法,协议,属性等。可以看到最终都调用了remethodizeClass(cls);函数。我们来到remethodizeClass(cls);函数内部查看。

remethodizeClass函数内部.png
通过上述代码我们发现attachCategories函数接收了类对象cls和分类数组cats,如我们一开始写的代码所示,一个类可以有多个分类。之前我们说到分类信息存储在category_t结构体中,那么多个分类则保存在category_list中。

来到attachCategories函数内部。

attachCategories函数内部实现.png
上述源码中可以看出,首先根据方法列表,属性列表,协议列表,malloc分配内存,根据多少个分类以及每一块方法需要多少内存来分配相应的内存地址。之后从分类数组里面往三个数组里面存放分类数组里面存放的分类方法,属性以及协议放入对应mlistproplistsprotolosts数组中,这三个数组放着所有分类的方法,属性和协议。
之后通过类对象的data()方法,拿到类对象的class_rw_t结构体rw,在class结构中我们介绍过,class_rw_t中存放着类对象的方法,属性和协议等数据,rw结构体通过类对象的data方法获取,所以rw里面存放这类对象里面的数据。
之后分别通过rw调用方法列表、属性列表、协议列表的attachList函数,将所有的分类的方法、属性、协议列表数组传进去,我们大致可以猜想到在attachList方法内部将分类和本类相应的对象方法,属性,和协议进行了合并。

下面来看一下attachLists函数内部。

attachLists函数内部.png
上述源代码中有两个重要的数组
array()->lists: 类对象原来的方法列表,属性列表,协议列表。
addedLists:传入所有分类的方法列表,属性列表,协议列表。

attachLists函数中最重要的两个方法为memmove内存移动和memcpy内存拷贝。我们先来分别看一下这两个函数

// memmove :内存移动。
/*  __dst : 移动内存的目的地
*   __src : 被移动的内存首地址
*   __len : 被移动的内存长度
*   将__src的内存移动__len块内存到__dst中
*/
void    *memmove(void *__dst, const void *__src, size_t __len);

// memcpy :内存拷贝。
/*  __dst : 拷贝内存的拷贝目的地
*   __src : 被拷贝的内存首地址
*   __n : 被移动的内存长度
*   将__src的内存移动__n块内存到__dst中
*/
void    *memcpy(void *__dst, const void *__src, size_t __n);
下面解释下上面重点代码的含义
memmove: 将array()->lists的内存 移动oldCount * sizeof(array()->lists[0]) 个内存 到 lists + addedCount。
memcpy: 将addedLists的内存 复制addedCount * sizeof(array()->lists[0]) 个内存 到 array()->lists。

下面通过图示分析下经过memmove和memcpy方法过后的内存变化。

经过memmove和memcpy方法之前

array()->lists (原来的方法,属性,协议列表)


array()->lists内存结构.png

addedLists (传入的分类方法,属性,协议列表)


addedLists内存结构.jpg
经过memmove方法之后,内存变化为
// array()->lists 原来方法、属性、协议列表数组
// addedCount 分类数组长度
// oldCount * sizeof(array()->lists[0]) 原来数组占据的空间
memmove(array()->lists + addedCount, array()->lists, 
                  oldCount * sizeof(array()->lists[0]));

array()->lists (原来的方法,属性,协议列表)


array()->lists 经过memmove之后.png

addedLists (传入的分类方法,属性,协议列表)未变化
经过memmove方法之后,我们发现,虽然本类的方法,属性,协议列表会分别后移,但是本类的对应数组的指针依然指向原始位置。
memcpy方法之后,内存变化

// array()->lists 原来方法、属性、协议列表数组
// addedLists 分类方法、属性、协议列表数组
// addedCount * sizeof(array()->lists[0]) 原来数组占据的空间
memcpy(array()->lists, addedLists, 
               addedCount * sizeof(array()->lists[0]));

最终结果


最终效果图.png

我们发现原来指针并没有改变,至始至终指向开头的位置。并且经过memmove和memcpy方法之后,分类的方法,属性,协议列表被放在了类对象中原本存储的方法,属性,协议列表前面。

那么为什么要将分类方法的列表追加到本来的对象方法前面呢,这样做的目的是为了保证分类方法优先调用,我们知道当分类重写本类的方法时,会覆盖本类的方法。
其实经过上面的分析我们知道本质上并不是覆盖,而是优先调用。本类的方法依然在内存中的。我们可以通过打印所有类的所有方法名来查看

- (void)printMethodNamesOfClass:(Class)cls
{
    unsigned int count;
    // 获得方法数组
    Method *methodList = class_copyMethodList(cls, &count);
    // 存储方法名
    NSMutableString *methodNames = [NSMutableString string];
    // 遍历所有的方法
    for (int i = 0; i < count; i++) {
        // 获得方法
        Method method = methodList[i];
        // 获得方法名
        NSString *methodName = NSStringFromSelector(method_getName(method));
        // 拼接方法名
        [methodNames appendString:methodName];
        [methodNames appendString:@", "];
    }
    // 释放
    free(methodList);
    // 打印方法名
    NSLog(@"%@ - %@", cls, methodNames);
}
- (void)viewDidLoad {
    [super viewDidLoad];    
    Preson *p = [[Preson alloc] init];
    [p run];
    [self printMethodNamesOfClass:[Preson class]];
}

通过下图中打印内容可以发现,调用的是Test2中的text方法,并且Person类中存储着两个text方法。

关于源码的读取顺序

objc-os.mm
_objc_init
load_images
prepare_load_methods
schedule_class_load
add_class_to_loadable_list
add_category_to_loadable_list
call_load_methods
call_class_loads
call_category_loads
(*load_method)(cls, SEL_load)

总结:

1.Category的实现原理,以及Category为什么只能加方法不能加属性?

分类的实现原理是将category中的方法,属性,协议数据放在category_t结构体中,然后将结构体内的方法列表拷贝到类对象的方法列表中。
Category可以添加属性,但是并不会自动生成成员变量及set/get方法。因为category_t结构体中并不存在成员变量。通过之前对对象的分析我们知道成员变量是存放在实例对象中的,并且编译的那一刻就已经决定好了。而分类是在运行时才去加载的。那么我们就无法再程序运行时将分类的成员变量中添加到实例对象的结构体中。因此分类中不可以添加成员变量。
如果Category中的方法和类中的方法重复,将调用Category中的方法,因为在将结构体内的方法列表拷贝到类对象的方法列表中的时候放在了类数据前面。

更多iOS 开发相关知识

我的简书主页

上一篇下一篇

猜你喜欢

热点阅读