OC底层原理(05)类的底层探究(下)

一. WWDC 2020 类优化

  首先,我们先来看看苹果2020年WWDC视频,视频中苹果开发者主要对底层数据结构做了如下的三个修改。

在磁盘上,在你的APP二进制文件中类是这样的,如下所示:

Xnip2022-07-04_22-26-30.png

这个类对象本身,它包含了最常被访问的信息,就是 指向元类、超类以及方法缓存的指针,除此之外,还有一个指向更多数据的指针,存储额外信息的地方叫做class_ro_tro代表只读,这结构中,包含类名、方法、协议和实例变量 的信息,如下图所示:

Xnip2022-07-04_22-28-16.png

当类第一次从磁盘加载到内存中时,它们一开始也是这样的,但是一经使用,它们就会发生变化,但是在了解这些变化之前,我们有必要了解一下什么是clean memory以及dirty memory的区别:

  • clean memory:指加载后不会发生更改的内存。
  • dirty memory:指在进程运行时会发生更改的内存。

class_ro_t就属于clean memory,因为它是只读的。类结构一经使用就会变成dirty memory,因为运行时会向它写入新的数据(例如:创建一个新的方法缓存并从类中指向它)。dirty memoryclean memory要昂贵得多,只要进程在运行,它就必须一直存在,另一方面clean memory可以进行移除,从而节省更多的内存空间。因为如果你需要clean memory,系统可以从磁盘中重新加载。macOS可以选择换出dirty memory,但因为iOS不使用swap,所以dirty memoryiOS中代价很大。dirty memory是这个类数据被分成两部分的原因,可以保持清洁的数据越多越好,通过分离出那些永远不会更改的数据,可以把大部分的类数据存储为clean memory,虽然这些数据足以让我们开始,但运行时需要追踪每个类的更多信息。所有,当一个类首次被使用,运行时会为它分配额外的存储容量,这个运行时分配的存储容量是class_rw_t用于读取-编写数据,如下图所示:

Xnip2022-07-04_22-35-36.png

在这个数据结构中,我们存储了只有在运行时才会生成的新信息(First SubclassNext Sibing Class),例如:所有的类都会链接成一个树状结构。这是通过使用First SubclassNext Sibing Class指针实现的。这允许运行时遍历当前使用的所有类,这对于使方法缓存非常有用。但为什么方法和属性既在class_ro_t(只读数据),又在class_rw_t(读写数据)中呢?因为 class_rw_t 可以在运行时进行更改,当 category 被加载时,它可以向类中添加新的方法,而且程序员可以使用 runtime API动态地添加它们。因为class_ro_t是只读的,所以我们需要在class_rw_t中追踪这些东西。   但是以上做法导致的结果是会占用相当多的内存,在任何给定的设备中都有许多类在使用,那么我们如何缩小这些结构呢?记住,我们在class_rw_t中需要这些东西,因为这些东西可以在运行时进行更改。但是通过检查实际设备上的使用情况,我们发现大约只有%10的类真正地更改了它们的方法,而且只有Swift类会使用demangled name字段,并且Swift类并不需要这一字段,除非有东西询问它们的Objective-C名称时才需要,所有我们可以拆掉那些平时不用的部分,这将class_rw_t的大小减少了一半,如下图所示:

Xnip2022-07-04_22-42-32.png

对于那些确实需要额外信息的类,我们可以分配这些扩展记录中的一个,并把它滑到类中供其使用,如下图所示:

Xnip2022-07-04_22-43-43.png

大约 90% 的类从来不需要这些扩展数据,在系统范围内可节省大约14MB的内存,这些内存可以用于更有效的用途,比如存储你的App数据。这对于dirty memory来说,这是真正的节省内存,现在,很多从类中获取数据的代码,必须同时处理那些有扩展数据和没有扩展数据的类,当然,运行时会为你处理这一切,并且从外部看,一切都像往常一样工作,只是使用更少的内存,之所以会这样,是因为读取这些结构的代码,都在运行时内并且还会同时进行更新,坚持使用这些API 真的很重要,因为任何视图直接访问这些数据结构的代码都将在今年的OS版本中停止工作,如下图所示:

Xnip2022-07-04_22-46-01.png

因为数据结构已经发生了变化,而且该代码不知道新的布局,除了你自己的代码,也要注意那些依赖的外部代码,你可能正把它们带入你的 app 中,它们可能会在你没有意识到的情况下,访问这些数据结构。这些结构中的所有信息,都可以通过官方API获取,一些API如下图所示:

