开头
从写第一行 [object method] 开始,几乎每个 Objective-C 开发者都被告知过这样一句话:方法调用其实是给对象发消息。但这个“发消息”到底意味着什么,和 C 语言里 function() 直接跳转有什么区别,很多人学了几年也没真正搞清楚。我见过不少开发者,ARC、Block、GCD 都能熟练使用,一谈到 objc_msgSend 就模模糊糊,遇到“方法找不到崩了”“方法覆盖了但没生效”“动态添加方法能不能行”这类问题只能靠搜索引擎碰运气。
这篇文章会把 OC 方法调用的本质从头到尾拆一遍,从编译器做了什么、objc_msgSend 怎么找到实现、方法缓存如何加速,到消息转发三步走的具体代码实现。中间会穿插大量可以直接在 Xcode 里操作的验证方法和调试命令,还会聊到 OC 与 JavaScript 互调这类实际场景下的应用,以及 UIStackView 这类日常 UI 组件背后和消息机制的关系。适合 iOS 初中级开发者阅读,哪怕你只是刚接触 OC 语法,把它当一篇“底层原理入门地图”也完全能跟得上。
1. 从调用语法到底层逻辑:为什么说 OC 是“消息语言”
1.1 中括号语法和 C 语言函数调用的本质区别
先看一段最简单的代码:
objective-c复制Person *person = [[Person alloc] init];
[person eat];
编译器在处理最后一行时,并不像 C 语言那样直接生成一条 call 指令跳转到 Person 类的 eat 方法地址,而是把它改写成一个 C 函数调用:
c复制objc_msgSend(person, @selector(eat));
objc_msgSend 是 Objective-C runtime 的核心入口函数,它的作用是根据“接收者”和“方法编号”找到具体的函数指针(IMP),然后跳转执行。这和 C++ 的虚函数表(vtable)有相似之处,但比虚函数表更灵活——C++ 的虚函数查询是编译期就确定好的偏移量,而 objc_msgSend 的查找过程是运行时才真正发生的。
打个比方,C 语言的函数调用像你打开手机通讯录,直接点某个联系人的号码就能拨通;而 OC 的消息发送像是你对着手机喊一句“帮我联系一下张三”,手机需要先查通讯录找到张三的号码,再拨出去,而且这个查通讯录的过程还允许你中途改号码、加号码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.2 编译期到运行期之间发生了什么
完整的核心流程可以拆成这几步:
- 编译器把
[person eat]改写成objc_msgSend(person, @selector(eat)) - 运行时通过
person->isa找到对象所属的类 - 在类的
method_list_t中查找eat对应的Method(结构体里包含SEL和IMP) - 如果当前类没找到,沿着
superclass指针一路向父类查找 - 找到
IMP后跳转执行;没找到进入消息转发流程
有意思的是,@selector(eat) 并不只是一个字符串。编译器会为每个方法名生成唯一的 SEL 值,这个值在运行时是全局唯一且被缓存复用的。所以 @selector(eat) 和 @selector(eat) 比较一定相等,速度快到可以忽略不计。这也是为什么方法名的拼写必须完全一致,一旦写错,SEL 就不同了,消息自然发不到目标方法上。
2. 消息发送的完整链路:isa、SEL、IMP 与缓存机制
2.1 isa 指针和类对象的联系
每个 OC 对象在内存中的第一个成员就是 isa 指针(在 arm64 架构下是 isa_t 联合体),它指向对象所属的类。而类对象本身也有 isa(指向元类),类对象的 superclass 指向父类。这个结构让 OC 可以在运行时通过“isa 走位”找到完整的方法链。
举个实际例子。如果你调用了一个当前类没有、但父类有的方法,比如 UIView 的子类调用 initWithFrame:,消息发送函数会先从子类的方法列表查起,查不到就顺着 superclass 链往上找,一直找到 NSObject。这个查找过程不是一个一个遍历方法名,而是通过方法列表中的 sel 与 name 的映射关系进行比较,具体实现在 runtime 源码的 lookUpImpOrForward 函数里。
关于 isa 有一点容易被忽略:因为 isa 用于类对象时指向元类,所以类方法(用 + 声明的方法)是存在元类中的。调用 [Person sharedInstance] 时,消息是发给 Person 类对象,runtime 会沿着 Person->isa 找到元类,在元类的方法列表里查找 sharedInstance。
2.2 objc_msgSend 的查找规则和消息缓存的影响
直接理解 objc_msgSend 的完整实现会有些吃力,runtime 源码用了大量汇编来优化这个高频路径。但从逻辑上拆,它的查找过程是这样的:
- 检查接收者是否为
nil,如果是则直接返回,这也是为什么向nil发消息不会崩溃 - 通过
isa找到类 - 在类的
cache_t缓存中查找方法,缓存命中直接拿到IMP - 缓存未命中则去方法列表中查找,找到后写入缓存
- 仍没找到,调用
_class_lookupMethodAndLoadCache3,进入完整的查找和转发逻辑
这里最值得关注的是方法缓存。每一个类都维护一个哈希缓存表,存储“SEL 到 IMP”的映射。当你的代码在循环里反复调用同一个方法时,objc_msgSend 能在缓存里直接命中,速度非常快。而你在调试时看到的 objc_msgSend 汇编代码中的 CacheLookup 宏,就是在做这一步。
在实际开发里,你几乎不需要手动控制方法缓存,但了解它的存在能解释很多性能问题。比如在 tableView:cellForRowAtIndexPath: 里调用 [self updateCell:cell],之所以能每秒顺畅执行几十次,方法缓存功不可没。正因为缓存的存在,运行时查找一次的成本均摊到成千上万次调用上,几乎可以忽略。
2.3 消息查找失败后的动态处理流程
当方法列表和父类都找不到对应的实现时,runtime 并不会马上崩溃,而是给了三次“补救”的机会,也就是消息转发。调用顺序固定:
+ (BOOL)resolveInstanceMethod:(SEL)sel:动态方法决议,允许你在运行时给这个类添加方法实现- (id)forwardingTargetForSelector:(SEL)sel:快速转发,把消息转发给另一个对象- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel和- (void)forwardInvocation:(NSInvocation *)invocation:完整转发,可以对参数和返回值做修改后再转发
关于这一块的代码实现,我会在下一节重点演示,因为它是“方法调用的本质”里最直观体现运行时动态性的部分。
3. 用实际代码验证动态特性:在消息转发里做文章
3.1 动态添加方法的场景与实现
假设现在有这样一个需求:你要设计一个网络请求层,支持在运行时根据收到的请求类型动态生成对应的处理方法,而不是在编译期把所有方法都写好。如果按传统思路,需要手动维护一个巨大的 switch-case 或 if-else 分支,一旦新增类型就要改代码重新发版。而利用消息转发,你可以把“这个方法不存在”变成“没关系,运行时我来补一个”。
一个最典型的例子是 iOS 开发中未实现方法的兜底处理。比如开发阶段有人不小心调用了一个不存在的方法,正常情况下会崩掉,但如果你在类里实现了 resolveInstanceMethod:,就能在崩溃前动态往上添加方法,甚至可以保证方法存在时正常执行、不存在时给一个默认返回值:
objective-c复制@implementation MyDynamicObject
void defaultImplementation(id self, SEL _cmd) {
NSLog(@"动态生成的默认实现: %@", NSStringFromSelector(_cmd));
}
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(doSomethingUndefined)) {
class_addMethod(self, sel, (IMP)defaultImplementation, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
@end
这段代码的关键点在于 class_addMethod 的参数。self 是当前类对象,sel 是要添加的方法编号,(IMP)defaultImplementation 是 C 函数指针,最后一个 "v@:" 是类型编码,表示返回值为 void、参数为 self 和 _cmd(每个 OC 方法都隐式带这两个参数)。
实际运行一下你会发现,即使 doSomethingUndefined 在头文件中从没声明过,只要给它发送消息,也能正常执行到 defaultImplementation 函数里。这里的核心原理就是:objc_msgSend 查找失败后,会先询问“类能不能自己补一个实现”,能的话就动态加上,然后重新执行查找。
3.2 消息转发到其他对象的完整示例
动态添加方法适合“方法确实属于本类,只是实现时机在运行时”的情况。但有时候,这个方法本就不该由当前对象来处理,更适合扔给其他对象。比如一个统一管理类实现了 forwardingTargetForSelector:,把所有不认识的未知消息都二次转发给一个兜底对象:
objective-c复制@interface ProxyObject : NSObject
@property (nonatomic, strong) id target;
@end
@implementation ProxyObject
- (id)forwardingTargetForSelector:(SEL)aSelector {
if ([self.target respondsToSelector:aSelector]) {
return self.target;
}
return [super forwardingTargetForSelector:aSelector];
}
@end
调用起来就是:
objective-c复制ProxyObject *proxy = [[ProxyObject alloc] init];
proxy.target = [[RealObject alloc] init];
[proxy doRealWork]; // RealObject 实现了 doRealWork,proxy 把它转发过去了
这个特性在 iOS 的 UITableView 的 delegate 机制、组件化框架、APM 无侵入埋点里都能看到影子。它体现了消息机制一个很重要的设计哲学:调用方并不需要关心消息最终由谁处理,只要负责“发出消息”就行。
3.3 完整转发和 NSInvocation 的参数处理
如果只需要把消息原封不动转给另一个对象,forwardingTargetForSelector: 就够了。但如果需要修改消息的参数、改变返回值,或者转发给多个对象,那就必须用完整转发。
先看代码:
objective-c复制- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if (aSelector == @selector(addNumber:toNumber:)) {
return [NSMethodSignature signatureWithObjCTypes:"i@:ii"];
}
return [super methodSignatureForSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation {
if (anInvocation.selector == @selector(addNumber:toNumber:)) {
int firstNumber = 0;
int secondNumber = 0;
[anInvocation getArgument:&firstNumber atIndex:2];
[anInvocation getArgument:&secondNumber atIndex:3];
int result = firstNumber + secondNumber + 100; // 在原来逻辑上增加偏移
[anInvocation setReturnValue:&result];
}
}
注意 methodSignatureForSelector: 必须被实现,并且返回正确的类型编码。"i@:ii" 的意思是:返回值是 int,参数依次是 self、_cmd、两个 int。这里少写一个 i 或者顺序错了,转发时取出参数就会错位,轻则数据不对,重则野指针崩溃。
这种带参数修改的完整转发在热修复框架、AOP 切面、方法拦截的中间件里用得非常普遍。你可以把它理解成快递代收点:快递到了代收点,代收点可以拆开检查、甚至可以修改内容,再决定怎么处理。而 forwardingTargetForSelector: 更像是快递柜,原封不动地交给下一个收件人。
4. 在 iOS 实际开发中的延伸:JS 互调与 UIStackView 背后的动态派发
4.1 JavaScriptCore 互调中的方法调用穿透
聊完底层机制,很多人的第一反应是:这些动态派发到底在日常开发里哪里用得上?我挑一个比较有代表性的场景——OC 与 JavaScript 的互相调用。
iOS 原生通过 JavaScriptCore 框架向 JS 环境暴露方法的方式,本质上就是方法调用的一种跨语言延伸。举个例子,JS 里执行 MyNativeBridge.sharedInstance.getUserName(),底层对应的是原生 OC 方法 getUserName。这个过程可以拆成两层:
- JS 层的
MyNativeBridge对象对应着原生MyBridge实例 - JS 引擎调用
getUserName时会通过JSExport协议生成的方法映射表,最终触发一次 OC 的消息发送
而如果你在 JS 侧调用了一个原生没有导出的方法,JavaScriptCore 的桥接层也有一套类似消息转发的兜底逻辑,只不过它的兜底是被动的——方法找不到会直接抛 JS 异常。这提醒我们一件事:在 OC 和 JS 互调时,原生侧暴露给 JS 的方法一定要做空实现或异常保护,不能把消息转发的兜底逻辑和跨语言的异常处理混为一谈。
另一种常见的互调方式是 WebView 的 URL 拦截。比如在 WKWebView 的 decidePolicyForNavigationAction 里拦截 jsbridge://doSomething?param=xxx 这样的 URL,再 performSelector: 调用原生方法:
objective-c复制SEL selector = NSSelectorFromString(@"handleDoSomething:");
if ([self respondsToSelector:selector]) {
[self performSelector:selector withObject:param];
}
这里又回到了消息查找的范畴。respondsToSelector: 就是沿着 isa 和 superclass 链走一遍查找逻辑,找到返回 YES,找不到返回 NO。理解了消息发送的本质,这类桥接代码对你来说就没有任何神秘感了。
4.2 UIStackView 的约束更新为何也依赖动态方法
UIStackView 是 iOS 开发中非常常用的布局容器,它的 axis、alignment、distribution 这些属性在设置后都会触发内部的重排。这个重排流程里,很多内部方法也是通过 runtime 动态派发来调用的。
比如你在 viewDidLoad 里设置了 stackView.axis = UILayoutConstraintAxisHorizontal,系统内部会调用 setAxis: 的 setter 方法,而 setter 的实现内部会触发 layoutSubviews、updateConstraints 等一连串的方法调用。这些调用分布在 stackView 的继承链中,从 UIStackView 到 UIView 再到 UIResponder,消息查找的核心链路同样没变。
看一个更直接的例子。很多人遇到过“StackView 里加了一个隐藏的视图,约束却报错”的情况。隐藏视图的 hidden 属性在 UIStackView 内部会被监听,系统通过 KVO 观测 hidden 值变化,然后调用相关的私有方法重新计算布局。KVO 本身也是基于 runtime 动态生成的 setter 实现的。这就是为什么用 UIStackView 时,你很少需要手动去 update 约束——系统的动态派发机制已经帮你做了大部分事。
理解这一点,对排布 UIStackView 的约束冲突会有更底层的判断力。遇到奇怪的约束日志,我通常第一反应不是去猜系统逻辑,而是先用 bt 命令看调用栈,在栈里就能看到一套完整的 objc_msgSend 传递链,问题在哪里调用断了就一目了然。
4.3 method swizzling 和动态派发的实战边界
和消息机制相关的高频技巧里,method swizzling(方法交换)占用非常重要的位置。它的原理就是利用 runtime 的 method_exchangeImplementations 函数,交换两个方法的 IMP 指针。
objective-c复制+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL originalSelector = @selector(viewDidLoad);
SEL swizzledSelector = @selector(swizzled_viewDidLoad);
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
});
}
- (void)swizzled_viewDidLoad {
[self swizzled_viewDidLoad];
NSLog(@"viewDidLoad 被交换了");
}
这里有一个老生常谈但必须注意的坑:交换后,调用 [self swizzled_viewDidLoad] 实际上会去执行原来的 viewDidLoad 实现,因为两个 IMP 已经互换了。只要再次理解“消息是根据 SEL 去查找 IMP”的机制,这个反直觉的行为就很好解释了——方法名没变,查到的 IMP 变了而已。
swizzling 能做的事很多,比如无侵入埋点、统计页面曝光、防止按钮快速点击等。但它也有明确的边界:不能随意交换父类的私有方法、不能对已加载过的类频繁做交换、在 Swift 类上使用时还要考虑 @objc dynamic 修饰。这些都是消息机制“灵活”之外的另一面——灵活意味着你可以在运行时改规则,但也意味着改错了影响面会被放大。
5. 常见问题与排查技巧实录
5.1 方法找不到崩溃的完整定位流程
开发中最经典的崩溃就是 unrecognized selector sent to instance 0x...。很多人一看到这个就懵,实际上定位思路很清晰。
第一步,看崩溃信息里提到的类名和方法名。比如 -[MyViewController doSomething]: unrecognized selector sent to instance 0x7f...,说明 MyViewController(或它的父类链上)找不到 doSomething。
第二步,确认调用代码里有没有拼写错误。SEL 是按名称精确匹配的,doSomething 和 doSomthing 是完全不同的两个消息,编译器有时会给出警告,但不一定全部拦截。
第三步,检查是否在分类里声明了方法但忘记实现。分类的方法在编译后会合入主类的方法列表,如果只声明没实现,调用时一样会走进消息转发。
第四步,用 class_copyMethodList 打印类的全部方法列表,确认方法是否存在:
objective-c复制unsigned int count = 0;
Method *methods = class_copyMethodList([MyViewController class], &count);
for (unsigned int i = 0; i < count; i++) {
SEL selector = method_getName(methods[i]);
NSLog(@"方法: %@", NSStringFromSelector(selector));
}
free(methods);
如果方法确实存在但调用还是崩溃,那大概率是调用对象的实际类型和你想的不一样。比如声明的是 UIViewController *vc,实际传入的却是 MyViewController 的父类实例。这时候在崩溃点打一个 po [vc class],立刻就能看出来。
5.2 方法覆盖和分类加载的常见困惑
OC 的消息查找是按继承链自下而上进行的,子类的方法永远优先于父类找到,所以子类覆盖父类方法是很自然的事。但有一个坑来自分类。如果两个分类都实现了同一个方法,消息查找时只会找到最后加载的那个分类的实现,而加载顺序取决于工程的编译顺序。
排查这种“方法被覆盖但行为不对”的问题,有一个非常实用的命令:
code复制image lookup -rn [MyClass myMethod]
这个命令能在 LLDB 中查看某个方法的所有实现地址,如果看到同一个方法名对应了多个地址,说明确实存在多个分类实现。还可以用 image list -o -f 查看加载顺序,结合编译顺序判断谁最后生效。
5.3 高性能场景下的消息调用优化
方法缓存虽然高效,但 objc_msgSend 毕竟比 C 语言直接函数调用多了一层查找开销。如果在意极致性能,有几种处理手段:
- 对于循环体内调用的方法,可以用
IMP缓存直接保存函数指针,避免每次查找 - 使用
C函数替换不涉及动态派发了的性能热点 - 使用
-fobjc-direct-methods编译选项把某些方法标记为 direct method,跳过消息发送流程
objective-c复制// 缓存 IMP 的示例
SEL selector = @selector(doWork);
IMP imp = [self methodForSelector:selector];
void (*func)(id, SEL) = (void (*)(id, SEL))imp;
for (int i = 0; i < 10000; i++) {
func(self, selector);
}
但大多数 App 业务代码完全不需要这一步优化。真正需要关注消息性能的通常是渲染引擎、音视频处理、大数组遍历等场景。日常开发里,保证方法名规范和消息链路清晰,比抠这几个纳秒更有价值。
5.4 消息转发和 KVO 共同作用时的问题
KVO 的实现机制是:为被观察对象动态生成子类,重写被观察属性的 setter 方法,然后消息查找时会先走到这个动态子类的重写 setter 里。如果用 class 方法打印对象的类,经常会看到 NSKVONotifying_MyClass 这样的名字。
这个机制本身很精妙,但也带来过一个经典问题——你在 KVO 回调里调用对象的方法,如果这个方法在 KVO 动态子类里有重写,消息就会先被动态子类拦截。理解了消息发送链路,你就能猜到这个拦截可能影响 swizzling 或者消息转发的行为。
我的建议是:不要在 KVO 的 observeValueForKeyPath 回调里做太复杂的消息转发操作,也不要在同一个类上同时用 KVO 和 swizzling 修改同一个 setter。真要都用到,先用一个中间层方法做二次转发,避免两个动态机制互相干扰。
5.5 问题排查速查表
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| unrecognized selector 崩溃 | 方法名拼写错误、未实现、对象类型不符 | 断点 po [obj class],打印方法列表 |
| 分类方法覆盖不生效 | 多个分类实现同一方法,加载顺序问题 | image lookup -rn 查实现地址 |
| 方法动态添加后仍崩溃 | class_addMethod 类型编码错误 |
检查 v@: 等编码是否匹配真实参数 |
| KVO 回调里方法行为异常 | 动态子类介入,消息被重定向 | 打印 object_getClass(obj) 确认类型 |
| 消息转发不执行 | 没有实现 methodSignatureForSelector: |
补全方法签名,确认返回非 nil |
| 循环调用方法性能差 | 方法列表查找路径太长 | 缓存 IMP 或改用 C 函数 |
结尾
断断续续写了这么多,本质上想表达的就是一句话:OC 的方法调用不是一个机械的“跳到某个地址执行”,而是一整套基于运行时、以消息为核心的分发机制。真正理解 objc_msgSend 的查找链路、isa 的指向规则、消息转发的三次机会,再去读 runtime 源码、看别人写的 swizzling 工具、调试 KVO 相关问题时,会明显感觉到不再被底层细节绊住。
我自己的习惯是,每接触一个陌生的 iOS 库或框架,第一步就是先看它的方法调用链,弄清楚“谁在向谁发消息、消息最终由谁处理”。这个思维习惯帮我解决过不少莫名其妙的 bug。如果你也想深入这块,建议亲手把 forwardInvocation: 的示例跑一遍,再用 LLDB 的 bt 命令跟一次消息转发流程,比单纯看十篇文章都管用。踩过一次这种坑,你才算真正摸到了 OC 运行时的门道。
