Runtime 不是一张 C API 清单。真正需要掌握的是:一条 Objective-C 消息找不到实现时会发生什么,框架怎样插入这条链,以及插错位置为什么会污染继承树、破坏 ABI,甚至与 KVO、DelegateProxy 冲突。
本文直接围绕 RuntimeLab 中可运行的案例展开。每个案例都有独立源码,完整索引见 CASES.md。
先看结论:Runtime 改的是哪一层
这张图足够作为总地图。下面不再枚举 API,而是看五个真实问题。
先做一个最小实验:一条消息到底是什么
先不要背 objc_msgSend。在 Objective-C 中写下:
NSString *text = [receiver greetingWithName:@"Lin"];
从 Runtime 的角度,它至少包含四样东西: DelegateProxy 完整消息转发链
| 角色 | 本例中的值 | 作用 |
|---|---|---|
| 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,而不是只记交换模板。
延伸阅读与引用
下面按“公开语义 -> 可运行实验 -> Hook 风险”分层阅读。引用用于交叉验证机制,不替代当前 SDK、Demo 和测试结果。
- Apple Objective-C Runtime Programming Guide:先确认对象模型、消息发送、动态解析和消息转发的公开语义。
- objc-runtime-CN:辅助阅读 Runtime 源码;它不能视为当前 iOS 私有实现的稳定契约。
- IANRuntimeStudy:可运行的 Runtime API 实验,可与本文 Demo 对照。
- NSHipster: Method Swizzling 与 Mike Ash: Method Replacement:理解交换实现、继承关系和初始化时机。
- Aspects:适合理解基于消息转发的 Hook 及冲突来源;其 README 已明确不建议用于生产。
- RSSwizzle:用于比较继承、并发和重复 Swizzle 的处理方式;它降低常见风险,但不代表绝对安全。
阅读边界:GitHub star 数不证明实现正确;Runtime 内部结构、KVO 动态子类名和私有实现都可能随系统版本变化。生产方案必须补齐并发、继承、多方 Hook、签名一致性和回滚测试。
补充教程:从对象模型到完整消息转发
这一节用于补齐前文的基础学习路径。所有示例都已同步到 RuntimeLab,并通过 XCTest 验证;私有 Runtime 实现只作为辅助模型。
实例、类对象和元类分别保存什么
Objective-C 中至少要区分三层对象:
| 层级 | 典型内容 | 收到什么消息 |
|---|---|---|
| 实例对象 | isa、实例变量 | 实例方法 |
| 类对象 | 实例方法列表、协议、属性和 Ivar 元数据、指向父类的 superclass | 作为实例查找的起点 |
| 元类对象 | 类方法列表、指向父元类的 superclass | 类方法 |
Class 本身也是对象。实例的 isa 指向类对象,类对象的 isa 指向元类。给类发送类方法,本质上是把类对象当作 receiver,从它的元类开始查找。
对应实验:RTLObjectModelExample.m。实验不要依赖某个 SDK 中 isa 位域的私有布局,只比较公开可观察关系:
id object = [[RTLModelSubject alloc] init];
Class cls = object_getClass(object);
Class meta = object_getClass(cls);
NSLog(@"object class=%@", cls);
NSLog(@"class is meta=%@", class_isMetaClass(cls) ? @"YES" : @"NO");
NSLog(@"meta is meta=%@", class_isMetaClass(meta) ? @"YES" : @"NO");
运行时应观察到:实例指向普通 Class,普通 Class 再指向 Meta-Class。不要把 [object class] 和 object_getClass(object) 永远视为同义词;KVO 或单实例动态子类可以重写 -class,但 object_getClass 仍读取真实运行时 Class。
SEL、Method、IMP 不是同一个东西
| 名称 | 回答的问题 | 常见 API |
|---|---|---|
SEL | 方法叫什么 | @selector(...)、NSSelectorFromString |
Method | 某个 Class 的方法描述是什么 | class_getInstanceMethod |
IMP | 当前真正执行的函数地址在哪里 | method_getImplementation |
| 类型编码 | 参数和返回值怎样按 ABI 传递 | method_getTypeEncoding |
同一个 SEL 在父类、子类和不同 Hook 层中可以对应不同 IMP。反过来,同一个 IMP 也可以被多个 selector 复用。因此排查“到底调用了谁”时,不能只打印 selector。
Ivar、Property 和 Associated Object 的边界
Property 是访问器及其声明元数据,Ivar 才是实例内存布局中的字段。自动合成属性经常生成同名下划线 Ivar,但这不是两者永远一一对应的证明。Category 不能给已有类追加 Ivar,通常用 Associated Object 保存扩展状态。
对应实验:RTLIvarPropertyExample.m 和 RTLAssociatedObjectExample.m。关联策略只定义所有权和原子属性,不会让“读取-修改-写回”的业务操作自动线程安全。
第二部分:一条消息怎样找到 IMP
可以把公开可观察流程概括为:
receiver + selector
|
v
从真实 Class 的方法解析结果中查找
|
+-- 找到 IMP --> 按准确 ABI 调用
|
+-- 找不到 --> 动态方法解析
|
+-- 安装成功 --> 重新查找
|
+-- 未处理 --> 快速转发
|
+-- 换接收者成功
|
+-- 未处理 --> 完整转发
|
+-- 准确签名 + NSInvocation
+-- 无签名 --> doesNotRecognizeSelector:
Runtime 内部通常包含方法缓存和继承链查找,但缓存结构、汇编入口以及私有转发函数会随系统和架构变化。_objc_msgForward 可以用于解释 Aspects 风格实验,不应把网上还原的 __forwarding__ 伪代码当成当前 iOS 的公开契约。
类方法动态解析为什么加到元类
实例方法通过 +resolveInstanceMethod: 补到普通 Class;类方法通过 +resolveClassMethod: 处理,但真正承载类方法槽位的是元类:
+ (BOOL)resolveClassMethod:(SEL)selector {
if (selector == @selector(runtimeClassGreeting)) {
Class metaClass = object_getClass(self);
return class_addMethod(metaClass,
selector,
(IMP)RTLRuntimeClassGreeting,
"@@:");
}
return [super resolveClassMethod:selector];
}
这里仍然必须验证 class_addMethod 的返回值。返回 YES 只表示方法安装成功,不代表随便填写的类型编码会变得安全。
完整转发怎样读取参数、修改参数和返回值
NSInvocation 的索引 0 和 1 分别保留 self 与 _cmd,第一个显式参数从索引 2 开始。下面以对象参数和对象返回值为例:
- (void)forwardInvocation:(NSInvocation *)invocation {
if (invocation.selector != @selector(greetingWithName:)) {
[super forwardInvocation:invocation];
return;
}
__unsafe_unretained NSString *name = nil;
[invocation getArgument:&name atIndex:2];
NSString *trimmed = [name stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
NSString *normalized = trimmed.length > 0 ? trimmed : @"Anonymous";
__unsafe_unretained NSString *argument = normalized;
[invocation setArgument:&argument atIndex:2];
[invocation invokeWithTarget:self.target];
__unsafe_unretained NSString *result = nil;
[invocation getReturnValue:&result];
NSString *decorated = [result stringByAppendingString:@" [proxied]"];
__unsafe_unretained NSString *returnValue = decorated;
[invocation setReturnValue:&returnValue];
}
这段代码只适用于签名明确的对象参数和对象返回值。标量、结构体、浮点值和 block 都必须按真实类型声明临时变量,不能把所有内存都解释成 id。
多目标转发不是把同一个 invocation 随便调用多次
通知型、无返回值的消息可以按明确顺序调用多个目标;存在返回值或 completion 时必须先定义聚合语义:谁提供最终返回值、completion 是否只能完成一次、某个节点拒绝后是否短路。没有这些契约,“多转发”只是顺序不稳定的副作用集合。
for (id target in self.targets) {
if ([target respondsToSelector:invocation.selector]) {
[invocation invokeWithTarget:target];
}
}
用于 DelegateProxy 链时,更稳妥的模型是每个节点明确选择“处理后继续”或“处理后终止”,而不是一个中心对象无条件广播。
一个常见但危险的“万能防崩溃代理”
不要为所有未知 selector 统一伪造:
// 错误示例:只描述“返回对象、没有显式参数”的方法。
return [NSMethodSignature signatureWithObjCTypes:"@@:"];
如果真实调用方期待 NSInteger、CGRect、浮点数或额外参数,NSInvocation 会按错误布局读取寄存器和栈,属于 ABI 不匹配。吞掉 unrecognized selector 还会把拼写错误和协议缺失从第一现场变成更晚出现的数据错误。
安全处理必须满足:只识别白名单 selector;从协议、真实下游或原始 Method 取得准确签名;为每个返回类型提供确定语义;未知 selector 继续交给 super。
消息转发的性能应该怎样验证
性能顺序通常是“已缓存的正常派发 < 快速转发 < 完整转发”,但不要写死网上某个纳秒数字。优化级别、架构、方法签名和 Instruments 本身都会改变结果。有效实验至少要:
- 使用 Release 配置并做预热,排除首次解析和类初始化。
- 分别测量正常 IMP、快速转发和完整转发的大量重复调用。
- 防止编译器消除结果,并报告设备、系统、架构和样本数量。
- 生产设计先减少高频转发,再讨论缓存方法签名;缓存错误签名只会更快地制造错误。