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 的方法转交给替代对象的防御性代码。
第 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 文章中另行介绍。
总结
- unrecognized selector 崩溃不是当场死亡,而是3 个补救阶段全部失败的结果。
- 第 1 阶段
resolveInstanceMethod:会即时添加方法——@dynamic 和 Core Data 都在这里运行。 - 第 2 阶段
forwardingTargetForSelector:是只替换接收者的Fast Forwarding——无需 NSInvocation 即可低成本完成。 - 第 3 阶段是
methodSignatureForSelector:与forwardInvocation:的组合——必须有签名才能创建 NSInvocation。 - 多播代理、NSUndoManager 和 weak proxy 都是建立在这一结构之上的应用。
- 构建转发代理时,别忘了覆盖
respondsToSelector:。
继续了解 Swift 为什么默认放弃这种灵活性而选择静态分派,以及即便如此仍能通过@objc dynamic重新进入这个世界的原因,就能更立体地理解两种语言的设计理念。