Xnip2022-07-04_22-48-19.png

当你使用这些API访问信息时,无论Apple在后台进行什么更改,这些APIS都将继续工作,所有的API都可以在Objective-C运行时说明文档中找到,而这个文档在developer.apple.com中。

总结

class_rw_t优化,其实就是对class_rw_t不常用的部分进行了剥离。如果需要用到这部分就从扩展记录中分配一个,滑到类中供其使用。现在大家对类应该有个更清楚的认识。

二. 类方法的探索方式

// YJPerson.h
@interface YJPerson : NSObject 
- (void)sayHello;
+ (void)eat;
@end

// YJPerson.m
@implementation YJPerson 
- (void)sayHello {}
+ (void)eat {}
@end

2.1 lldb 读取类方法

通过 类的底层探究上 我们知道 对象方法也就是实例方法是存放在类中的,那么 相对于 元类 来说就是 类对象,由此猜想类对象的方法是不是在 元类中呢?下面我们来通过实例探索:

Xnip2022-07-05_13-40-36.png

2.1 runtime API读取类的方法列表

/// 输出类的方法列表
/// @param cls 类
void yj_copyMehtodList(Class cls)
{
    unsigned int count = 0;
    Method *methods = class_copyMethodList(cls, &count);
    for (unsigned int i=0; i < count; i++) {
        Method const method = methods[i];
        // 获取方法名
        NSString *key = NSStringFromSelector(method_getName(method));
        YJLog(@"Method, name: %@", key);
    }
    free(methods);
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        YJPerson *person = [YJPerson alloc];
        YJLog(@"类的方法列表:")
        // 类
        Class pCls = object_getClass(person);
        // 获取类的 方法列表
        yj_copyMehtodList(pCls);
  
        YJLog(@"\n元类的方法列表:")
        // 元类
        const char *className = class_getName(pCls);
        Class metaCls = objc_getMetaClass(className);
        // 获取元类的 方法列表
        yj_copyMehtodList(metaCls);
    }
    return 0;
}

输出:

Xnip2022-07-05_14-34-19.png

类中的方法是 sayHello 方法,元类中的方法是 eat 方法,因此 类方法是在 元类 中的

三. 编码

3.1 官方编码说明

地址链接

Xnip2022-07-05_14-49-35.png

3.2 通过代码获取编码信息

Xnip2022-07-05_14-56-44.png

@encode() 获取一个给定类型的编码字符串

四. setter方法底层实现

OC对象本质与 isa 1.2.4 中发现属性setter方法有的是通过objc_setProperty实现的,有的是直接内存偏移获取变量地址,然后赋值。这是为什么呢?下面我们通过实例来探索

main.m 中创建 YYJPerson 代码为:

// main.m
@interface YYJPerson : NSObject
{
    NSString *yj_name;
    NSObject *yj_objc;
}
@property (nonatomic, copy) NSString *c_name;
@property (nonatomic, strong) NSString *s_name;
@property (nonatomic, assign) int age;
@end
@implementation YYJPerson
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
    }
    return 0;
}

使用clang 命令:clang -rewrite-objc main.m -o main.cpp生成 main.cpp 文件:

Xnip2022-07-05_16-07-42.png

我们发现,虽然这两个属性 c_names_name 都是 NSString 类型的,但是编译后的源码,c_name 属性的 setter 方法中使用的是objc_setProperty 函数赋值,而s_name属性的setter方法中使用的确实指针偏移赋值,这是为什么呢?

首先,我们先来探究一下为什么会需要 objc_setProperty 这个函数,因为在你编写类的代码时,底层并不知道你会定义什么样的属性以及方法,但是属性的settergetter 方法又要调用底层的接口,这就形成了一个矛盾,那该如何解决这个问题呢?由于所有属性的setter方法只有第二个参数_cmd是不一样的,因此,就出现了objc_setProperty函数,用来作为中间层,替换了属性setter方法的imp实现,这样就保证了底层代码不会因为属性的增多而增加多余的代码,如下图所示:

Xnip2022-07-05_16-18-46.png

