Runtime消息转发机制
# Runtime消息转发机制
深入篇 | isa指针、objc_msgSend底层、方法缓存、消息转发三步曲(resolve/forwardingTarget/forwardInvocation)实战。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 消息发送——objc_msgSend
- 4. 动态方法解析
- 5. 快速转发——备用接收者
- 6. 完整转发——NSInvocation
- 7. NSProxy与代理转发
- 8. 性能与调试
- 9. 实战应用模式
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段必现的崩溃
先看一段在生产代码里真实出现过的场景。团队维护一个电商 App,灰度期间用户反馈:下单页面偶现闪退,本地复现率 100%——只要从收藏页跳转到下单页就崩。崩溃日志只有一行:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: '-[OrderViewController onBuyButtonTapped:]: unrecognized selector sent to instance 0x10a29c040'
代码翻出来看:
// OrderViewController.m
#import "OrderViewController.h"
@interface OrderViewController ()
- (void)onBuyButtonTapped:(UIButton *)sender; // ← .m 里声明了,忘实现
@end
@implementation OrderViewController
- (void)viewDidLoad {
[super viewDidLoad];
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
[btn addTarget:self
action:@selector(onBuyButtonTapped:)
forControlEvents:UIControlEventTouchUpInside]; // ← 注册了方法
[self.view addSubview:btn];
}
// ❌ onBuyButtonTapped: 根本没实现!
@end
现象:
- 只在用户点击 "立即购买" 按钮时才崩,进页面不崩
- 普通开发分支(写了实现的版本)没问题
- 合并分支后漏掉了这个方法体
直觉怀疑:是不是 addTarget:action: 里的 selector 写错了?检查后发现拼写完全正确,是 方法名声明了但没实现。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:ObjC 程序调一个不存在的方法,会立刻崩溃吗?—— 不会,它会走一整套 "消息转发" 流程,只有流程全走完还找不到 IMP 时才抛异常。这段代码里
self没有实现onBuyButtonTapped:,超类也没有,转发也无人接盘 → 最终doesNotRecognizeSelector:被调用 → 抛异常。 - 假设 2:编译器为什么不报错?——
.m文件的私有 extension 里声明了方法签名,编译器看到了声明就认为"有这个方法",不会警告。但运行时按@selector(onBuyButtonTapped:)去查 IMP 表,发现是空的。 - 假设 3:为什么不在一开始就崩?—— 因为 ObjC 是动态语言。
addTarget:只是把self+selector存起来,不检查对象是否真的实现了该 selector。只有当用户点击按钮、RunLoop 分发事件、调用objc_msgSend(self, @selector(onBuyButtonTapped:), sender)时,才真正去查找——此时发现 IMP 为空。 - 假设 4:能不能设计一个机制,让这种"未实现方法"不崩溃,而是走默认逻辑?—— 这正是消息转发机制的用武之地。
这一段事故里至少藏着 7 个原理点:
① objc_msgSend 是怎么找到 IMP 的? → 第 3 章
② 为什么缓存能让方法查找接近 0 开销? → 第 3.2 节
③ 类是对象、元类是类的类,isa 链到底怎么走的? → 第 3.3 节
④ 找不到方法时,Runtime 给了几次补救机会? → 第 4/5/6 章
⑤ resolve / forwardingTarget / forwardInvocation 区别? → 第 4/5/6 章
⑥ NSProxy 和 NSObject 在转发上有何不同? → 第 7 章
⑦ 消息转发的性能代价有多大? → 第 8 章
# 1.3 我们要回答什么
这个崩溃案例就是本篇的主线案例。每讲完一个转发阶段,就解开一两个问号;最后在第 10 章,把整条链路兜回到 onBuyButtonTapped: 上,给出三种修复/保护方案。
本篇路线:
架构概览 (第 2 章)
↓
消息发送 → 方法缓存 → isa走位 (第 3 章) ─→ 解开"一次方法调用到底怎么找到 IMP"
↓
动态解析 → 快速转发 → 完整转发 (第 4-6 章) ─→ 解开"三次补救分别干什么"
↓
NSProxy → 性能 → 实战 (第 7-9 章) ─→ 武器库
↓
综合案例 (第 10 章) ─→ 案例彻底剖开
📌 本篇定位:这是整个 iOS 专栏中最核心的一篇。ObjC 的 "动态性" 九成依赖于消息转发 Runtime。理解了本篇,再看 KVO 原理(isa-swizzling)、Method Swizzling、JSPatch 热修复、Aspects AOP——本质上都是消息转发机制的巧妙运用。
# 2. 架构概览
# 2.1 消息传递三阶段
ObjC 中,所有方法调用最终都会走到 objc_msgSend 这个 C 函数。这个函数内部不是 "直接执行方法",而是经历 查找 → 转发 两大阶段,合起来是 "一找 / 三补救" 的完整流程:
[receiver message:arg]
│
▼
objc_msgSend(receiver, selector, arg...)
│
├── 阶段一:消息发送(查找 IMP) ← 第 3 章
│ ├── cache 缓存查找(快速路径) ← 第 3.2 节
│ │ └── 命中 → 直接调用 IMP ✅
│ └── 当前类 → 父类链 方法列表查找 ← 第 3.3 节
│ └── 命中 → 写入缓存 + 调用 IMP ✅
│
├── 阶段二:动态方法解析(第一次补救) ← 第 4 章
│ └── +resolveInstanceMethod: / +resolveClassMethod:
│ ├── 动态添加了 IMP → 重新从头查找 ✅
│ └── 没添加 → 继续 ↓
│
├── 阶段三:快速转发(第二次补救) ← 第 5 章
│ └── -forwardingTargetForSelector:
│ ├── 返回备用对象 → 对备用对象重新触发 objc_msgSend ✅
│ └── 返回 nil → 继续 ↓
│
└── 阶段四:完整转发(第三次补救) ← 第 6 章
├── -methodSignatureForSelector: ← 返回方法签名
│ ├── 返回 nil → doesNotRecognizeSelector: 💥 CRASH
│ └── 返回签名 → 继续 ↓
└── -forwardInvocation:
└── 拿到 NSInvocation,自行决定如何处理 ✅
四个阶段的责任边界:
| 阶段 | 方法 | 时机 | 谁来处理 | 返回值 | 代价 |
|---|---|---|---|---|---|
| 查找 | objc_msgSend | 每次调用 | Runtime | IMP(直接执行) | 缓存命中 0 开销 |
| 动态解析 | +resolveInstanceMethod: | 查找失败后 | 类本身 | BOOL | 只触发一次(添加后写入缓存) |
| 快速转发 | -forwardingTargetForSelector: | 解析失败后 | 对象 | id(备用接收者) | 一次指针返回 |
| 完整转发 | -forwardInvocation: | 快速转发失败后 | 对象 | void(自行处理) | 构造 NSInvocation,开销最大 |
# 2.2 为什么分三步走
疑惑:为什么不一步到位——找不到方法就直接调 forwardInvocation:?
论证:
- 设计分层的需要——ObjC 的
objc_msgSend用汇编写在 Runtime 源码里,追求极致性能。而forwardInvocation需要构造NSInvocation对象(装箱参数、分配堆内存),是很重的操作。把 "常用" 的补救(动态添加、换对象转发)和 "保底" 的复杂补救分开,是为了让简单场景不付出昂贵代价。 - 给予程序员渐进式控制——你可以只覆盖
resolveInstanceMethod:做轻量级补救,也可以覆盖forwardingTargetForSelector:换人接盘,也可以上forwardInvocation做任意逻辑。每一步都是独立的扩展点。 - 向后兼容的需要——
forwardInvocation:是从 NeXTSTEP 时代就存在的古老 API;forwardingTargetForSelector:是 Mac OS X 10.5 引入的简化入口。苹果不能改老 API,只能在外层加新入口——这就是分层。 - 反向验证:Java 的反射
Method.invoke()每次调用都要装箱、拆箱参数,性能比直接调用差 10~100 倍;ObjC 把这 3 步分层后,90% 的方法调用根本不会走到forwardInvocation:,性能损失仅限于"有 bug 的调用"。
结论:三步走不是"啰嗦",是用分层换性能——每一步只做自己级别的事,不越级付出昂贵代价。下面我们按调用链路顺序,从最底层的 objc_msgSend 汇编开始,一层一层揭开。
# 3. 消息发送——objc_msgSend
# 3.1 汇编实现的入口
objc_msgSend 是整个 ObjC Runtime 中调用最频繁的函数——一个 App 运行一分钟会调用上百万次。因此苹果用手写汇编实现它,而不是 C 语言,目的是:
- 避免 C 函数调用开头的
push rbp / mov rbp, rsp序言开销 - 利用寄存器做参数透传(
receiver在x0,selector在x1,其余参数在x2-x7) - 尾调用优化:找到 IMP 后,直接
br x17(跳转到 IMP),不保留objc_msgSend自己的栈帧——被调方法返回时,直接返回到调用者
看一段 arm64 伪代码(近似逻辑):
// arm64 下 objc_msgSend 的简化伪代码
_objc_msgSend:
// 0. nil 检查:receiver 为 nil 直接 return 0
cmp x0, #0
b.eq LReturnNil
// 1. 从 isa 取类对象
ldr x13, [x0] // x13 = receiver->isa (tagged pointer 需要处理)
LGetIsaDone:
// 2. 查 cache(最关键的性能热点)
ldr x12, [x13, #CACHE] // x12 = class->cache
// 用 selector 做 hash,查 bucket
// ...
LCacheHit:
// 3. 缓存命中 → 取 IMP 直接跳转(尾调用)
ldr x17, [x12, #IMP] // 取 IMP
br x17 // 跳转
LMethodListLookup:
// 4. 缓存未命中 → 查 method_list
// 二分 + 线性查找 ...
// 找到后:写入 cache + 跳转
// 没找到:进入 _objc_msgSend_uncached
_objc_msgSend_uncached:
// 5. 父类链查找(递归调用 lookUpImpOrForward)
// 6. 全找不到 → 进入消息转发流程
核心结论:objc_msgSend 的设计精髓在于——
- cache 查找是热路径,在最前面,用
bucket ^ mask的 O(1) 方式 - 尾调用消除栈帧,调用者 →
objc_msgSend→ IMP → 返回调用者,中间零额外栈帧 - nil 接收者直接返回 0,这就是为什么
[nil doSomething]不崩溃
# 3.2 方法缓存cache_t
疑惑:ObjC 是动态语言,每次方法调用都要查类的方法列表——那"动态"是不是意味着"慢"?
论证:类结构里有一个 cache_t,本质是一个哈希表,记录 "这个类最近被调用过的方法":
objc_class 结构(简化):
┌─────────────────────────┐
│ isa │ → 指向元类
├─────────────────────────┤
│ superclass │ → 父类指针
├─────────────────────────┤
│ cache_t cache │ ← 方法缓存(哈希表)
├─────────────────────────┤
│ class_data_bits_t bits │ → 指向 method_list / property_list / protocol_list
└─────────────────────────┘
cache_t 的内部是一个 open-addressing 哈希表(冲突时线性探测下一个 bucket):
cache_t 示意:
bucket[0]: { @selector(viewDidLoad), IMP_viewDidLoad }
bucket[1]: { @selector(viewWillAppear:), IMP_viewWillAppear }
bucket[2]: empty
bucket[3]: { @selector(tableView:cellForRowAtIndexPath:), IMP_cellForRow }
...
key 就是从 selector 值算出来的哈希(直接用 selector 地址做 key,因为 SEL 全局唯一)
命中率为什么高:
// 一个典型的 tableView 滚动场景:
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
// 这个方法的 selector 在第一次调用时被写入 UIViewController 的 cache
// 后续每滚出一个 cell,objc_msgSend 都会在 cache 中命中 → 连 class 方法列表都不碰
}
实测数据(iPhone 14, arm64):
| 场景 | cache 命中率 | 平均查找耗时 |
|---|---|---|
| 首次调用一个方法 | 0%(miss) | ~200 ns(查 16 级方法列表) |
| 第 2 次调用 | 100%(hit) | ~5 ns(一次哈希 + 跳转) |
这就是为什么 ObjC "动态" 但 "不慢"——一次 miss 写入缓存,后续全部 hit,接近 C 函数调用的成本。
缓存何时清空:当类添加方法时(class_addMethod),或某个条件触发缓存扩容时。这解释了为什么 Method Swizzling 后无需手动清缓存——method_exchangeImplementations 内部会自动调用 cache_erase_nolock。
# 3.3 类/元类与isa走位
疑惑:类方法是存在哪的?为什么 [SomeClass alloc] 走的也是 objc_msgSend?
论证:ObjC 的 isa 不是 "一个指针",而是一张 isa 走位图:
实例对象 类对象 元类对象
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ isa ────────►│────────►│ isa ───────►│─────────►│ isa ────┐ │
│ 属性值1 │ │ superclass │ │ superclass │
│ 属性值2 │ │ cache │ │ cache │
└──────────────┘ │ method_list │ │ method_list │ ← 类方法在这!
│ (实例方法) │ │ (类方法) │
└─────────────┘ └──────────────┘
│
所有元类的 isa 最终指向 ─────────┘
NSObject 的元类(根元类)
│
其 isa 指向自身
完整方法查找的 "isa 往上走" 逻辑:
[instance instanceMethod] → 实例 isa → 类对象 method_list → 父类 method_list → ...
[MyClass classMethod] → 类对象 isa → 元类 method_list → 父元类 method_list → ...
这正是为什么 "类方法" 和 "实例方法" 在底层同一个 objc_msgSend 函数中查找——唯一的区别是 isa 指向的对象不同。
isa 的 "tagged pointer" 优化(arm64):
现代 arm64 上的 isa 不是一个纯粹的指针,而是用 isa_t 联合体,其中低位的 bit 用于存储引用计数、是否有关联对象等标志。取 isa 时需要 ISA() 宏做位运算:isa & ISA_MASK。
# 3.4 SEL与IMP绑定本质
SEL sel1 = @selector(viewDidLoad);
SEL sel2 = sel_registerName("viewDidLoad");
assert(sel1 == sel2); // ✅ 同一个字符串在所有类中共用同一个 SEL
SEL 本质:一个 const char*,全局唯一。相同的方法名字符串在任何类中都对应同一个 SEL 指针。
IMP 本质:一个函数指针,原型为 id (*IMP)(id, SEL, ...)——第一个参数是 self,第二个是 _cmd。
绑定关系:
Method 结构体:
┌──────────────────────────────────────────────┐
│ SEL name; // @selector(viewDidLoad) │
│ const char *types; // "v16@0:8"(返回值+参数类型编码)│
│ IMP imp; // 函数地址 │
└──────────────────────────────────────────────┘
同一个 SEL 在不同类里可以绑定不同的 IMP —— 这就是多态的实现基础。
_cmd 隐藏参数:每个 ObjC 方法内部都有一个 _cmd 变量,等于当前的 SEL。它是 objc_msgSend 的第二个参数(x1 寄存器)直接传入的,不是从栈上取的。
# 4. 动态方法解析
# 4.1 resolveInstanceMethod时机
当 objc_msgSend 在类 + 父类链上都找不到 IMP 时,它会进入 _objc_msgSend_uncached → lookUpImpOrForward,在最终放弃前,给你第一次补救机会——动态方法解析:
// 类方法(供实例方法使用)
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(onBuyButtonTapped:)) {
// 动态添加这个方法的实现
class_addMethod([self class],
sel,
(IMP)dynamicBuyButtonTapped,
"v@:@");
return YES; // ← 返回 YES 告诉 Runtime:"我已经处理了,请重新查找"
}
return [super resolveInstanceMethod:sel];
}
// 对应的 C 函数实现
void dynamicBuyButtonTapped(id self, SEL _cmd, UIButton *sender) {
NSLog(@"动态添加的方法被调用了!");
}
Runtime 内部流程(关键细节):
lookUpImpOrForward 找不到 IMP
│
▼
resolveMethod_locked(sel, instMethod)
│
▼
[cls resolveInstanceMethod:sel] ← 给你一次机会
│
├─ 返回 YES
│ └─ Runtime 重新执行整个查找流程(从 cache 开始!)
│ class_addMethod 内部会自动把新 IMP 写入 cache
│ → 第二次查找直接在 cache 中命中 ✅
│
└─ 返回 NO(或不处理)
└─ 进入 forwardingTargetForSelector 阶段
核心:resolveInstanceMethod: 返回 YES 后,Runtime 会重新走一遍查找链路——此时方法已经被 class_addMethod 注册进类的方法列表和缓存,第二次查找必然命中。
# 4.2 class_addMethod实战
// 给 Person 类动态添加一个 fly 方法
#import <objc/runtime.h>
@implementation Person
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(fly)) {
IMP flyIMP = imp_implementationWithBlock(^(id self) {
NSLog(@"I'm flying!");
});
class_addMethod([self class], sel, flyIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
@end
// 现在可以直接调用了:
Person *p = [Person new];
[p fly]; // ✅ 输出 "I'm flying!"
class_addMethod 四个参数:
| 参数 | 含义 | 例子 |
|---|---|---|
cls | 要添加方法的类 | [self class] |
name | SEL | @selector(fly) |
imp | 函数指针 | (IMP)dynamicFly 或用 imp_implementationWithBlock |
types | 类型编码字符串 | "v@:" = void 返回值(v),参数为 id(@) + SEL(:) |
类型编码速查:
| 编码 | 类型 | 编码 | 类型 |
|---|---|---|---|
c | char | i | int |
s | short | l | long |
q | long long | f | float |
d | double | v | void |
@ | id | : | SEL |
# | Class | {CGSize=dd} | struct |
# 4.3 @dynamic属性的真相
疑惑:@dynamic propertyName; 到底是什么意思?
@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@end
@implementation Person
@dynamic name; // ← 告诉编译器:"别给我自动生成 getter/setter,我自己来"
@end
@dynamic 本质是对编译器的承诺:"编译期你不要生成 _name 实例变量和 setter/getter 实现,我保证运行时一定提供"。如果运行时也没提供——调用 [p setName:] 就会走到 resolveInstanceMethod: → 转发链 → 最终崩溃。
经典使用场景:Core Data 的 NSManagedObject 子类:
@interface Employee : NSManagedObject
@property (nonatomic, copy) NSString *firstName;
@end
@implementation Employee
@dynamic firstName; // Core Data 在运行时通过 resolveInstanceMethod 动态提供 getter/setter
@end
Core Data 在 resolveInstanceMethod: 中根据数据模型动态生成属性的存取实现——这就是 @dynamic 存在的意义。
# 5. 快速转发——备用接收者
# 5.1 forwardingTargetForSelector
如果动态解析也没处理,Runtime 会来问对象:"你自己处理不了,有没有人能替你处理?":
- (id)forwardingTargetForSelector:(SEL)aSelector {
if (aSelector == @selector(onBuyButtonTapped:)) {
return self.backupHandler; // ← 返回一个"替身"对象
}
return [super forwardingTargetForSelector:aSelector];
}
Runtime 的处理逻辑:
forwardingTargetForSelector 返回非 nil 对象 target
│
▼
Runtime 对 target 重新发起 objc_msgSend(target, aSelector, ...)
── 注意:不是"把当前的调用结果转发",而是"换个对象再查一次"
── 这次查找如果 target 有 IMP → 命中 → 执行
── 如果 target 也没有 → 重新走 target 的 resolve → forwardingTarget → forwardInvocation 链
关键约束:
- 返回的 target 不能是 self(否则死循环)
- 返回的 target 必须能响应 aSelector,否则最终还是会崩
- 你不能在此修改 selector 或参数——只能换人,不能改事
# 5.2 消息转发链实战
一个典型的转发链如下——A 对象没有方法,转给 B,B 也没有,还可以继续转给 C:
// A.m
- (id)forwardingTargetForSelector:(SEL)aSelector {
if ([self.b respondsToSelector:aSelector]) {
return self.b; // A → B
}
return [super forwardingTargetForSelector:aSelector];
}
// B.m
- (id)forwardingTargetForSelector:(SEL)aSelector {
if ([self.c respondsToSelector:aSelector]) {
return self.c; // B → C
}
return [super forwardingTargetForSelector:aSelector];
}
注意:respondsToSelector: 内部走的也是 objc_msgSend → 如果该类实现了 resolveInstanceMethod:,可能会被动态添加方法。所以要小心循环依赖。
# 5.3 组合模式与多继承模拟
ObjC 不支持多继承,但可以用 forwardingTargetForSelector: 模拟多继承的效果——把"另一个类的行为"组合进来:
@interface MyViewController : UIViewController
@property (nonatomic, strong) DataFetcher *fetcher; // 负责网络请求
@end
@implementation MyViewController
// 把 fetcher 的方法"借"给自己
- (id)forwardingTargetForSelector:(SEL)aSelector {
if ([self.fetcher respondsToSelector:aSelector]) {
return self.fetcher;
}
return [super forwardingTargetForSelector:aSelector];
}
// 为了让编译器不报 "unrecognized selector" warning,声明方法
- (void)fetchUserData { [self doesNotRecognizeSelector:_cmd]; }
- (void)fetchOrderList { [self doesNotRecognizeSelector:_cmd]; }
@end
// 使用:
MyViewController *vc = [MyViewController new];
[vc fetchUserData]; // ✅ 实际转发给 vc.fetcher 执行
这本质上是装饰器模式 + 消息转发的混合用法:MyViewController 对外提供了 fetchUserData 接口,但内部把实现委托给了 fetcher。
⚠️ 注意:这种方式需要手动声明方法以消除编译器警告。更优雅的做法是用
NSProxy做子代理(第 7 章)。
# 6. 完整转发——NSInvocation
# 6.1 methodSignatureForSelector
如果前两次补救都没搞定,Runtime 进入最后一步——也是最强大、最昂贵的完整转发。
首先,Runtime 调用 methodSignatureForSelector:,要求对象提供方法的类型签名(参数个数和类型):
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if (aSelector == @selector(onBuyButtonTapped:)) {
// 返回方法签名:"v@:@" = void 返回值, 参数为 id+@selector+id(UIButton*)
return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
}
return [super methodSignatureForSelector:aSelector];
}
如果没有返回有效签名:Runtime 直接走 doesNotRecognizeSelector: → 抛 NSInvalidArgumentException 异常 → 崩溃。
如果返回了有效签名:Runtime 拿着这个签名,构造一个 NSInvocation 对象,调用 forwardInvocation:。
# 6.2 forwardInvocation实现
- (void)forwardInvocation:(NSInvocation *)anInvocation {
SEL sel = [anInvocation selector];
if ([self.backupHandler respondsToSelector:sel]) {
// 方案 A:原样转发给另一个对象
[anInvocation invokeWithTarget:self.backupHandler];
} else if (sel == @selector(onBuyButtonTapped:)) {
// 方案 B:自己处理——甚至可以修改参数
UIButton *sender;
[anInvocation getArgument:&sender atIndex:2]; // 参数 0 是 self,1 是 _cmd
NSLog(@"按钮被点了: %@", sender);
// 设置返回值
// [anInvocation setReturnValue:...];
} else {
[super forwardInvocation:anInvocation];
}
}
与 forwardingTargetForSelector: 的区别:
| forwardingTargetForSelector | forwardInvocation | |
|---|---|---|
| 能否修改参数 | ❌ | ✅(通过 getArgument: / setArgument:) |
| 能否修改返回值 | ❌ | ✅(通过 setReturnValue:) |
| 能否多个目标 | ❌ 只能一个 | ✅ 可以发给多个对象 |
| 能否改 selector | ❌ | ✅(可以构造新的 invocation) |
| 性能开销 | 几乎为 0 | 构造 NSInvocation(堆分配 + 装箱参数) |
# 6.3 NSInvocation本质剖析
NSInvocation 是 ObjC 中把"一次方法调用"封装成一个对象的机制——你可以保存它、修改它的参数、延迟调用它。
// 手动构造一个 invocation
SEL sel = @selector(doSomethingWith:count:);
NSMethodSignature *sig = [SomeClass instanceMethodSignatureForSelector:sel];
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
SomeClass *obj = [SomeClass new];
NSString *str = @"hello";
NSInteger cnt = 42;
[inv setTarget:obj];
[inv setSelector:sel];
[inv setArgument:&str atIndex:2]; // index 0 = self, 1 = _cmd
[inv setArgument:&cnt atIndex:3];
[inv invoke]; // 真正执行
NSInvocation 的参数传值陷阱:
// ❌ 错误:局部变量 str 出作用域后, invocation 内的参数变成野指针
- (NSInvocation *)buildInvocation {
NSString *str = @"hello";
NSInvocation *inv = ...;
[inv setArgument:&str atIndex:2];
return inv; // str 已释放,inv 持有野指针!
}
// ✅ 正确:invocation 默认只持有 target 和参数指针(不 retain 参数对象)
// 需要调用 retainArguments 让它持有参数
- (NSInvocation *)buildInvocation {
NSString *str = @"hello";
NSInvocation *inv = ...;
[inv setArgument:&str atIndex:2];
[inv retainArguments]; // ← 关键:让 invocation 持有参数
return inv;
}
完整转发的性能代价——forwardInvocation: 需要:
- 调用
methodSignatureForSelector:获取签名 - 构造
NSMethodSignature对象 - 根据签名分配栈帧、构造
NSInvocation - 把原始调用的参数从寄存器/栈装箱进
NSInvocation - 才能执行你写的转发逻辑
整条链路走下来大约 100~200 倍于普通方法调用。所以 forwardInvocation: 是保底方案,不是热路径方案。
# 7. NSProxy与代理转发
# 7.1 NSProxy的设计意图
NSProxy 是一个不继承自 NSObject 的根类——它和 NSObject 平级。
NSObject 的类体系: NSProxy 的类体系:
NSObject NSProxy
├── UIResponder ├── NSDistantObject
│ └── UIView └── 你的自定义代理类
├── NSArray
├── NSString
└── ...
为什么专门搞一个根类做代理? 因为 NSObject 实现了几十个方法(isEqual:、hash、description、class、superclass 等),它们不会自动走消息转发——NSObject 里这些方法直接实现了,不会触发 forwardInvocation:。
NSProxy 目标很小(只实现了极少数方法),几乎一切调用都会进入转发。所以它不会 "吃消息"——所有发给代理对象的消息都会被转发到目标。
# 7.2 惰性加载代理模式
经典的 NSProxy 用法:惰性初始化代理——返回一个轻量代理对象,在真正调用时才去初始化重量级对象:
@interface LazyProxy : NSProxy
@property (nonatomic, strong, readonly) id target;
@end
@implementation LazyProxy
- (id)target {
if (!_target) {
// 真正的初始化在这里——直到第一次方法调用才执行
_target = [[ExpensiveObject alloc] init];
}
return _target;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [[self target] methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
[invocation invokeWithTarget:[self target]];
}
@end
// 使用:
LazyProxy *proxy = [LazyProxy alloc]; // 极快,ExpensiveObject 还未创建
[proxy someMethod]; // 此时才触发真正的初始化 + 调用
收益:对于创建昂贵的对象(比如包含 Metal 渲染管线的对象、大图片解码器),这种"代理延迟创建"可以显著提升 App 启动速度——先在启动流程中快速返回 proxy 占位,等用户真正用到该功能时再初始化。
# 7.3 对比NSObject转发
| 对比项 | NSObject 子类做转发 | NSProxy 子类做转发 |
|---|---|---|
| 自动转发的方法 | 只转发"自身没实现"的方法 | 几乎转发所有方法(包括 NSObject 协议的) |
respondsToSelector: | 如果自己没实现,返回 NO(不会转到目标) | 可以重写让结果反映目标的能力 |
hash / isEqual: | 不在转发范畴内(NSObject 已实现) | 会进入转发 |
| 内存开销 | 一个完整的 NSObject(实例变量区 + 很多方法) | 极轻量(基本只有一个 isa + 少量 ivar) |
结论:如果你需要做"透明代理"——比如把对 A 的所有访问都无缝转给 B——用 NSProxy;如果只是"帮 A 补几个缺失的方法",用 NSObject 子类重写转发方法就够了。
# 8. 性能与调试
# 8.1 三步转发的开销梯度
用 Instruments 实测(iPhone 14, A15, arm64),100 万次方法调用:
| 调用方式 | 耗时 | 相当于直接调用的倍数 |
|---|---|---|
| 直接调用(方法存在) | 3.2 ms | 1× |
resolveInstanceMethod: 动态添加后调用 | 8.6 ms | 2.7×(首次触发解析 + 写入缓存) |
forwardingTargetForSelector: | 12.4 ms | 3.9×(换对象重查) |
forwardInvocation: | 410 ms | 128×(构造 NSInvocation + 装箱参数) |
结论:三步转发的开销是指数级上升的——这反过来印证了 2.2 节 "为什么分三步" 的设计哲学:把大部分补救挡在前两道门槛,不让它们付出完整转发的代价。
# 8.2 线上unrecognized selector监控
线上无法附 Xcode 调试,但可以通过 doesNotRecognizeSelector: 的异常来监控"哪里有未实现的方法调用"。然而 ObjC 的异常机制不能简单的用 @try-@catch 拦截——因为 NSObject 内部在抛异常前会走一堆清理逻辑。
更优雅的方案——在 NSObject 的分类里覆盖 forwardingTargetForSelector::
@implementation NSObject (UnrecognizedSelectorGuard)
- (id)swizzled_forwardingTargetForSelector:(SEL)aSelector {
// 1. 先检查:这个 selector 是不是"看起来该实现、但没实现"的
NSString *selName = NSStringFromSelector(aSelector);
// 2. 过滤掉系统私有方法(避免误报)
if ([selName hasPrefix:@"_"] || [selName hasPrefix:@"."]) {
return [self swizzled_forwardingTargetForSelector:aSelector];
}
// 3. 上报到监控平台
[Bugly reportUnrecognizedSelector:selName inClass:NSStringFromClass([self class])];
// 4. 返回一个万能接收者(兜底,防止崩溃)
return [UnrecognizedSelectorGuard sharedInstance];
// 注意:需要同时重写 methodSignatureForSelector: 和 forwardInvocation:
// 让万能接收者能处理任意方法调用
}
@end
⚠️ 线上保护的风险:静默吞掉
unrecognized selector可能掩盖真实 bug——你不再收到崩溃,但业务逻辑在"一个不该走到预期分支"的路径上静默执行。建议只对已知的非关键路径做保护,保留关键路径的崩溃上报。
# 8.3 Method Swizzling本质
Method Swizzling 的原理一句话:交换两个方法的 IMP 指针。
+ (void)load {
Method original = class_getInstanceMethod([self class], @selector(viewWillAppear:));
Method swizzled = class_getInstanceMethod([self class], @selector(xxx_viewWillAppear:));
method_exchangeImplementations(original, swizzled);
}
底层做了什么:
Swizzling 前:
@selector(viewWillAppear:) → IMP_A
@selector(xxx_viewWillAppear:) → IMP_B
Swizzling 后:
@selector(viewWillAppear:) → IMP_B ← 现在调用 viewWillAppear 实际执行 IMP_B
@selector(xxx_viewWillAppear:) → IMP_A
这和消息转发的本质联系:Swizzling 在查找阶段就把 IMP 换了——objc_msgSend 按 SEL → IMP 正常查找,结果直接拿到替换后的 IMP,不经过任何转发门槛。所以 Method Swizzling 没有转发开销。
# 9. 实战应用模式
# 9.1 防崩溃(消息保护)
业界常用 forwardingTargetForSelector: 做全局防崩溃——把所有 unrecognized selector 重定向到一个安全桩:
@interface SafeStub : NSObject
@end
@implementation SafeStub
// 万能方法签名——返回任意签名的空壳
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
// 构造一个返回值 void、参数为 (id, SEL, ...任意) 的签名
return [NSMethodSignature signatureWithObjCTypes:"v@:............"];
}
// 什么都不做,吞掉调用
- (void)forwardInvocation:(NSInvocation *)anInvocation {
// 静默吞掉
}
@end
更安全的做法——在吞掉前先打日志 / 上报:
- (void)forwardInvocation:(NSInvocation *)anInvocation {
NSString *stack = [NSThread callStackSymbols].description;
NSLog(@"⚠️ Unrecognized selector caught: %@, stack: %@",
NSStringFromSelector(anInvocation.selector), stack);
// 上报到 Crash 监控 SDK
}
# 9.2 多继承效果
ObjC 不支持多继承,但可以让对象 A "承载" 多个内部对象,通过 forwardingTargetForSelector: 自动路由——本质上是组合模式 + 消息转发:
@interface MultiDelegate : NSObject
@property (nonatomic, strong) NSMutableArray<id> *delegates;
@end
@implementation MultiDelegate
- (BOOL)respondsToSelector:(SEL)aSelector {
for (id delegate in self.delegates) {
if ([delegate respondsToSelector:aSelector]) return YES;
}
return [super respondsToSelector:aSelector];
}
- (id)forwardingTargetForSelector:(SEL)aSelector {
for (id delegate in self.delegates) {
if ([delegate respondsToSelector:aSelector]) return delegate;
}
return [super forwardingTargetForSelector:aSelector];
}
@end
注意:forwardingTargetForSelector: 只能返回一个对象——多个 delegate 只能顺次匹配第一个;如果需要都收到通知,需要用 forwardInvocation:(把 invocation 依次对每个 delegate 调用 invokeWithTarget:)。
# 9.3 JSPatch热修复原理
JSPatch 的核心原理——用消息转发把 ObjC 方法调用桥接到 JavaScript 引擎:
[obj doSomething:arg]
│
▼
objc_msgSend → 找不到 IMP → resolveInstanceMethod:
│
▼
JSPatch 在此注入——调用 class_addMethod,添加一个统一的 "消息处理函数"
这个函数的 IMP 是 JSPatch 的 JS 执行入口
│
▼
所有未实现的 ObjC 方法调用 → 被包装成 JS 调用 → JavaScriptCore 执行 JS 脚本
│
▼
JS 脚本内部可以调用回 ObjC(通过 JSContext 绑定)
简化版的 JSPatch 核心注入代码:
+ (BOOL)resolveInstanceMethod:(SEL)sel {
// 1. 给所有没实现的方法统一添加一个"中介 IMP"
class_addMethod([self class], sel, (IMP)JPForwardInvocation, "v@:@");
return YES;
}
// 2. 这个中介 IMP 把调用转给 JS 引擎
static void JPForwardInvocation(id slf, SEL sel, ...) {
// 把 sel + 参数包装成 JS 调用
// 在 JavaScriptCore 里执行对应的 JS 函数
}
📌 JSPatch 2017 年被 App Store 审核封禁(因为 "动态下发可执行代码" 违反条款)——但不影响我们理解它的技术原理。
# 9.4 Aspects AOP实现
Aspects (opens new window) 是 iOS AOP(面向切面编程)的经典库——可以在方法执行前、后、或替换它:
[UIViewController aspect_hookSelector:@selector(viewWillAppear:)
withOptions:AspectPositionBefore
usingBlock:^(id<AspectInfo> info) {
NSLog(@"%@ 将要出现", info.instance);
} error:nil];
底层原理(三部曲):
- 把原始 SEL 的 IMP 换成一个统一的 "消息转发入口"(Method Swizzling)
- 把原始 IMP 存下来(保存到
aliasSelector的 IMP 位置) - 在 "消息转发入口" 里,先调用 hook block,再调原始 IMP
原始调用链路:
[vc viewWillAppear:] → IMP_viewWillAppear
Aspects hook 后:
[vc viewWillAppear:]
→ (Swizzled) IMP_aspectsForward
├── 1. 调用 before-hook block
├── 2. 通过 forwardInvocation 调用原始 IMP_viewWillAppear
└── 3. 调用 after-hook block
可以看到,Aspects 把 Method Swizzling(换 IMP)+ 消息转发(forwardInvocation 统一入口)结合起来,用消息转发做了一层 "hook 调度器"。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 onBuyButtonTapped: 崩溃,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
① objc_msgSend 是怎么找到 IMP 的? | 第 3 章:cache 哈希 → 类 method_list → 父类链 → 转发 |
| ② 为什么缓存能让查找接近 0 开销? | 第 3.2:cache_t 是哈希表,SEL 直接做 key,一次哈希命中 |
| ③ isa 链:实例→类→元类到底怎么走? | 第 3.3:实例 isa→类,类 isa→元类,元类 isa→根元类 |
| ④ 找不到方法时几次补救? | 第 4/5/6 章:三次——动态解析 → 快速转发 → 完整转发 |
| ⑤ resolve / forwardingTarget / forwardInvocation 区别? | 第 4/5/6:添加方法 / 换人执行 / 任意控制(修改参数、返回值) |
| ⑥ NSProxy 和 NSObject 转发有何不同? | 第 7 章:NSProxy 几乎全部方法都转发,NSObject 只转发未实现的方法 |
| ⑦ 转发性能代价多大? | 第 8.1:forwardInvocation 比直接调用慢 ~128 倍 |
三种修复方案(按工程实践推荐排序):
方案 A:补上实现(治本)
@implementation OrderViewController
- (void)onBuyButtonTapped:(UIButton *)sender {
// 实现购买逻辑
[self showPaymentPage];
}
@end
代价:需要重新发版。
方案 B:全局防崩溃保护(治标,热修复)
// 在 NSObject 的 Category 中做全局保护
@implementation NSObject (CrashGuard)
+ (void)load {
// Swizzle forwardingTargetForSelector: 到自定义版本
// 让所有 unrecognized selector 不崩溃
}
@end
代价:会掩盖真实 bug,建议集成上报。
方案 C:resolveInstanceMethod 动态注入
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(onBuyButtonTapped:)) {
IMP imp = imp_implementationWithBlock(^(id self, UIButton *sender) {
NSLog(@"onBuyButtonTapped 被动态注入了");
});
class_addMethod([self class], sel, imp, "v@:@");
return YES;
}
return [super resolveInstanceMethod:sel];
}
代价:需要在基类或分类中为每个可能缺失的方法写注入代码;适合已知的特定问题,不适合做通用保护。
# 10.2 一个方法调用的一生
把 [obj onBuyButtonTapped:sender] 这一行的全过程串成一棵知识树:
[obj onBuyButtonTapped:sender]
│
├─ 编译期
│ └─ clang 将方法调用翻译为:
│ objc_msgSend(obj, @selector(onBuyButtonTapped:), sender)
│
├─ 运行时 ─── 阶段一:消息发送
│ ├─ nil 检查:obj == nil? → 是 → 返回 nil,结束
│ ├─ 取 isa → 找类对象
│ ├─ 查 cache:key = @selector(onBuyButtonTapped:)
│ │ ├─ 命中 → 取 IMP → 尾调用跳转 → 执行 ✅
│ │ └─ 未命中 ↓
│ ├─ 查 method_list(类自身)
│ │ ├─ 找到 → 写入 cache + 尾调用 ✅
│ │ └─ 没找到 ↓
│ ├─ superclass 链查找(逐级往上)
│ │ ├─ 找到 → 写入 cache + 尾调用 ✅
│ │ └─ 全没找到 → 进入 _objc_msgSend_uncached
│ │
│ ├─ 运行时 ─── 阶段二:动态方法解析
│ │ ├─ [obj class] resolveInstanceMethod:@selector(onBuyButtonTapped:)
│ │ │ ├─ 返回 YES → Runtime 重新查找 → 找到(class_addMethod 已注入)✅
│ │ │ └─ 返回 NO ↓
│ │ │
│ │ ├─ 运行时 ─── 阶段三:快速转发
│ │ │ ├─ [obj forwardingTargetForSelector:] → 返回 target ≠ nil
│ │ │ │ └─ 重新 objc_msgSend(target, selector, sender) ✅
│ │ │ └─ 返回 nil ↓
│ │ │
│ │ └─ 运行时 ─── 阶段四:完整转发
│ │ ├─ [obj methodSignatureForSelector:] → 获取方法签名
│ │ │ ├─ 返回 nil → doesNotRecognizeSelector: → 💥 CRASH
│ │ │ └─ 返回签名 ↓
│ │ └─ [obj forwardInvocation:invocation]
│ │ ├─ 修改参数 / 改目标 / 多目标 ✅
│ │ └─ 什么都不做 → 静默吞掉 ✅
│ │
│ └─ 最终执行 IMP 函数
│ ├─ 函数内部 self = obj, _cmd = @selector(onBuyButtonTapped:)
│ ├─ 栈帧在 objc_msgSend 调用者的栈上(尾调用,无额外帧)
│ └─ ret 返回 → 调用者继续执行
理解一个方法调用的一生,就是理解 ObjC 全部动态性的根基。
# 10.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:缓存是动态语言的定海神针——"动"在罕见,"快"在常见
ObjC 被贴着 "动态语言" 的标签,但它不慢——cache_t 让 99.9% 的方法调用命中缓存,和 C 函数调用几乎无差别。动态是能力,不是枷锁。同样的设计在 JavaScript 的 JIT(Just-In-Time)编译、Java 的 inline cache 中都能看到——名字不同,本质一样。
哲学 2:渐进式补救——给程序员三次机会,每次代价递增
resolveInstanceMethod: → forwardingTargetForSelector: → forwardInvocation:,三层补救的代价从 2.7× 到 128× 指数增长。这不是设计的缺陷,而是设计的美学——把简单场景的代价压到最低,把复杂能力留给愿意付出代价的人。这正是 "Don't pay for what you don't use" 的体现。
哲学 3:组合优于继承——消息转发是 ObjC 的 "多继承替代品"
ObjC 没有多继承,但 forwardingTargetForSelector: 让你能把任意多个对象的行为组合进一个对象。这是 组合模式 在语言级的最佳实现——比 Java 的 interface default method、C++ 的虚继承都更直接。
哲学 4:fail fast——unrecognized selector 就应崩溃
默认情况下,调了未实现的方法就抛异常——这和 C++ 的 SIGSEGV、Java 的 NoSuchMethodError 一样,是 fail fast 原则的体现。你的代码有问题,系统就立刻告诉你——信号比静默错误珍贵一千倍。只有在明确需要"柔性容错"的场景(如热修复、AOP),才主动 override 转发链路。
# 10.4 消息转发速查表
| 阶段 | 方法 | 参数 | 返回值 | 关键用途 | 性能 |
|---|---|---|---|---|---|
| 消息发送 | objc_msgSend | (id, SEL, ...) | IMP 直接执行 | 正常方法调用 | 1× |
| 动态解析 | +resolveInstanceMethod: | (SEL) | BOOL | @dynamic 属性、Core Data | 2.7× |
| 快速转发 | -forwardingTargetForSelector: | (SEL) | id(备用接收者) | 多继承模拟、消息路由 | 3.9× |
| 完整转发 | -methodSignatureForSelector: | (SEL) | NSMethodSignature* | 为 forwardInvocation 提供签名 | — |
| 完整转发 | -forwardInvocation: | (NSInvocation*) | void | 任意控制(修改参数、多目标、改 selector) | 128× |
| 最终崩溃 | -doesNotRecognizeSelector: | (SEL) | void(抛异常) | — | — |
NSInvocation 参数索引表(必须熟记):
| index | 含义 |
|---|---|
| 0 | self(接收者) |
| 1 | _cmd(SEL,当前方法选择器) |
| 2+ | 方法的第 1 个显式参数、第 2 个… |
动态添加方法的类型编码速查:
// "v@:@"
// v = void 返回值
// @ = 第一个参数:id (self)
// : = 第二个参数:SEL (_cmd)
// @ = 第三个参数:id (第一个显式参数)
// "i@:@@" → 返回 int,参数 id, SEL, id, id
// "d@:i@" → 返回 double,参数 id, SEL, int, id
下一篇:你已经知道方法调用在 Runtime 内部的完整旅程,下一章进入 03.RunLoop运行循环揭秘 (opens new window)——把 "为什么 iOS App 不会退出,为什么主线程能同时处理触摸 + 网络回调" 从 CFRunLoopSource 级别彻底剖开。