iOS Runtime 面试精选

4 阅读7分钟
  • 问题:你对 Runtime 是如何理解的?

    • Runtime 是一套用 C、C++ 和汇编语言编写的底层 API,为 Objective-C 提供了动态特性。它将数据类型的确定、方法调用的决定等从编译时推迟到运行时
    • 我们平时写的 OC 代码,最终都会转换成 Runtime 的 C 语言代码来执行,例如 [obj foo] 会被转换成 objc_msgSend(obj, @selector(foo))
  • 问题:介绍一下 isa 指针?对象、类、元类之间是什么关系?

    • isa 指针:是每一个 Objective-C 对象(包括实例对象和类对象)内部的第一个成员变量,它指向对象所属的类

    • 三者关系:这是一个经典的三层结构。

      • 实例对象(Instance) :它的 isa 指向类对象(Class) ,类对象中存储了实例方法列表、属性等信息。
      • 类对象(Class) :它的 isa 指向元类(Metaclass) ,元类中存储了类方法列表。
      • 元类(Metaclass) :它的 isa 指向根元类(Root Metaclass,通常是 NSObject 的元类) ,根元类的 isa 指向自己。其 superclass 最终指向根类对象(如 NSObject 类对象),形成一个闭环
  • 问题:为什么要设计 Metaclass(元类)?

    • 核心目的是为了让类方法的存储和调用机制与实例方法保持统一。在 OC 中,类本身也是一个对象,也需要能够响应消息(即类方法)。元类就是用来存储类方法列表的“类对象”,从而让“发送给类对象的消息”也能通过 isa 指针找到实现
  • 问题:class_rw_t 和 class_ro_t 有什么区别?

    • class_ro_t:其中的 ro 代表 Read-Only。它存储的是类在编译时就已经确定的属性、方法、协议等信息,在运行期间是只读的,不可修改
    • class_rw_t:其中的 rw 代表 Read-Write。它在运行时被创建,包含了 class_ro_t 的内容,并且为运行时动态添加的内容(如分类的方法、通过 Runtime API 添加的属性)提供了存储空间,这些动态内容是存储在 class_rw_t 中的

消息传递与转发

  • 问题:OC 中向一个对象发送消息,底层的流程是怎样的?

    • 当调用一个方法时,编译器会将其转换为 objc_msgSend(receiver, selector)
    • 快速查找:首先根据对象的 isa 指针找到其所属的类,然后在类的方法缓存(cache_t)  中查找方法的实现 IMP。如果找到,直接调用
    • 慢速查找:如果缓存中没有,则去类的方法列表(class_rw_t 中的方法列表)中查找。如果没找到,就通过 superclass 指针去父类的方法列表中查找,直到根类
    • 消息转发:如果最终仍然没有找到方法实现,就会进入消息转发流程
  • 问题:什么是消息转发机制?它在什么情况下会被触发?一共有哪些步骤?

    • 当给一个对象发送了其无法响应的消息时(即在类及其父类中都找不到对应的方法实现),就会触发消息转发机制

    • 完整流程分为三步:

      1. 动态方法解析:首先调用 +resolveInstanceMethod:(或 +resolveClassMethod:),允许开发者在这个阶段动态地添加方法实现。如果返回 YES,Runtime 会重新发送消息
      2. 快速转发:如果上一步未处理,会调用 -forwardingTargetForSelector:,允许开发者将消息转发给另一个对象处理。这个步骤效率最高,适合简单的消息转发
      3. 完整消息转发:这是最后的机会。首先调用 -methodSignatureForSelector: 获取方法的方法签名(参数和返回值类型),如果返回不为 nil,则再调用 -forwardInvocation:,将一个 NSInvocation 对象封装给开发者,可以修改其调用目标和方法实现。如果这一步也没处理,最终会调用 -doesNotRecognizeSelector: 抛出 unrecognized selector sent to instance 异常
  • 问题:向 nil 对象发送消息会发生什么?

    • 在 Objective-C 中,向 nil 对象发送任何消息都不会导致程序崩溃。Runtime 的 objc_msgSend 函数在检查到接收者为 nil 时,会直接返回一个默认值(对于返回结构体的消息,返回值是未定义的,但通常也是0填充)

