objc runtime梳理(三):总结

0

前面几篇都是在阅读源码过程中记下的笔记,过于琐碎,这里梳理总结一下。

1. 对象结构

img

首先还是回到这张经典的图,非常好地展现了object -> class -> metaclass这三者之间的关系。

why metaclass

考虑一个面向对象系统的实现,objectclass的出现都很好理解,面向对象的概念里本来就有这样的东西,object是实例,class是类本身,每个类对应一个class,从一个class可以创建很多的object

metaclass在这里就比较让人费解了。

metaclass的直接作用很明确,就是用来存放类方法。其直接带来的好处也比较明确,让classobject的方法调用流程保持基本一致。

我们回顾一下方法调用的大体流程,一个object调用一个方法,是去它的“类”即class里去找,一个class调用一个方法,语法上来讲就是调用它的类方法,也是去它的“类”即metaclass里找。显然,metaclass的存在让方法调用流程大大简化了,在调用流程里,可以不关注它的调用方是个object还是class,顺着isa去它的“类”里面找方法就可以了。

缺点也很明显,本来面向对象系统只要objectclass两层结构,把类方法放到class里面,调用的时候做个if-else的判断,虽然调用逻辑麻烦了点,但是结构简单啊。而加上metaclass就凭空多出一层,结构变得复杂了。仅从这个层面考虑,最多算对半开吧。

那么,是否还有更多的理由支持metaclass的设计?

回头看objc_objectobjc_class的关系,object_class是继承自objc_object的。类也是一种特殊的对象,这个做法是可以理解的。一方面符合面向对象系统里一切皆对象的思路,另一方面也方便以后扩展一些通用能力。这引出了一个问题,object是从某个class创建出来的,它的isa指向对应的class。既然objc_class继承自objc_object,那么,objc_classisa应该指向谁?当然,留空也是可以的,不过这样就不优雅了。于是自然引出了metaclass这个玩意儿。而metaclassisa可以指向相对抽象的root meta classroot meta classisa指向自己。metaclasssuperclassclass保持一致,指向classsuperclassmetaclass。而root meta classsuperclass可以指向root class。由此,达成了面向对象结构上的完整闭环。

复杂的isa

再来看对象的具体结构。object有唯一的变量isa,主要作用是指向它对应的classisa这个结构经历了很多次演化,早年它直接是个objc_class的指针,现在它是个union类型,可以按Classbits或一个特定struct的方式来读。

主要原因是在64位系统下,内存虚拟地址并不需要64位那么多(ARM64下iOS的指针有效长度为33,x86_64下iOS/Mac的指针有效长度为47),因此剩下的位可以多放点信息。其中最重要的是引用计数信息。在此之前,对象的引用计数是有个全局的表来存的,修改时需要对整个表加锁;在64位下引用计数放在了isa里,大大提高了引用计数的效率。

对ISA的形式,有几个概念。新版本的这种,isa其中一部分才是指向class的指针,称为NONPOINTER_ISA,即非指针形式的ISA,跟32位下简单指针形式的ISA对应。更细分一点,在x86_64arm64下,isa虽然内存布局不同,但结构一致,这种称为PACKED_ISA;而在__ARM_ARCH_7K__下,结构不同,没有直接存class的指针,而是在indexcls字段放了class在全局的索引,这种称为INDEXED_ISA,目前只用于apple watch。

Tagged Pointer

另一个类似的概念是Tagged Pointer,也是在64位下进行内存优化的手段。NONPOINTER_ISA是对class指针进行优化,而Tagged Pointer则是对object的指针进行优化。

主要是针对内容比较少的NSNumber、NSDate、NSString等数据量很小的类,由于它们本身内容很少,往往不足4字节,在64位系统下,object的指针+isa+对象实际值的存储,24个字节就被吃掉了,内存和性能的代价都有点大。对这样的类,runtime直接把数据放到object的指针里,再加几个标记位记录类型等信息。上层逻辑看起来是传了个指针,实际上是个类似struct的64位数据结构。省掉了构造对象的逻辑和空间。64位中有几位要来标记对象类型(如NSNumber)与内容类型(如long),剩余56位存放数据。

需要注意的是,对于NSString,还会通过一些压缩编码的方式尽量使用Tagged Pointer特性,实在放不下才会变成正常的对象。

参考iOS Tagged Pointer

对象结构图谱

图片描述

一些字段的具体解释和参考

  • class_rw_t

    • firstSubclass/nextSiblingClass:可以找到首个子类和下一个兄弟类,可以像链表一样从父类遍历所有的子类
    • demangledName:swift的class和protocol的名称会在编译期加上一个前缀用于区分,这个动作称为mangle(重整?),demangledName即不做mangle时的name(如果有的话)。
    • RW_COPIED_RO:默认地,rw->ro = ro,指针拷贝,不可修改。如果要对ro的内容做修改,则必须是memory copy这样的深拷贝。
    • RW_CONSTRUCTING:objc_allocateClassPairobjc_registerClassPair之间的时候就是CONSTRUCTING
    • RW_HAS_INSTANCE_SPECIFIC_LAYOUT:看起来是给Mac上短暂出现的gc能力用的,会在修改ivarLayout后打上这个标记。
  • class_ro_t

