Runtime 不是一张 C API 清单。真正需要掌握的是:一条 Objective-C 消息找不到实现时会发生什么,框架怎样插入这条链,以及插错位置为什么会污染继承树、破坏 ABI,甚至与 KVO、DelegateProxy 冲突。
本文直接围绕 RuntimeLab 中可运行的案例展开。每个案例都有独立源码,完整索引见 CASES.md。
先看结论:Runtime 改的是哪一层
这张图足够作为总地图。下面不再枚举 API,而是看五个真实问题。
先做一个最小实验:一条消息到底是什么
先不要背 objc_msgSend。在 Objective-C 中写下:
NSString *text = [receiver greetingWithName:@"Lin"];
从 Runtime 的角度,它至少包含四样东西:
| 角色 | 本例中的值 | 作用 |
|---|---|---|
| receiver | receiver | 决定从哪条类继承链开始查找 |
| selector | greetingWithName: | 只是方法名,不包含实现地址 |
| arguments | @"Lin" | 与隐藏参数 self、_cmd 一起按 ABI 传递 |
| return type | NSString * | 决定调用方怎样读取返回寄存器或内存 |
编译器会根据静态类型选择合适的消息发送入口。为了理解机制,可以把它近似写成下面的函数指针调用,但不要在业务代码中随意手写 objc_msgSend:
typedef NSString *(*GreetingSend)(id, SEL, NSString *);
GreetingSend send = (GreetingSend)objc_msgSend;
NSString *text = send(receiver,
@selector(greetingWithName:),
@"Lin");
这里的强制转换不是装饰。IMP 本质上是函数地址,但参数和返回值仍受平台 ABI 约束。把返回结构体的方法按对象返回值调用,或者漏掉浮点参数,即使 selector 和地址都“找对了”,寄存器解释也会错。
跟着调试器看一次派发
- 在将要发送消息的源码行打断点。
- 停住后执行
po receiver,确认实际对象。 - 执行
po object_getClass(receiver),查看当前真实 Class;KVO 场景不要只看[receiver class]。 - 执行
p (IMP)class_getMethodImplementation(object_getClass(receiver), @selector(greetingWithName:)),取得当前查找到的 IMP。 - 使用
image lookup --address <IMP地址>,确认实现属于哪个二进制和符号。
这组操作回答的是“当前这一刻会调用谁”,比只打印类名更接近实际派发结果。缓存属于 Runtime 私有实现细节,公开 API 能观察最终解析结果,但不应假设某个版本的缓存数据结构永远不变。
super 不是另一个接收者
[super value] 中的接收者仍然是当前 self。变化的是查找起点:编译器让 Runtime 从当前类的父类开始寻找 value。因此父类实现里的 self 仍指向原来的子类对象,动态派发也不会变成“给父类对象发消息”。
可以用 RuntimeLab 的 super 查找案例验证:在父类实现中同时打印 self、object_getClass(self) 和 _cmd。如果把 super 理解成一个父类实例,后续分析 Swizzle、KVO 动态子类和多层 Hook 时几乎一定会出错。
案例一:线上出现 unrecognized selector,能否动态补救
现象
对象收到 runtimeGreeting,类中没有声明或实现该方法。普通情况下消息查找最终会崩溃:
-[RTLDynamicReceiver runtimeGreeting]: unrecognized selector sent to instance
实验
对应文件:RTLDynamicMethodExample.m。
static NSString *RTLDynamicallyResolvedGreeting(id self, SEL _cmd) {
return [NSString stringWithFormat:@"动态 IMP 已处理 %@",
NSStringFromSelector(_cmd)];
}
+ (BOOL)resolveInstanceMethod:(SEL)selector {
if (selector == NSSelectorFromString(@"runtimeGreeting")) {
return class_addMethod(self,
selector,
(IMP)RTLDynamicallyResolvedGreeting,
"@@:");
}
return [super resolveInstanceMethod:selector];
}
运行输出:
动态 IMP 已处理 runtimeGreeting
解析后 respondsToSelector:YES
证据与结论
resolveInstanceMethod: 只在正常查找失败后获得一次补充 IMP 的机会。真正危险的不是 class_addMethod,而是最后的类型编码。"@@:" 表示返回 Objective-C 对象,并接收隐藏参数 self 与 _cmd。如果实际方法返回结构体或接收额外参数,却伪造成 v@:,调用约定就被破坏;一次没崩不代表安全。
这适合插件注册、兼容旧协议等明确边界,不适合把拼写错误静默吞掉。无法识别的 selector 应继续交给 super。
自己跟做时要观察什么
第一次调用前,respondsToSelector: 可以是 NO;触发动态解析并成功添加方法后,再次查询会变成 YES。第二次调用通常不会再次进入 resolveInstanceMethod:,因为方法已经进入类的方法表。
建议给 +resolveInstanceMethod: 加断点并连续调用两次。需要记录的不是“没崩溃”,而是:解析回调次数、安装后的 IMP 地址、返回值,以及错误 selector 是否仍然正确崩溃。最后一项能证明代码没有把所有拼写错误都吞掉。
案例二:快速转发和完整转发到底差在哪
场景
RTLForwardingReceiver 本身不实现 fastMessage 和 fullMessage,真实实现位于 RTLForwardTarget。
对应文件:RTLForwardingExample.m。
快速转发直接换接收者:
- (id)forwardingTargetForSelector:(SEL)selector {
if (selector == @selector(fastMessage)) {
return self.target;
}
return [super forwardingTargetForSelector:selector];
}
完整转发必须先给出准确签名,再处理同一个 invocation:
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
if (selector == @selector(fullMessage)) {
return [self.target methodSignatureForSelector:selector];
}
return [super methodSignatureForSelector:selector];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
if ([self.target respondsToSelector:invocation.selector]) {
[invocation invokeWithTarget:self.target];
return;
}
[super forwardInvocation:invocation];
}
运行输出:
快速转发:消息由备用对象直接接收
完整转发:NSInvocation 保留了参数和返回值
为什么 DelegateProxy 需要完整转发
Proxy 无法提前知道所有可选 delegate 方法,因此必须从真实下游取得方法签名。伪造统一签名会丢失参数和返回值布局。快速转发不能执行 before/after,也不能组成多层责任链。
三个阶段怎样选择
| 阶段 | 能做什么 | 做不了什么 | 适合场景 |
|---|---|---|---|
| 动态解析 | 给当前类补一个真实 IMP | 不能逐次选择不同接收者 | 延迟安装、协议兼容 |
| 快速转发 | 把原消息交给另一个对象 | 拿不到 NSInvocation,难以做 before/after | 简单备用对象 |
| 完整转发 | 读取参数、改目标、改返回值、组成代理链 | 成本更高,签名错误会破坏 ABI | DelegateProxy、通用转发、AOP |
完整转发的调试顺序应是:先确认 methodSignatureForSelector: 返回非空且准确,再确认 forwardInvocation: 收到同一个 selector,最后确认 invocation 只调用一次。很多“代理偶现不回调”并不是代理链断了,而是签名返回了 nil,消息在进入 forwardInvocation: 前就终止了。
案例三:KVO 为什么动态创建子类
要解决的问题
KVO 需要只拦截某一个实例的 setter。如果全局 Swizzle 原类,所有实例都会被改变。因此系统为被观察对象创建动态子类,再修改这个实例的 isa。
对应文件:RTLKVOExample.m。
实验同时记录四个证据:
Class before = object_getClass(subject);
[subject addObserver:recorder forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
Class during = object_getClass(subject);
Class reported = [subject class];
subject.name = @"Runtime";
[subject removeObserver:recorder forKeyPath:@"name"];
Class after = object_getClass(subject);
输出:
观察中 isa 改变:YES
-class 伪装为原类:YES
name=Runtime
移除后恢复:YES
object_getClass(subject) 读取真实运行时 Class;[subject class] 仍是一条可重写消息。KVO 重写 -class 是为了隐藏实现细节。实验不依赖 NSKVONotifying_ 私有命名,因为私有类名不是公开契约。
DelegateProxy 不需要模仿 KVO:它已经拥有公开 delegate 槽位,拦截的是发给 delegate 的消息,不是业务对象自身的 setter。
为什么 KVO 还要重写 -class
动态子类解决“只影响一个实例”,重写 -class 解决“尽量维持外部类型认知”。观察期间:
NSLog(@"reported=%@", [subject class]);
NSLog(@"runtime=%@", object_getClass(subject));
两者可能不同。前者是一条正常消息,可以被动态子类覆盖;后者直接读取 isa 指向的 Class。调试 KVO、单实例 Hook 或 Aspects 实例 Hook 时,应同时记录这两个值,否则很容易把动态子类误判成对象类型损坏。
移除观察后还要验证 object_getClass(subject) 是否恢复,而不是只验证回调停止。恢复失败意味着对象可能继续使用临时子类,后续 setter、释放和其他 Hook 都会落在错误的类上。
案例四:一个错误 Swizzle 为什么污染父类
错误实现
Method method = class_getInstanceMethod(Child.class, @selector(value));
method_setImplementation(method, replacement);
如果 Child 没有覆盖 value,class_getInstanceMethod 返回的 Method 可能实际属于父类。直接替换后,父类实例和兄弟子类都会改变。
RuntimeLab 的修复
对应文件:RTLSafeSwizzle.m。
Method resolvedMethod = class_getInstanceMethod(cls, selector);
IMP previous = method_getImplementation(resolvedMethod);
const char *types = method_getTypeEncoding(resolvedMethod);
IMP replacement = factory(previous);
if (!class_addMethod(cls, selector, previous, types)) {
Method localMethod = class_getInstanceMethod(cls, selector);
method_setImplementation(localMethod, replacement);
} else {
class_replaceMethod(cls, selector, replacement, types);
}
输出:
子类:base->child-hook
父类未污染:base
首次安装=YES,重复安装=NO
关键不是“交换两个方法名”,而是先把继承来的实现物化到目标类,再只替换目标类自己的槽位。工程还用锁和唯一键保证同一个 Hook 只安装一次。
为什么 original IMP 必须先捕获
replacement 发布到 Class 后,其他线程随时可能进入。如果先替换,再把 original 写入一个全局变量,就存在 replacement 已执行但 original 仍为空的窗口。RuntimeLab 使用 factory:在安装锁内先取得 previous IMP,创建已经捕获它的 replacement,最后一次性发布。
锁只保护安装过程,不会自动让被 Hook 方法里的业务状态线程安全。
为什么经典 method_exchangeImplementations 教程不够
许多教程只演示“两个本类方法互换”。真实项目还会遇到:目标 selector 继承自父类、其他框架已经 Hook、运行中并发安装、replacement 依赖 _cmd、类和子类使用同一个唯一键等情况。
Defagos 的 Swizzling 文章和 RSSwizzle README 都强调了继承问题:不能简单地在安装时缓存一个父类 IMP,然后假设它永远是正确的 original。父类可能稍后也被 Hook。安全封装必须明确自己的语义:original 是“安装当时的下游”,还是“调用时动态解析的父类实现”。两种都能成立,但混用就会造成跳过某层 Hook 或递归。
RuntimeLab 当前的教学封装选择“安装时捕获当前下游并形成责任链”,因此测试必须固定安装顺序并断言:每层执行一次、父类与兄弟类不受污染、重复安装返回失败或保持幂等。
案例五:Aspects 风格为什么冲突面更大
对应文件:RTLForwardingAOPExample.m。
实验先保存原 IMP 到 alias,再把原 selector 改成 _objc_msgForward:
class_addMethod(cls,
@selector(rtl_alias_submit),
method_getImplementation(method),
method_getTypeEncoding(method));
class_replaceMethod(cls,
@selector(submit),
RTLMessageForwardIMP(),
method_getTypeEncoding(method));
forwardInvocation: 中执行横切逻辑:
before -> original -> after
返回值:done
风险不是“Aspects 老”,而是 forwardInvocation: 是类级共享入口。原类、父类、KVO、DelegateProxy 或另一套 AOP 框架都可能需要它。alias、类型编码、继承层级和动态子类任一处处理错误,都会破坏消息链。
RSSwizzle 风格通常围绕明确 selector 做 IMP 替换,不占用完整转发通道,因此冲突面相对小。但“相对安全”不等于绝对安全:多方 Hook 同一 selector 仍受安装顺序影响;任意一层漏调 original、调用两次或使用错误函数签名都会改变行为。
把两个方案放进同一个故障场景
假设埋点框架要统计 submit,业务类自身又用完整转发兜底未知消息:
- Aspects 风格把
submit的 IMP 改为_objc_msgForward。 - Runtime 进入
methodSignatureForSelector:与forwardInvocation:。 - AOP 框架需要保存并调用业务类原有的转发实现。
- 如果另一个 DelegateProxy 或测试 Mock 也占用转发通道,三方必须共同维护 selector、alias 和 invocation 的调用顺序。
直接 IMP Hook 只改 submit 对应槽位,不必接管所有完整转发消息,因此冲突面更小。但它依然必须用准确的 C 函数签名调用 original,并接受安装顺序决定责任链顺序。
判断一个 Hook 是否能上线,不要问“它是不是 Runtime”,而要回答:修改的是单个方法槽位还是共享转发入口;作用于类还是实例;能否恢复;谁拥有 original;多方同时安装时顺序是否可测试。
生产选择:先看公开扩展点
| 需求 | 首选机制 | 原因 |
|---|---|---|
| 组件公开了 delegate | DelegateProxy / 组合代理 | 不修改业务类方法槽位 |
| 监听单个实例属性变化 | KVO 或显式状态绑定 | 作用域比全局 Swizzle 小 |
| 横切稳定且明确的 selector | 继承安全 IMP Hook | 冲突面可测试 |
| 需要统一处理任意方法签名 | 完整转发 AOP | 能力强,但必须接受共享通道风险 |
| 业务流程本身可修改 | 显式调用、装饰器或埋点 | 最容易理解和回归 |
上线前至少验证:父类和兄弟子类不受污染、重复及并发安装幂等、original 调用次数、多个 Hook 的顺序、返回值和 completion 只完成一次、KVO 前后组合,以及对象释放后的恢复行为。
运行工程
可以单独打开 RuntimeLab.xcodeproj,也可以打开外层 nodeOCAndSwift.xcworkspace。RuntimeLab 以 Development Pod 接入,Pods 中修改的就是 03-runtime 本地源码。
参考资料
- Apple:Objective-C Runtime Programming Guide:校准消息、动态解析、转发和类型编码的公开语义。
- NSHipster:Method Swizzling:适合理解 SEL、Method、IMP 和基础 Swizzle,但生产代码仍需补齐继承与多方 Hook 测试。
- Samuel Défago:Yet another article about method swizzling:重点讨论继承方法和类方法 Swizzle 的隐藏问题。
- steipete/Aspects:README 明确说明动态子类、
_objc_msgForward、性能和生产风险。 - rabovik/RSSwizzle:重点参考 original IMP provider、继承处理、唯一键模式与线程安全设计。
- Mike Ash:Method Replacement for Fun and Profit:从 IMP 替换角度理解 Hook,而不是只记交换模板。