Objective-C Runtime:从消息派发到安全 Hook
这套笔记不把 Runtime 当成一组需要背诵的 C API,而是沿着"对象怎样表示、消息怎样找到实现、找不到后怎样转发、框架怎样改变这条链"逐层建立心智模型。
目录
- Runtime 是什么
- 对象、类对象与元类
- SEL、Method、IMP、Ivar 与 Property
- 消息发送与
super - 三阶段消息转发
- 动态类和动态子类
- Method Swizzling
- KVO
- Associated Object
- Runtime AOP
- Aspects 为什么风险较高
- RSSwizzle 为什么相对安全
- 生产边界与测试策略
- 与 DelegateProxy 的关系
知识地图
总图只承担导航,不把每个 API 塞进去:
案例索引、操作步骤和独立文件见工程内 03-runtime/CASES.md。
一、Runtime 是什么
Objective-C 编译器不会把普通方法调用完全固定成一个函数地址。[receiver doWork] 会被编译为消息发送,运行时根据 receiver 当前的 Class、selector 和继承关系决定执行哪个 IMP。这使分类、动态解析、消息转发、KVO 和 Method Swizzling 成为可能。
学习时要区分三层边界:Objective-C 语言语义、<objc/runtime.h> 等公开接口、系统内部实现。方法缓存的存在可以帮助理解性能,但私有缓存结构不是业务代码可依赖的契约。
二、对象、类对象与元类
- 实例的 isa 指向类对象,类对象保存实例方法。
- 类对象自身也是对象,它的 isa 指向元类;元类保存类方法。
superclass形成继承查找链;根元类的 isa 最终闭合到根元类。- 现代 Runtime 的 isa 可能包含压缩和标记位,不应该把它当成普通裸指针直接位运算。
object_getClass(object) 读取真实运行时 Class;[object class] 是一条可被重写的消息。KVO 正是利用二者差异,在改变 isa 后通过重写 -class 隐藏动态子类。
对应实验还会分别枚举 Ivar 与 Property。只读计算属性可以有 Property 元数据和 getter,却没有同名实例存储;自动合成属性则通常产生带下划线的 Ivar。两份列表不能互相替代。
三、SEL、Method、IMP、Ivar 与 Property
- SEL 是方法名字的唯一标识,不包含具体实现。
- Method 是 Runtime 方法记录,可查询 selector、IMP 和类型编码。
- IMP 是可调用函数地址,至少接收
self和_cmd两个隐式参数。 - Ivar 描述真实实例布局;Property 主要描述声明元数据和访问器约定,不等于一定存在同名 Ivar。
调用 IMP 时函数指针签名必须与方法类型编码一致。参数数量、结构体返回规则或浮点 ABI 不一致都属于未定义行为,不能因为一次运行没崩就认为安全。
四、消息发送与 super
概念上的查找流程:
super 不是另一个对象。它仍把 self 作为 receiver,只把方法查找起点改为编译期父类。因此父类实现内部看到的 self 仍是原来的子类实例。
五、三阶段消息转发
未找到 IMP 后依次有三次机会:
resolveInstanceMethod:/resolveClassMethod:动态补充实现。forwardingTargetForSelector:快速换一个接收者。methodSignatureForSelector:与forwardInvocation:进入完整转发。
完整转发依赖正确的 NSMethodSignature。统一伪造 v@: 会丢失参数和返回值布局。DelegateProxy 能透明传递未知代理方法,也正是因为它从真实下游获取签名并转发同一个 invocation。
六、动态类和动态子类
objc_allocateClassPair 创建类后,可以在注册前添加 Ivar 和方法;调用 objc_registerClassPair 后类布局固定。单实例 Hook 则创建目标类的动态子类,再用 object_setClass 只改变一个实例。
这类方案比全局 Swizzle 作用域小,但仍需处理:类名冲突、重复安装、原 Class 保存、恢复时机,以及对象已经被 KVO 或其他框架动态子类化的情况。不能看到动态类就强行拆链。
七、Method Swizzling
最常见错误是对子类调用 class_getInstanceMethod 后直接 method_exchangeImplementations。如果子类没有覆盖该方法,取到的 Method 实际属于父类,交换会污染所有父类实例和兄弟子类。
安全的基本步骤是:
- 找到当前继承链可见的 Method、IMP 和类型编码。
- 用
class_addMethod把该实现物化到目标子类。 - 只替换目标类自己的方法槽位。
- 用唯一安装键和锁保证同一 Hook 不会重复安装。
- 在安装锁内先取得上一层 IMP,由 factory 创建已经捕获它的 replacement,再把新 IMP 发布到 Class。
如果采用"先替换方法,再用输出参数把 original IMP 写到外部全局变量"的接口,并发线程可能在两步之间进入 replacement,此时 original 仍为空,会直接崩溃。factory/provider 的价值不只是 API 好看,而是消除这个发布窗口。replacement 调用 original 时仍必须使用完全匹配 ABI 的函数指针。
这里的锁只保护"安装过程",不自动保证被 Hook 方法内部的业务状态线程安全。
并发实验让 32 个线程使用同一 Class、selector 和唯一键竞争安装,只有一个调用能够成功。再次运行成功数为 0 是幂等结果,而不是失败;Method Hook 是进程级 Class 修改,不能为了刷新 UI 任意卸载和重装。
八、KVO
KVO 通常为被观察对象创建动态子类,改变实例 isa,在子类 setter 中发送变化通知,并重写 -class 报告原类。实验只验证公开可观察结果,不依赖 NSKVONotifying_ 私有命名规则。
当 automaticallyNotifiesObserversForKey: 对某个 key 返回 NO,setter 必须用成对的 willChangeValueForKey: / didChangeValueForKey: 包围真正赋值。派生属性可以用依赖键声明传播变化;这和 KVO 自动生成动态子类是两个互补机制。
动态子类不是 DelegateProxy 的必要条件。KVO 需要拦截某个实例自身的 setter;DelegateProxy 已经占据组件公开的 delegate 槽位,可以通过消息转发完成职责链,两者解决的问题不同。
九、Associated Object
关联对象让分类或 Hook 框架给现有对象附加状态。常用策略对应 assign、retain、copy 及 atomic/nonatomic 变体。它管理的是关联值所有权,不是业务级线程安全:一次 get 后修改可变集合仍需要自己的同步方案。
assign 不保留对象,也不是自动清零 weak。若被关联对象先释放,继续读取可能得到悬空引用,因此生产代码通常不把 assign 用作对象弱引用替代品。
十、Runtime AOP 的两条路线
IMP 路线
把明确 selector 的 IMP 换成 replacement,replacement 调用安装时捕获的 previous IMP。多个 Hook 可以形成显式嵌套链,但调用顺序仍取决于安装顺序。
消息转发路线
先把原 IMP 保存到 alias selector,再把目标 selector 指向 _objc_msgForward,最后在 forwardInvocation: 中执行 before/instead/after 并调用 alias。
十一、Aspects 为什么风险较高
先纠正一个表述:不是"使用 Aspects 一定不安全",而是它的 Hook 模型冲突面较大。
Aspects 类方案为了统一处理任意参数和返回值,会把 selector 导向消息转发,并维护 alias、Hook 容器、动态子类及 forwardInvocation:。风险集中在:
forwardInvocation:是一个类级共享入口,原类、父类、KVO、代理框架或另一个 AOP 库都可能需要它。- alias selector 和转发 IMP 必须保持精确 ABI,任意一环错误都会破坏参数或返回值。
- 继承方法与单实例动态子类同时存在时,Hook 究竟安装在哪一层会影响整个继承树。
- 多方覆盖转发入口时,很难像普通 IMP 链一样可靠捕获"上一层"并继续调用。
- selector 黑名单和系统内部调用关系会随系统版本变化;Hook 高频或关键 UIKit 生命周期方法的风险尤其高。
因此风险来自共享转发通道和复杂状态组合,不应简化为"因为项目老"或"因为用了 Runtime"。
十二、RSSwizzle 为什么相对安全
RSSwizzle 类型的封装主要围绕明确 selector 做 IMP 替换,并把容易写错的部分集中处理:
- 正确区分目标类自有方法和继承方法。
- 用唯一键避免相同 Hook 重复安装。
- 串行化安装,避免两条线程同时读写方法槽位。
- 通过 original IMP provider/factory 在发布 replacement 前捕获安装时的上一层实现,避免并发调用读到未初始化 original。
- 尽量不占用
forwardInvocation:,因此与消息转发/KVO 的交叉面更小。
它只是相对安全,不是绝对安全。不同框架 Hook 同一 selector 仍有安装顺序依赖;任何一层漏调 original、调用两次、使用错误类型签名,都会改变行为。Apple 私有实现变化也不会因为套了一层库就消失。
十三、生产边界与测试策略
上线前至少验证:父类和兄弟子类不受污染、重复安装幂等、并发安装只有一次成功、原实现调用次数、多个 Hook 的确定顺序、返回值和 completion handler 只完成一次、KVO 前后组合、对象释放和动态子类恢复。
能通过组合、公开 delegate、通知或明确业务埋点解决的问题,优先不用全局 Hook。Runtime Hook 适合边界清晰、selector 稳定、测试充分且有明确收益的横切接入。
十四、与 DelegateProxy 的关系
- Swizzle 改变某个类的方法实现,适合没有公开扩展点但必须横切普通消息的场景。
- DelegateProxy 接住公开 delegate 槽位上的消息,再把 invocation 沿责任链传递,不需要改业务类自身实现。
- KVO 创建动态子类拦截某个实例的 setter,用于属性变化通知。
三者都会使用 Runtime,但作用对象、所有权和冲突边界不同。不能因为实现底层都有消息派发,就把 DelegateProxy 解释成 KVO 或观察者模式。
运行
可以直接打开 RuntimeLab.xcodeproj,也可以打开外层 nodeOCAndSwift.xcworkspace,在 Pods 中查看并实时修改 RuntimeLab Development Pod 源码。
参考资料
- Apple Objective-C Runtime Programming Guide
- Apple Objective-C Runtime API Reference
- Mike Ash, Method Replacement for Fun and Profit
- steipete/Aspects 源码与 README 风险说明
- rentzsch/jrswizzle 与 RSSwizzle 的继承安全 Hook 思路