2. 一些特性的运行时实现

category和extension

我们知道通过category可以给类添加实例方法、类方法、协议、属性。常见的使用场景是把类的实现拆分到不同的文件以及为系统的类添加功能。

extension在代码层面是个匿名内部category,不过它可以添加成员变量。但底层实现上这两者是不同的,extension只存在于编译期,编译后它就成为了class的一部分,不再单独区分。而category在运行时仍是个独立结构,runtime在加载类时把category中的方法、属性等写入class中,且保存了category。

细节参考深入理解Objective-C:Category

protocols

接口/协议...多继承是个很久远的话题了,禁止多继承而使用protocol来提供类似的能力,是个不错的解决方案了。当然现在一些新的语言会倾向于prototype based的解决方案...扯远了。

protocol这里好像没什么可说的,class_ro_t和class_rw_t里都有它的list,是个运行时的东西。

associate objects

这个手段往往用来在category中模拟给实例添加成员变量。

我们知道实际的成员变量在运行时是不可变的,因此只能另辟蹊径,associate objects就是一种替代手段,在运行时它实际上是存在一个全局的表里的。

其实是做了一个object - key - value的关联。key就有点像成员变量。

引用计数

如果是个tagged pointer,那就不需要引用计数了,实际上是值传递。

如果有nonpointer isa,引用计数通常存在isa的extra_tc字段;

如果extra_tc字段存不下,或者是raw isa,则存在一个sidetable里面,那是个hash表。

从源码可以看到,retain/release的时候,会用到sidetable的锁,一个有意思的话题是,gc在回收内存时导致stop-the-world而饱受诟病,不过如果平摊下来,gc的代价可能比引用计数更低。

block

block说起来还蛮复杂的。

首先我们从block的使用上来想一下,它的实现应该是什么样子。从block的调用方式来看,很容易联想到c的函数指针,由于oc立足于c/c++,可以相信block底层应该是个函数;其次,为了做内存管理等行为,在这个函数之外可能会有个封装结构。

下面看一下它的具体实现,由于block的很大一部分实现是跟编译时相关的,因此研究block经常从clang rewrite入手。

一个简单的例子

#import <Foundation/Foundation.h>
int main(int argc, char * argv[]) {
    int a = 1;
    void (^blk)(void) = ^{
        printf("%d\n", a);
    };
    a = 2;
    blk();
    return 0;
}

使用clang -rewrite-objc main.m,可以看到会生成一个main.cpp文件,里面代码非常多,不过我们只关注和我们直接相关的部分。

如下:

struct __block_impl {
  void *isa;
  int Flags;
  int Reserved;
  void *FuncPtr;
};
struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int a;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _a, int flags=0) : a(_a) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  int a = __cself->a; // bound by copy
        printf("%d\n", a);
    }

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};
int main(int argc, char * argv[]) {
    int a = 1;
    void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, a));
    a = 2;
    ((void (*)(__block_impl *))((__block_impl *)blk)->FuncPtr)((__block_impl *)blk);
    return 0;
}

从clang输出的代码可以看到,跟一个block相关的,有impl,funcdesc三个玩意儿。其中,desc里两个变量,reserved看起来是保留变量,Block_size应该是保存了block的大小。func是直接对应block的函数实现。impl则是对应着func之上的封装结构,可以和block直接划等号。

__main_block_impl_0的初始化来看,变量a是值传递的,因此我们这个小demo实际输出为1。

block也是对象

block也是对象,这句话我们多少有点了解,它可以作为类的成员变量/property传递,也有内存管理。结合上面重写后的代码应该如何理解“block也是对象”这句话?

可以把block作为一个类的成员变量然后rewrite一下,可以发现传递的是__main_block_impl_0这个结构体的指针,那么它是如何被当做对象来处理的?可以看到,__main_block_impl_0的第一个成员变量是__block_impl impl,而__block_impl也是个struct,第一个成员变量是void *isa。那么,如果用objc_object这个结构去解__main_block_impl_0,正好可以取到其中的isa指针。

真的是灵活。可以这么讲,任何一块内存,只要前32/64位是个isa指针,就可以当对象使。(当然乱搞还是有可能挂掉)

其实想一下对象的创建,也是差不多的,一个对象实例占用的内存空间是objc_object加上成员变量占用的内存空间,然后让objc_object指向这块内存,objc_object只关注开头那一小块内存。

回到block,block作为对象,有三种类型:

  1. NSConcreteGlobalBlock 全局的静态 block,不会访问任何外部变量。
  2. NSConcreteStackBlock 保存在栈中的 block,当函数返回时会被销毁。
  3. NSConcreteMallocBlock 保存在堆中的 block,当引用计数为 0 时会被销毁。

显然,分别是block变量作为全局变量、局部变量和被引用时的实现。