动态特性与应用

  • 问题:Category(分类) 和 Extension(扩展) 有什么区别?

    • Extension(扩展) :在编译时决议,可以添加实例变量和方法,但只作为类的私有部分,在 .m 文件中声明
    • Category(分类) :在运行时决议,无法直接添加实例变量(但可以通过关联对象间接实现)。分类的方法会被合并到类的方法列表中,如果分类有和类同名的方法,优先调用分类中的实现(最后编译的分类优先)
  • 问题:Runtime 是如何实现 weak 变量自动置 nil 的?

    • Runtime 维护了一个全局的 SideTables 哈希表,其中每个 SideTable 里都有一个 weak_table_t,它是一个弱引用表
    • 当一个对象被 __weak 修饰时,Runtime 会以对象的内存地址为 key,将该 weak 指针的地址注册到弱引用表中。
    • 当对象销毁时(dealloc 调用),Runtime 会通过对象地址在弱引用表中找到所有指向它的 weak 指针,并将其全部置为 nil,然后从表中移除该记录
  • 问题:能否向编译后得到的类中增加实例变量?为什么?

    • 不能。编译后的类,其内存布局(instance_size)和实例变量列表(objc_ivar_list)已经在编译时确定并注册到 Runtime 中,无法再修改
    • 运行时动态创建的类,在调用 objc_allocateClassPair 之后、objc_registerClassPair 之前,可以通过 class_addIvar 来添加实例变量。因为此时类的内存结构还未最终定型

💡 一道经典"超纲题"

这是网上流传的一道 Sunnyxx 的考题,可以很好地检验你对 Runtime 底层和内存的掌握程度

objectivec

// Person类
@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
- (void)print;
@end

@implementation Person
- (void)print {
    NSLog(@"my name = %@", self.name);
}
@end

// ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    
    id cls = [Person class];
    void *obj = &cls;
    [(__bridge id)obj print];
}

问:这段代码会输出什么?会崩溃吗?为什么?

点击查看答案 **答案**:不会崩溃,会打印出当前 `ViewController` 对象的信息,例如 `my name = `。

核心原因

  1. 栈内存分配obj 是一个指向栈空间的指针。栈空间的内存地址是从高到低分配的。viewDidLoad 中有 cls 和 obj 两个局部变量,它们的地址是连续的
  2. isa 指针的奥秘obj 指向了 cls 的地址。当对 obj 发送 print 消息时,Runtime 会从 obj 指向的内存开始,将其当作一个对象的 isa 指针。巧合的是,cls 本身就是一个指向 Person 类的指针,它恰好可以充当这个"假对象"的 isa,所以 Runtime 能正确地找到 Person 类并调用 print 方法
  3. 访问成员变量:关键在于 print 方法中访问了 self.namename 属性的 getter 方法本质上是通过 self 指针,跳过 isa 指针的长度(8字节),来读取内存中的值。在这个例子中,self 就是 objobj 指向的是 cls 的地址。那么 obj + 8 会指向哪里?由于 cls 和 obj 是连续的栈变量,cls 占8字节,obj + 8 正好指向了 cls 变量下方的内存。而在调用 [super viewDidLoad] 时,系统会生成一个临时结构体(包含 self 和 ViewController 类),这个结构体恰好位于栈上,其地址覆盖了 cls 下方的内存。因此,self.name 实际上读取到了 self(即当前的 ViewController 实例)的地址,所以打印出了 ViewController 的信息

希望这份总结对你的面试准备有所帮助。除了概念本身,这些底层原理之间的关联性(比如 isa 如何连接消息发送和 weak 如何影响对象生命周期)往往是面试官考察的重点。祝你面试顺利!