接着,我们还需要探究的是objc_setProperty()调用流程,但是现在就出现了一个问题,就是我们是去那里找objc_setProperty()这个函数的调用流程呢,如果你去objc的源码中查找,就会发现objc的源码中有objc_setProperty()函数的声明以及实现,但问题是,这里是我们自定义的类中的属性的setter方法里面调用的objc_setProperty(),这肯定不是objc源码调用的,而是llvm在编译的时候调用的,但是为什么要在编译时刻如此修改呢?为什么不在运行时修改呢?因为要知道,在一个项目工程中,我们自定义的类是很多的,类中的属性也是很多的,如果我们全部交给运行时处理,效率就会很低,因此放在编译时修改属性的setter方法的imp是最明智的选择。现在使用VS Code打开llvm源码,搜索objc_setProperty,我们找到了如下的代码:

Xnip2022-07-05_16-36-16.png

全局搜索发现 其实是调用了 getSetPropertyFn() 这个函数,根据这个函数的名字,可以判断这个函数是用来获取 setProperty 这个函数的,因此我们需要知道在哪里调用了 getSetPropertyFn()这个函数,然后我们全局搜索getSetPropertyFn(),发现了以下的代码:

Xnip2022-07-05_16-39-55.png

全局搜索发现 其实是 GetPropertySetFunction函数调用了GetSetPropertyFn 这个函数,接着我们全局搜索GetPropertySetFunction(),看看GetPropertySetFunction在哪里调用了,然后发现了如下代码:

Xnip2022-07-05_16-49-00.png

Xnip2022-07-05_16-57-53.png

GetPropertySetFunction 函数的实际上调用的是generateObjCSetterBody函数,在这个函数中对strategy.getKind()函数调用后的枚举值进行比较,当枚举值为GetSetPropertySetPropertyAndExpressionGet并且UseOptimizedSetter(CGM)这个函数调用结果不为真的时候将会调用GetPropertySetFunction函数获取到setProperty这个函数,并在下面的函数中进行调用,因此我们需要知道strategygetKind函数是如何实现的,其代码如下:

Xnip2022-07-05_17-08-27.png

getKind()函数实际上是获取了类PropertyImplStrategy中的成员变量Kind的值,因此我们再来看看成员变量Kind是在何时进行赋值的,最后我们在PropertyImplStrategy的构造方法中找到了其成员变量Kind的赋值,代码如下所示:

Xnip2022-07-05_17-15-10.png

分析上图代码,我们就可以明白,当设置属性为Copy时,就会给Kind赋值为GetSetProperty,这就是objc_setProperty()函数底层的调用流程。 尽管底层是如此实现的,但是我们还是想做一个验证,编写代码如下:

// main.m 
@interface YYJPerson : NSObject
@property (nonatomic, copy) NSString *n_c_name;
@property (atomic, strong) NSString *a_c_name; 
@property (nonatomic) NSString *n_name; 
@property (atomic) NSString *_name; 
@end 

@implementation YYJPerson
@end

再次生成 main.cpp

Xnip2022-07-05_17-27-07.png

可以发现,只有使用 copy 修饰的属性才会在其setter方法中调用objc_setProperty函数。

五. 面试题

通过一个经典面试题来探究下isKindOfClassisMemberOfClass

// iskindOfClass & isMemberOfClass 类方法调用
BOOL re1 = [(id)[NSObject class] isKindOfClass:[NSObject class]];
BOOL re2 = [(id)[YJPerson class] isKindOfClass:[YJPerson class]];
BOOL re3 = [(id)[NSObject class] isMemberOfClass:[NSObject class]];
BOOL re4 = [(id)[YJPerson class] isMemberOfClass:[YJPerson class]];
NSLog(@"\n re1 :%hhd\n re2 :%hhd\n re3 :%hhd\n re4 :%hhd\n", re1, re2, re3, re4);

// iskindOfClass & isMemberOfClass 实例方法调用
BOOL re5 = [(id)[NSObject alloc] isKindOfClass:[NSObject class]];
BOOL re6 = [(id)[YJPerson alloc] isKindOfClass:[YJPerson class]];
BOOL re7 = [(id)[NSObject alloc] isMemberOfClass:[NSObject class]];
BOOL re8 = [(id)[YJPerson alloc] isMemberOfClass:[YJPerson class]];
NSLog(@"\n re5 :%hhd\n re6 :%hhd\n re7 :%hhd\n re8 :%hhd\n",re5,re6,re7,re8);

输出结果:

Xnip2022-07-06_09-21-37.png

5.1 类方法 isKindOfClass 探索

我们来探索底层实现,首先在这儿打断点 Xnip2022-07-06_09-23-11.png