block对外部变量的处理

  1. 局部变量,copy到block中,因此外部值改变不影响block内的值
  2. 全局变量,静态变量,直接使用,block不做特殊处理。
  3. __block 修饰的外部变量,会被copy到堆内存,使用起来跟全局变量、静态变量类似。
  4. oc对象

    1. 全局变量、静态变量、__block修饰的变量,不会修改引用计数
    2. 局部变量,增加引用计数
    3. 成员变量,会增加self的引用计数

注意:

  • MRC 环境下,block 截获外部用 __block 修饰的变量,不会增加对象的引用计数
  • ARC 环境下,block 截获外部用 __block 修饰的变量,会增加对象的引用计数

消息机制

[receiver message]

objc这种方法调用的方式称为消息机制,不同于C++的函数调用,消息机制是运行时实现的非常灵活的调用机制。

在编译期,上面的语法被处理为objc_msgSend(receiver, @selector(message));

在这个函数中,会顺着继承链寻找方法实现,如果没有找到,则进入消息转发流程。

消息转发流程中对消息有三个层次的处理:

  1. resolveInstanceMethod:

    • 可以动态地给对象增加方法
  2. forwardingTargetForSelector:

    • 可以通过该函数返回一个可以处理该消息的对象
  3. methodSignatureForSelector:

    • doesNotRecognizeSelector:
    • forwardInvocation:

objc从smalltalk传承的消息机制,在很长一段时间内都是非常独特而先进的,但直到近几年仍有部分文章给予objc的消息机制过高的评价,这就不太客观了。其实,仔细想想就会知道,任何一个动态类型的面向对象语言,都必然有显式或隐式的消息机制。

一篇有意思的文章:各种语言如何响应未定义方法调用

从实际的实践来看,虽然objc的消息机制暴露了三个层次的转发接口出来,使得一些骚操作成为可能,但实际上用途并不多。

目前我只见过两种:

  1. 对于一些Model,把property全部声明为dynamic的,在消息转发流程拦截所有的set/get方法,取到对应的key和类型后,从一个dictionary里set/get。这样做的好处,主要是微量减少安装包大小。减包把猿逼到这份上,简直一把辛酸泪。
  2. 在转发流程对找不到方法的情况进行兜底处理和上报。

3. 一些runtime的应用

方法替换

方法替换(Method Swizzling)是最常见的runtime应用场景之一。主要的用途是对一些异常情况进行全局的兜底。有时候也用来处理系统函数的部分兼容性问题。

如对NSMutableDictionary的set方法参数为nil的情况进行兜底处理。

参考:method-swizzling

关联对象

关联对象(Associated Objects),常用于在分类中给已存在的类添加属性。

  • id objc_getAssociatedObject(id object, const void *key)
  • void objc_setAssociatedObject(id object, const void * key,id value, objc_AssociationPolicy policy)

主要就是应用这俩方法。注意这里的key,为了保证唯一性,往往是用@selector,或者NSString常量,也有用char指针的。

如:

// NSObject+AssociatedObject.h
@interface NSObject (AssociatedObject)
@property (nonatomic, strong) id associatedObject;
@end

// NSObject+AssociatedObject.m
@implementation NSObject (AssociatedObject)
@dynamic associatedObject;

- (void)setAssociatedObject:(id)object {
     objc_setAssociatedObject(self, @selector(associatedObject), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (id)associatedObject {
    return objc_getAssociatedObject(self, @selector(associatedObject));
}

Encoder & Decoder

runtime提供了在运行时遍历对象propertyList和ivarlist的能力,通常应用于Model自动序列化/反序列化,或json/model间的自动转化。

#import "NSObject+AutoEncodeDecode.h"
@implementation NSObject (AutoEncodeDecode)
- (void)encodeWithCoder:(NSCoder *)encoder {
    Class cls = [selfclass];
    while (cls != [NSObjectclass]) {
        unsigned int numberOfIvars =0;
        Ivar* ivars = class_copyIvarList(cls, &numberOfIvars);
        for(const Ivar* p = ivars; p < ivars+numberOfIvars; p++){
            Ivar const ivar = *p;
            const char *type =ivar_getTypeEncoding(ivar);
            NSString *key = [NSStringstringWithUTF8String:ivar_getName(ivar)];
            id value = [selfvalueForKey:key];
            if (value) {
                switch (type[0]) {
                    case _C_STRUCT_B: {
                        NSUInteger ivarSize =0;
                        NSUInteger ivarAlignment =0;
                        NSGetSizeAndAlignment(type, &ivarSize, &ivarAlignment);
                        NSData *data = [NSDatadataWithBytes:(constchar *)self + ivar_getOffset(ivar)
                                                      length:ivarSize];
                        [encoder encodeObject:dataforKey:key];
                    }
                        break;
                    default:
                        [encoder encodeObject:value
                                       forKey:key];
                        break;
                }
            }
        }
        free(ivars);
        cls = class_getSuperclass(cls);
    }
}

runtime这块,拖拖拉拉学习整理两个多月了才算有个相对完整的认知,进度有点太慢了,一方面是工作强度有点大,一方面也是自己有所懈怠,继续加油吧。


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

载入中...