objc-runtime梳理三总结

24次阅读

共计 8588 个字符,预计需要花费 22 分钟才能阅读完成。

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

1. 对象结构

首先还是回到这张经典的图,非常好地展现了 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

    • ivarLayout/weakIvarLayout:具体布局解析可以参考 runtime 使用篇: class_getIvarLayout 和 class_getWeakIvarLayout
    • RO_HIDDEN:Controlling Symbol Visibility
    • RO_HAS_WEAK_WITHOUT_ARC:Objective-C Class Ivar Layout 探索
    • RO_FUTURE/RW_FUTURE

      • 参考 runtime 源码中的objc_getFutureClass,在类未加载前,可以通过 name 拿到一个实现为空的类,当其加载后,其内容会被正确填充。这样的类被称为 future class。从源码注释看,是 toll-free bridging 会用到这个能力,但我没有想明白它为什么需要这个能力。

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

正文完
 0