开启 Always Show Disassembly Xnip2022-07-06_09-24-26.png

运行项目: Xnip2022-07-06_09-32-18.png

通过断点调试,我们发现 isKindOfClass 底层调用的是 objc_opt_isKindOfClass。既然知道了调用的哪个方法,我们直接找到它:

BOOL objc_opt_isKindOfClass(id obj, Class otherClass)
{
#if __OBJC2__
    // obj为空的是小概率事件基本不会发生
    if (slowpath(!obj)) return NO;

    // 获取 obj 的 isa
    // 如果 obj 是实例对象,则 cls 就是类
    // 如果 obj 是类,则 cls 就是元类
    Class cls = obj->getIsa();
    
    // fastpath(!cls->hasCustomCore()) (类或者父类中大概率没有默认的isKindOfClass方法
    if (fastpath(!cls->hasCustomCore())) {
        // 先用 isa 返回的 cls 去比较,相等 return yes
        // 不相等 再循环获取 cls 的 superClass 去比较
        for (Class tcls = cls; tcls; tcls = tcls->getSuperclass()) {
            if (tcls == otherClass) return YES;
        }
        return NO;
    }
#endif
// 非OBJC2版本直接走消息转发
    return ((BOOL(*)(id, SEL, Class))objc_msgSend)(obj, @selector(isKindOfClass:), otherClass);
}

现在用的基本都是 OBJC2 版本。obj->getIsa() 获取 或者元类obj对象就获取obj就获取元类 然后就接着for循环,先用 getIisa 返回的 cls 去比较,相等就 return yes;不相等循环 获取 clssuperClass 去比较

objc_opt_class 底层实现:

Class objc_opt_class(id obj)
{
#if __OBJC2__
    // obj为空的是小概率事件基本不会发生
    if (slowpath(!obj)) return nil;
    Class cls = obj->getIsa();
    // 类或者父类中大概率没有默认的class方法
    if (fastpath(!cls->hasCustomCore())) {
        // 如果 cls 是元类就返回 obj,非元类就返回 cls
        return cls->isMetaClass() ? obj : cls;
    }
#endif
    return ((Class(*)(id, SEL))objc_msgSend)(obj, @selector(class));
}

objc_opt_class 其实就是获取 ,如果参数是 对象 则返回 ,如果是 就返回

5.2 对象方法 isKindOfClass 探索

断点发现,对象方法 isKindOfClass 在底层也是调用的 objc_opt_isKindOfClass

Xnip2022-07-06_10-28-40.png 这里就不重复分析了,参考 5.1

5.3 类方法 isMemberOfClass 探索

打断点: Xnip2022-07-06_10-17-59.png

运行项目: Xnip2022-07-06_10-21-05.png

发现这里是直接调用了 isMemberOfClass,找到这个方法实现:

+ (BOOL)isMemberOfClass:(Class)cls {
    // 获取自己的 isa,这里是类方法,isa 返回的肯定是元类
    // 用自己的元类 和 cls 比较
    return self->ISA() == cls;
}

直接获取元类 vs cls(需要比较的类),相同就返回YES,否则返回NO

5.4 对象方法 isMemberOfClass 探索

断点发现对象方法也是直接调用了 isMemberOfClassXnip2022-07-06_10-34-09.png

找到这个方法的实现:

- (BOOL)isMemberOfClass:(Class)cls {
    return [self class] == cls;
}

 vs cls(需要比较的类),相同就返回YES,否则返回NO

总结:

  • + isKindOfClass 流程。 类的 元类 vs cls(需要比较的类),不同继续比较 。元类的父类 vs cls,不同继续比较直到找到 根元类根元类 vs cls,不同继续比较。根类(NSObject) vs cls,如果还不相同则 根类(NSObject)的父类为 nil,跳出循环返回NO

  • - isKindOfClass 流程。获取当前对象所属类, vs cls,不同继续比较 。类的父类 vs cls,不同继续比较直到找到根类(NSObject)根类(NSObject) vs cls,如果还不相同则根类(NSObject)的父类为nil,跳出循环返回NO

  • + isMemberOfClass 流程。 类的元类 vs cls(需要比较的类),相同就返回YES,否则返回NO

  • - isMemberOfClass 流程。  vs cls(需要比较的类),相同就返回YES,否则返回NO

猜你喜欢

转载自juejin.im/post/7117084846668644382