NSInvocation 详解

1. 什么是 NSInvocation?
一句话定义:
NSInvocation 是一个被“冷冻”的方法调用对象。
普通调用(热调用):
当你写 [object method:arg] 时,程序运行到这行代码,就像开枪一样,子弹(消息)立刻飞出去,击中目标,事情马上发生。
NSInvocation(冷调用):
想象你把一把枪(Target)、装什么子弹(参数)、瞄准哪里(Selector)全部设置好,然后按下暂停键,把它封存进一个盒子里。
这个盒子就是 NSInvocation。
-
你可以把盒子传给别人。
-
你可以过一小时再打开盒子开枪([invocation invoke])。
-
你可以修改瞄准的目标(修改 target)。
2. 为什么需要它?
普通的 [obj method] 必须在编译写代码时就知道你要调什么方法、传什么参数。
但在很多高级场景(比如路由组件、撤销/重做、消息转发)中,我们在写代码时根本不知道将来要调哪个对象的哪个方法,参数也是动态的。
这时候就需要 NSInvocation 来动态组装。
3. 核心三部曲:签名、组装、触发
使用 NSInvocation 就像是手动填报销单,步骤非常严格。
第一步:搞到“蓝图” (NSMethodSignature)
你要调用一个方法,必须先知道这个方法长什么样:返回值是啥?有几个参数?参数类型是啥?
这就是方法签名。
// 假设我们要调用的目标方法是:
// - (void)printName:(NSString *)name age:(int)age;
SEL selector = @selector(printName:age:);
// 从目标类里拿到这个方法的签名(蓝图)
NSMethodSignature *signature = [TargetClass instanceMethodSignatureForSelector:selector];
// 蓝图里包含了类型编码,比如 "v@:@i"
// v: void (返回)
// @: self (隐参数1)
// :: _cmd (隐参数2)
// @: NSString* (参数3)
// i: int (参数4)
第二步:根据蓝图制造盒子 (invocationWithMethodSignature)
有了蓝图,就能制造出一个能容纳这些参数的空盒子。
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
第三步:手动填空(设置 Target, Selector, Arguments)
这是最容易出错的地方,有两个铁律:
铁律 1:索引从 2 开始
Objective-C 的任何方法,底层实际上都有两个隐藏参数:
-
Index 0: self (方法调用者)
-
Index 1: _cmd (当前方法的 Selector)
-
Index 2: 这才是你定义的第一个参数!
铁律 2:传递指针的指针
setArgument:atIndex: 这个方法非常底层,它不管你传的是对象还是整数,它只负责从内存地址拷贝数据。
所以,你必须传值的地址 (&)。
// 设置目标和方法
[invocation setTarget:myTargetObj];
[invocation setSelector:selector];
// 准备参数
NSString *argName = @"Jack";
int argAge = 18;
// 注意:这里传的是 &argName,即“指针的地址”
// 因为 argName 本身是个指针,我们要把这个指针的值拷贝进 invocation
[invocation setArgument:&argName atIndex:2];
// 注意:这里传的是 &argAge,即“整数的地址”
[invocation setArgument:&argAge atIndex:3];
第四步:开火 (invoke)
这一步才是真正执行代码。
codeObjective-C
[invocation invoke];
// 此时,myTargetObj 的 printName:age: 方法被调用
第五步:拿战利品 (getReturnValue)
如果有返回值,也得去内存地址里取。
codeObjective-C
int result;
[invocation getReturnValue:&result];
4. 内存模型图解 (为什么传参数要用 &?)
这是 NSInvocation 最难理解的点。让我们看看底层内存发生了什么。
假设方法是 - (void)setAge:(int)age。
-
准备阶段:
你定义了 int age = 18;
在内存里,age 占了 4 个字节,存着二进制的 18。 -
设置参数:
[invocation setArgument:&age atIndex:2];-
NSInvocation 内部有一个缓冲区(Buffer)。
-
你把 &age(地址)传进去。
-
NSInvocation 根据签名知道 Index 2 是个 int(4字节)。
-
它跑到 &age 这个地址,把这 4 个字节的数据 memcpy(复制)到它自己的缓冲区里。
-
如果是对象呢?
假设方法是 - (void)setName:(NSString *)name。
-
准备阶段:
你定义了 NSString *name = @“Jack”;-
@“Jack” 这个对象在堆内存 0xFF00。
-
变量 name 是一个指针,它存的值是 0xFF00。
-
-
设置参数:
[invocation setArgument:&name atIndex:2];-
注意:这里传的是 &name(指针变量的地址),而不是 name(对象的地址)。
-
NSInvocation 根据签名知道 Index 2 是个对象指针 @(8字节)。
-
它跑到 &name 这个地址,把 0xFF00 这个地址值复制到它自己的缓冲区里。
-
结论:NSInvocation 只管拷贝内存。所以无论参数是什么类型,你都要把存着那个数据的变量的地址给它。
5. retainArguments 的坑
默认情况下,NSInvocation 不会持有(Retain)你传给它的对象参数。它只是傻傻地复制了指针地址。
-
如果你 invoke 完马上就释放 invocation,没问题。
-
但是,如果你把 invocation 存起来(比如做成一个 Operation 放到队列里过会儿执行),而在执行前,原来的参数对象 argName 被释放了,那么 invocation 里存的指针就变成了野指针,一调就 Crash。
解决方法:
调用 [invocation retainArguments];。
这会强制 NSInvocation 把所有对象类型的参数都 retain 一次,把 C 字符串复制一份,确保延时执行是安全的。