Swift 与 Objective-C

Objective-C 消息转发的 3 个阶段

iOS 崩溃日志中最常见的语句之一,就是 unrecognized selector sent to instance。

6 分钟阅读
Objective-C 消息转发的 3 个阶段 封面图

iOS 崩溃日志中最常见的语句之一,就是 unrecognized selector sent to instance

不过,这个崩溃其实并不是“当场死亡”。在崩溃之前,Objective-C 运行时会给对象三次机会。这套补救流程就是消息转发(Message Forwarding)。

上一篇 objc_msgSend 文章总结了“方法查找失败后会执行 3 个转发阶段”。这次我们用代码逐一拆解这三个阶段。重点是为什么第 2 阶段被称为Fast Forwarding,以及第 3 阶段为什么需要方法签名。


全貌:三次机会与成本

如果方法查找一直到达顶层类仍找不到 IMP(指向方法实际实现的函数指针),运行时就会按以下顺序提问。

阶段 方法 问题 成本
1. 动态方法解析 resolveInstanceMethod: “现在要不要添加方法?”
2. 替代接收者 forwardingTargetForSelector: “有没有对象可以代为接收?” 低(Fast)
3. 完整转发 methodSignatureForSelector: + forwardInvocation: “我把整条消息交给你,你要不要自行处理?”

顺序也就是成本顺序。运行时会先尝试最便宜的方法;如果全部被拒绝,就会调用doesNotRecognizeSelector:,随后发生那个著名的崩溃。


第 1 阶段:resolveInstanceMethod: — 现在动态添加方法

第一个问题以类方法调用的形式到达。在这里通过class_addMethod添加实现并返回 YES,消息发送就会从头重新开始,这次便能成功。

void dynamicIMP(id self, SEL _cmd) {
    NSLog(@"动态添加的实现");
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(dynamicMethod)) {
        class_addMethod(self, sel, (IMP)dynamicIMP, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

第三个参数的"v@:"是类型编码。返回值是 void(v),接收者是 id(@),选择子是(:)——所有 Objective-C 方法都会接收两个隐藏参数 self 和_cmd,这一点在这里就体现出来了。

Core Data 是这一阶段的典型使用者。使用@dynamic声明的属性在编译时没有访问器,第一次调用时才会在这一阶段动态创建。

需要注意一点:resolveInstanceMethod:即使不处于转发场景,也可能被调用。respondsToSelector:或 KVC(Key-Value Coding)在内部查询方法时也会调用它,因此在这里记录日志,出现频率会远高于预期。


第 2 阶段:forwardingTargetForSelector: — Fast Forwarding 的真相

第二个问题是:“如果你无法处理,能不能告诉我哪个对象可以代为处理?”

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if ([self.helper respondsToSelector:aSelector]) {
        return self.helper;
    }
    return [super forwardingTargetForSelector:aSelector];
}

返回非 nil 对象后,整条消息会重新发送给该对象。与第 3 阶段相比,这一阶段被称为Fast Forwarding的原因很明显:它不创建 NSInvocation 对象,只替换接收者,因此成本几乎和普通消息发送相同。

这一阶段在实际开发中也有明确用途。

  • 模拟多重继承:按选择子将消息委托给多个 helper 对象,就能不使用继承而组合多个类的能力。
  • weak proxy:用于打破 NSTimer retain cycle 的中间代理对象就利用了这里(准确地说,是 NSProxy 的转发)。
  • API 安全网:在旧版 OS 中,将仅存在于新版 OS 的方法转交给替代对象的防御性代码。
objc_msgSend 查找失败后的消息转发 3 阶段分支流程图,从 resolveInstanceMethod 到 forwardInvocation
三个阶段中只要有一个成功,就不会发生崩溃

第 3 阶段:完整转发 — 为什么必须先获取签名

第 2 阶段也被拒绝后,运行时会使用最后手段。但这一阶段不是覆盖一个方法,而是必须成对覆盖两个方法

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    NSMethodSignature *sig = [super methodSignatureForSelector:aSelector];
    if (!sig) {
        sig = [self.target methodSignatureForSelector:aSelector];
    }
    return sig;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    for (id target in self.targets) {
        if ([target respondsToSelector:invocation.selector]) {
            [invocation invokeWithTarget:target];
        }
    }
}

为什么签名必须在前?因为运行时要将消息封装成 NSInvocation 对象,就必须知道参数有几个、每个参数占多少字节。如果methodSignatureForSelector:无法返回有效签名,forwardInvocation: 根本不会被调用,而是直接崩溃。

拿到 NSInvocation 后,能做的事就多了。你可以修改参数,或者像上面的示例一样把同一条消息广播给多个对象(多播代理),也可以记录响应,稍后重放。NSUndoManager 的prepareWithInvocationTarget:正是采用这种方式:将要撤销的方法调用捕获为 NSInvocation,在 undo 时重放。

它最灵活,但成本也最高。由于创建 NSInvocation 和打包参数都有额外成本,Apple 文档也明确指出它“比普通消息发送慢得多”。对于性能关键路径,停在第 2 阶段才是正确选择。


陷阱:respondsToSelector: 不知道消息转发

即使对象能通过转发正确处理消息,询问respondsToSelector:时仍会回答 NO。选择子查找只查看方法列表,而转发路径必须真正发送消息后才会生效。

因此,要正确构建基于转发的代理,还必须覆盖respondsToSelector:,让它配合说出“这条消息我可以接收”的谎言。在委托检查(if ([delegate respondsToSelector:...]))很常见的 Objective-C 世界中,遗漏这一步,就会遇到转发已准备好却根本没有调用到的谜团。

NSProxy 不继承 NSObject,而是独立根类的原因也在于此。继承的方法越多,就越多消息会由自身处理而不会进入转发,因此才单独创建了一个只保留骨架的类。这部分会在 NSProxy 文章中另行介绍。

Fast Forwarding 替代接收者插图:一个机器人将消息信封交给另一个机器人
第 2 阶段只替换接收者后转发,因此速度很快

总结

  • unrecognized selector 崩溃不是当场死亡,而是3 个补救阶段全部失败的结果
  • 第 1 阶段resolveInstanceMethod:会即时添加方法——@dynamic 和 Core Data 都在这里运行。
  • 第 2 阶段forwardingTargetForSelector:是只替换接收者的Fast Forwarding——无需 NSInvocation 即可低成本完成。
  • 第 3 阶段是methodSignatureForSelector:forwardInvocation:的组合——必须有签名才能创建 NSInvocation。
  • 多播代理、NSUndoManager 和 weak proxy 都是建立在这一结构之上的应用。
  • 构建转发代理时,别忘了覆盖respondsToSelector:

继续了解 Swift 为什么默认放弃这种灵活性而选择静态分派,以及即便如此仍能通过@objc dynamic重新进入这个世界的原因,就能更立体地理解两种语言的设计理念。

延伸阅读