Objective-C Runtime:从消息派发到安全 Hook

26 阅读11分钟

Runtime 不是一张 C API 清单。真正需要掌握的是:一条 Objective-C 消息找不到实现时会发生什么,框架怎样插入这条链,以及插错位置为什么会污染继承树、破坏 ABI,甚至与 KVO、DelegateProxy 冲突。

本文直接围绕 RuntimeLab 中可运行的案例展开。每个案例都有独立源码,完整索引见 CASES.md

先看结论:Runtime 改的是哪一层

Objective-C Runtime 知识总纲

这张图足够作为总地图。下面不再枚举 API,而是看五个真实问题。

先做一个最小实验:一条消息到底是什么

先不要背 objc_msgSend。在 Objective-C 中写下:

NSString *text = [receiver greetingWithName:@"Lin"];

从 Runtime 的角度,它至少包含四样东西:

角色本例中的值作用
receiverreceiver决定从哪条类继承链开始查找
selectorgreetingWithName:只是方法名,不包含实现地址
arguments@"Lin"与隐藏参数 self_cmd 一起按 ABI 传递
return typeNSString *决定调用方怎样读取返回寄存器或内存

编译器会根据静态类型选择合适的消息发送入口。为了理解机制,可以把它近似写成下面的函数指针调用,但不要在业务代码中随意手写 objc_msgSend

typedef NSString *(*GreetingSend)(id, SEL, NSString *);

GreetingSend send = (GreetingSend)objc_msgSend;
NSString *text = send(receiver,
                      @selector(greetingWithName:),
                      @"Lin");

这里的强制转换不是装饰。IMP 本质上是函数地址,但参数和返回值仍受平台 ABI 约束。把返回结构体的方法按对象返回值调用,或者漏掉浮点参数,即使 selector 和地址都“找对了”,寄存器解释也会错。

跟着调试器看一次派发

  1. 在将要发送消息的源码行打断点。
  2. 停住后执行 po receiver,确认实际对象。
  3. 执行 po object_getClass(receiver),查看当前真实 Class;KVO 场景不要只看 [receiver class]
  4. 执行 p (IMP)class_getMethodImplementation(object_getClass(receiver), @selector(greetingWithName:)),取得当前查找到的 IMP。
  5. 使用 image lookup --address <IMP地址>,确认实现属于哪个二进制和符号。

这组操作回答的是“当前这一刻会调用谁”,比只打印类名更接近实际派发结果。缓存属于 Runtime 私有实现细节,公开 API 能观察最终解析结果,但不应假设某个版本的缓存数据结构永远不变。

super 不是另一个接收者

[super value] 中的接收者仍然是当前 self。变化的是查找起点:编译器让 Runtime 从当前类的父类开始寻找 value。因此父类实现里的 self 仍指向原来的子类对象,动态派发也不会变成“给父类对象发消息”。

可以用 RuntimeLab 的 super 查找案例验证:在父类实现中同时打印 selfobject_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 本身不实现 fastMessagefullMessage,真实实现位于 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 需要完整转发

DelegateProxy 完整消息转发链

Proxy 无法提前知道所有可选 delegate 方法,因此必须从真实下游取得方法签名。伪造统一签名会丢失参数和返回值布局。快速转发不能执行 before/after,也不能组成多层责任链。

三个阶段怎样选择

阶段能做什么做不了什么适合场景
动态解析给当前类补一个真实 IMP不能逐次选择不同接收者延迟安装、协议兼容
快速转发把原消息交给另一个对象拿不到 NSInvocation,难以做 before/after简单备用对象
完整转发读取参数、改目标、改返回值、组成代理链成本更高,签名错误会破坏 ABIDelegateProxy、通用转发、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 没有覆盖 valueclass_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 风格转发 AOP 调用链

风险不是“Aspects 老”,而是 forwardInvocation: 是类级共享入口。原类、父类、KVO、DelegateProxy 或另一套 AOP 框架都可能需要它。alias、类型编码、继承层级和动态子类任一处处理错误,都会破坏消息链。

RSSwizzle 风格通常围绕明确 selector 做 IMP 替换,不占用完整转发通道,因此冲突面相对小。但“相对安全”不等于绝对安全:多方 Hook 同一 selector 仍受安装顺序影响;任意一层漏调 original、调用两次或使用错误函数签名都会改变行为。

把两个方案放进同一个故障场景

假设埋点框架要统计 submit,业务类自身又用完整转发兜底未知消息:

  1. Aspects 风格把 submit 的 IMP 改为 _objc_msgForward
  2. Runtime 进入 methodSignatureForSelector:forwardInvocation:
  3. AOP 框架需要保存并调用业务类原有的转发实现。
  4. 如果另一个 DelegateProxy 或测试 Mock 也占用转发通道,三方必须共同维护 selector、alias 和 invocation 的调用顺序。

直接 IMP Hook 只改 submit 对应槽位,不必接管所有完整转发消息,因此冲突面更小。但它依然必须用准确的 C 函数签名调用 original,并接受安装顺序决定责任链顺序。

判断一个 Hook 是否能上线,不要问“它是不是 Runtime”,而要回答:修改的是单个方法槽位还是共享转发入口;作用于类还是实例;能否恢复;谁拥有 original;多方同时安装时顺序是否可测试。

生产选择:先看公开扩展点

需求首选机制原因
组件公开了 delegateDelegateProxy / 组合代理不修改业务类方法槽位
监听单个实例属性变化KVO 或显式状态绑定作用域比全局 Swizzle 小
横切稳定且明确的 selector继承安全 IMP Hook冲突面可测试
需要统一处理任意方法签名完整转发 AOP能力强,但必须接受共享通道风险
业务流程本身可修改显式调用、装饰器或埋点最容易理解和回归

上线前至少验证:父类和兄弟子类不受污染、重复及并发安装幂等、original 调用次数、多个 Hook 的顺序、返回值和 completion 只完成一次、KVO 前后组合,以及对象释放后的恢复行为。

运行工程

可以单独打开 RuntimeLab.xcodeproj,也可以打开外层 nodeOCAndSwift.xcworkspace。RuntimeLab 以 Development Pod 接入,Pods 中修改的就是 03-runtime 本地源码。

参考资料