
1. 这个警告到底在说什么ARC的防盗门逻辑先说个结论这条编译警告几乎每个从MRC时代走过来的iOS开发者都见过而且绝大多数人第一次看到它时都是一脸懵。明明performSelector:是iOS开发里再普通不过的方法调用结果ARC一开编译器直接甩脸子你这个调用可能会导致内存泄漏。更烦人的是你把代码换成objc_msgSend之后编译器反而闭嘴了——这到底是为什么要理解这个警告得先搞清楚ARC底下那条不成文的规矩方法名自带内存管理说明书。在Objective-C里一个方法返回的对象到底是谁的编译器不看你方法内部怎么写只看你方法名以哪个前缀开头。如果方法名以alloc、new、copy、mutableCopy开头那么按照约定这个方法的返回值是调用者所有也就是引用计数1调用完必须负责释放。剩下的情况都默认返回的是自动释放对象引用计数0调用者不需要管。这套规则是给编译器做静态分析用的也是ARC赖以为生的地基。再来看performSelector:这个家伙。它的声明大概是这样的- (id)performSelector:(SEL)aSelector;看到问题了吗这个方法的返回值类型是id但你真正想调用的那个aSelector对应的方法叫什么名字编译器在编译阶段根本不知道。它只知道你传了一个SEL类型的参数进来至于这个SEL指向的是copyArray还是viewDidLoad完全是个黑盒。于是编译器就陷入了一个非常尴尬的处境它必须决定从performSelector:返回的那个对象到底该不该帮你加release。按照命名规则performSelector:本身不以alloc、copy等前缀开头所以返回的id默认是0的编译器不需要插入释放逻辑。可是真正被执行的、那个未知的方法可能返回的却是1的对象。比如你传的selector是copyData实际执行时返回了一个引用计数为1的对象而ARC这里啥也没干没人去释放它——对象就永远躺在堆上这就是警告里说的leak。编译器的确很鸡贼它不是保证你会泄漏而是告诉你我无法验证这里是否泄漏。为了不承担这个风险它选择在最显眼的地方给你打一条黄色警告提醒你这里可能存在内存管理盲区。想用一个简单粗暴的比喻你在小区门口刷脸进门保安看你眼熟就放行了但如果你带了一个不认识的访客进来保安一定会多问一句你朋友叫什么住几号楼——performSelector:就是那个不认识的访客编译器就是那个多疑的保安。真正危险的地方还在后面这条警告不是只能在你手滑传错方法名时出现它的触发条件极其宽松几乎只要你在ARC环境下写了[obj performSelector:sel]哪怕是自上而下的标准用法警告依然会亮起。换句话说你不是真的泄漏了你只是被编译器有罪推定了。很多初学者被警告吓到第一反应是加#pragma clang diagnostic ignored -Warc-performSelector-leaks把警告压下去。能用但这绝对是你最不应该养成的习惯。压制警告解决的不是问题而是把问题藏起来了等哪天线上真的溢出崩溃再回头翻代码根本无从排查。接下来我的建议是先从场景层面把这个警告摸透再谈怎么正经解决。2. 最容易踩坑的场景盘点警告背后的真实风险很多文章只告诉你这个警告说明可能泄漏但具体到底哪些写法容易出问题又有哪些写法其实没问题说得非常模糊。这里我把实际开发中最容易踩的场景盘一遍方便你对照自己的代码。2.1 最常见的高危写法字符串拼selector这种写法最常见于动态调用场景比如根据一段服务端下发的字符串拼接出方法名去调用本地方法NSString *action copyUserInfo; SEL sel NSSelectorFromString(action); [obj performSelector:sel];这段代码里的action完全是运行时才确定的编译器只能看到你构造了一个SEL它无法推断这个SEL最终对应的是什么方法。如果copyUserInfo真的是以copy开头、返回一个1对象的方法那么泄漏就是实打实的。这种场景下警告的指向性是最准确的。实际业务里还有一种类似的把performSelector和NSSelectorFromString配合用来做路由转发。服务端下发页面类型客户端根据字符串拼出控制器类名和方法名执行跳转。这种架构最大的问题不只是泄漏隐患还有——方法签名不匹配。你很难保证字符串拼出来的方法参数和返回类型都跟预期一致。这个我们在后面解决方案里细说。2.2 传参场景比无参情况更复杂performSelector实际上不止无参的版本还有带参数的两个变体- (id)performSelector:(SEL)aSelector withObject:(id)object; - (id)performSelector:(SEL)aSelector withObject:(id)object withObject:(id)object2;这两个方法同样会触发警告而且参数越多越容易出现连锁问题。我从MRC时代走过来见过一个特别典型的翻车案例老同事写了一段代码从字典里取value把value通过performSelector:withObject:传给目标的saveData:方法。结果目标方法内部对参数做了强引用持有value自己也在字典里被强引用两个对象互相不释放Instruments一测就是一个循环引用的泄漏。ARC自动管理了局部引用计数但它管不了你业务逻辑里两个对象之间这种互相绑架的关系。所以这个警告还有一个隐藏提示你最好真正搞清楚selector对应的目标方法到底做了什么尤其是内存管理之外的生命周期问题。2.3 无返回值纯调用其实风险很低如果你的performSelector:纯粹是触发一下某个方法不关心返回值目标方法也只是一个普通的void方法那即便代码能过编译也亮着警告实际泄漏概率也不高。原因很简单——方法返回void就没有内存管理语义ARC想管理也没有对象可以管理。你比较多的是心里不舒服每次编译都看到黄色警告条好像代码哪里坏了似的时间长了就容易麻木真正的坏味反而闻不出来了。2.4 被忽略的遍历方法列表场景还有一种更隐蔽的坑是在循环里对集合中每个对象执行相同的selectorfor (id item in items) { [item performSelector:sel]; }这种写法在设计模式上叫消息转发本身没有错。但问题在于如果items里混入了不同类型的对象而某些对象并没有实现sel对应的方法运行时就会直接unrecognized selector崩溃。就算实现了各对象方法返回值的所有权也可能不同服务器下发的配置一旦变了你能得到的只有线上崩溃。这种场景直接暴露了动态调用的核心风险编译期给你的保护全部失效任何错误都被推迟到了运行期。我个人的建议是不到万不得已不要把你的业务核心逻辑建立在performSelector之上。它最大的价值是它灵活但灵活的反面就是不可控。如果你只需要把执行某个操作这个行为延迟或者转发出去Objective-C里有很多更安全的替代方案比如block、target-action机制、协议代理这些我们放到下一章具体展开。3. 五种解法与选型建议从治标到治本我梳理了实际工程里常见的五种处理方式每一种都有自己的适用场景和局限性。不夸张地说谁用对了谁的代码就能既保持动态调用的灵活性又不跟ARC对着干。3.1 方案一函数指针法最推荐的轻量替代这个话题要好好展开讲一下因为它在老牌iOS社区里已经是个经典答案了。思路很简单performSelector本质上是根据一个SEL去找方法对应的函数指针IMP找到之后再调用。那不如把中间的找这一步提前到编译期去做直接把方法指针拿出来手动调用。这样编译器能看到完整的函数签名内存管理语义也一目了然。SEL sel NSSelectorFromString(doSomething); IMP imp [obj methodForSelector:sel]; void (*func)(id, SEL) (void (*)(id, SEL))imp; func(obj, sel);关键在于最后一行的这个强转。IMP本身的类型是id (*)(id, SEL, ...)如果你不经转换直接调用编译器只把它当作返回id的处理如果方法实际返回void在某些架构下读写寄存器的方式会错位结果不可预期。所以必须根据真实的方法签名把函数指针转换成对应的类型。上面例子是无参无返回值的如果方法有一个BOOL参数、返回void就写成void (*func)(id, SEL, BOOL) (void (*)(id, SEL, BOOL))imp; func(obj, sel, YES);这种方案的好处是编译器不再警告因为它看到了完整的方法类型。比performSelector更直接省掉了方法查找的时间虽然这个时间在OC里本来就微乎其微。代码清晰读起来就是标准的C函数调用。代价是写起来稍微啰嗦尤其是当参数类型和个数变化时你得多写很多模板代码。所以这个方案最适合的是调用频率高、方法签名固定的场景。曾经我优化过一个批量刷新UI的模块循环里对几百个cell调用同一个刷新方法从performSelector改成函数指针调用之后肉眼可见地顺畅了一些——当然这不是在教你为了这点性能去折腾只是说明这个方案在性能上肯定不吃亏。3.2 方案二objc_msgSend直接发消息objc_msgSend本质上就是Objective-C方法调用的物理实现。你把消息发给对象runtime负责找到对应的IMP并跳转过去。所以用objc_msgSend替代performSelector是很多C系功底扎实的开发者会用的办法#import objc/message.h ((void (*)(id, SEL))objc_msgSend)(obj, sel);这里最神的地方在于——它不触发那条ARC警告。原因是编译器把objc_msgSend当成一个普通的C函数调用返回值被强制转换后已经是明确的类型了不再触发内存管理约定的推测。所以这也成了圈子里最常用的绕过警告手段。但我要给你一个忠告除非你很清楚runtime的细节否则不要随意增加参数个数。objc_msgSend在x86_64和arm64架构下对参数的传递方式是有差异的尤其是浮点参数和结构体参数如果你只是照着网上的代码抄很容易在真机上踩坑。比如arm64下如果方法的第一个参数是CGFloat而不是id你可能就得用objc_msgSend_fp2ret之类的变体这类用法非常容易出错。还有一种混用的情况返回值是结构体尤其是一个大于16字节的复杂结构体这时候你直接用objc_msgSend是不对的应该用objc_msgSend_stret。总之这个方案强大但危险适合那种performSelector处理不了、函数指针又写不出来的复杂场景不推荐作为首选。3.3 方案三NSInvocation保险箱级方案NSInvocation是官方提供的、最正规的动态调用方案。它能把一个消息封装成一个对象可以设置target、selector、参数、返回值还能控制是否保留参数。最棒的是编译器对它的任何接口都不会有内存管理的警告因为它本来就是一套完整的运行时消息封装。看一个标准的写法NSMethodSignature *signature [obj methodSignatureForSelector:sel]; if (!signature) { return; } NSInvocation *invocation [NSInvocation invocationWithMethodSignature:signature]; [invocation setTarget:obj]; [invocation setSelector:sel]; if (arg) { [invocation setArgument:arg atIndex:2]; } [invocation invoke];注意这个atIndex:2——NSInvocation里index 0是selfindex 1是_cmd第一个业务参数从2开始。刚接触的人十有八九会在这上面翻车写成atIndex:0或者atIndex:1程序不死才怪。NSInvocation最大的价值是它处理参数和返回值的方式极其统一无论方法签名有多复杂传参、取返回值都走同一套API。所以当你面对的是参数个数不固定、甚至类型都运行时确定的动态调用场景NSInvocation几乎是唯一靠谱的选择。代价有两个一个是性能开销确实比直接调用高不少另一个是类型安全完全靠自己保证签名不匹配时没有编译期提示运行起来可能深藏不露地崩。用法建议路由、插件化、AOP这类底层组件里用NSInvocation常规业务代码里避免使用你几乎不可能在业务代码里遇到必须用NSInvocation才能解决的场景。3.4 方案四pragma关闭警告能不用就不用这个方案我必须详细说因为太多人都这么干过包括早年的我自己。写法有两种#pragma clang diagnostic push #pragma clang diagnostic ignored -Warc-performSelector-leaks [obj performSelector:sel]; #pragma clang diagnostic pop或者直接给编译目标加-Wno-arc-performSelector-leaks全局关闭。它本质上就是在跟编译器说这个警告我知道了你闭嘴吧。问题在于警告被关闭之后编译器依然不会为你的动态调用插入任何内存管理逻辑该泄漏的地方照样泄漏只不过不再提醒你了。所以这个方案从解决问题的角度看完全没解决任何问题只是心理安慰。那它为什么还有存在价值因为有些老代码仓库动辄几百处performSelector而且很多是历史原因没法轻易重构的。你不可能在发版前把所有调用点都改完。这时候用pragma局部关闭至少能保证编译输出是干净的后续再安排重构。我当时的做法是关掉警告的同时在关闭的位置留一个// TODO: 重构为函数指针的注释并且把这种代码单独拉进一个技术债务清单——关闭警告只是战术选择绝不能当成技术方案。这里还有一个细节如果你用的是Swift混编Swift里调用OC的performSelector根本不会警告——因为Swift压根没有这种C风格的隐式内存管理提示但这不代表就安全了运行时性能反而更差这是另一个坑后面聊。3.5 方案五彻底换思路用block和target-action替代很多人一听到动态调用就默认只能靠selector但实际上如果你只是想把一个调用延迟到合适时机执行block才是现代OC里最优雅的工具。举个例子把这段代码[self performSelector:sel withObject:arg afterDelay:1.0];换成block版本dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self doSomethingWithArg:arg]; });不仅能避免警告还把参数传递从一个装箱的id变成了直接捕获变量类型安全也提高了。block的内存管理是完全透明、由编译器根据捕获规则自动处理的不存在动态调用的黑盒问题。再有一种常见场景是按钮点击事件[button addTarget:self action:selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside];如果你需要用一个变量来决定按钮点击后触发哪个方法performSelector并不是必要手段。可以对每个真实方法单独写一个selector在逻辑里通过条件判断来addTarget没必要把方法名做进数据里。用编译器能验证的方式代替运行时拼凑永远是最优解。4. 常见问题与leak check实操把警告变干净还不够解决了警告不等于解决了问题。下面这部分我们聊聊真实运行时的泄漏检查以及一些踩坑经验算是leak check的完整扫盲。4.1 为什么我关掉了警告用Instruments一扫还是提示泄漏这是我在技术社区里看到最高频的提问。答案其实很反直觉很多时候不是performSelector本身泄漏了而是你在selector对应的目标方法里不小心让对象互相强引用了。举个例子你有一个对象A里面以strong属性持有了对象BB又以strong属性持有A或者持有A的block。这种情况下你在主线程里调用performSelector去触发B的某个方法这个方法又无意识地访问了A——环就形成了。ARC根本管不了循环引用它不是GC没有根扫描算法。你关掉警告或者把调用方式改成函数指针这个环依然存在。所以做leak check的正确姿势不是只看有没有警告而是要看对象到底有没有被正确释放。这里给出一套我平时用的排查路径第一步打开Xcode的Instruments选择Leaks模板快捷键Cmd I选Leaks跑一遍你的功能路径尤其是涉及动态调用的页面。第二步等Leaks栏目里出现红色条目后选中它右侧能看到具体的调用堆栈注意这里显示的往往是分配了但是没有释放的栈不是泄漏点的栈别找错方向。第三步在Leaks的工具栏里同时开启Malloc Stack分配栈记录这样才能回溯到对象是在哪个调用路径上被创建的。第四步再用Debug Memory GraphXcode的Debug导航栏里那个类似三个圆环的图标检查引用环这个工具比Instruments更直观能看到每个对象和谁有关联。一套走下来你会发现绝大多数泄漏都出现在你根本想不到的地方——不是那个performSelector而是它后面跟着的业务逻辑。4.2 用autoreleasepool保护动态调用的边界还有一种比较特殊的情况你的performSelector被放在了for循环里每次循环都动态调用一个方法、返回一个对象。即便目标方法本身是0的但在循环的临时作用域里这个返回对象会被ARC持有一个强引用然后循环到下一次时上一个对象就得等自动释放池清空后才真正释放。如果循环次数特别多峰值内存就会非常难看。如果你非要保留performSelector的写法一段时间我建议至少给循环包一个autoreleasepoolfor (id item in items) { autoreleasepool { id result [item performSelector:sel]; } }这样至少能把临时对象的生命周期锁在循环体内不让它积压到自动释放池被统一排空。这套思路在写图片批量处理、数据迁移这类循环逻辑时特别有用值得记下来。4.3 Swift里的performSelector结果更糟最后提一嘴Swift。很多新项目已经开始用Swift了但Swift代码偶尔还是会通过NSObjectProtocol桥接去调用OC的performSelector。比如let obj: NSObjectProtocol someObject obj.performSelector(sel)这种调用在Swift里不会产生编译警告但性能反而比OC更糟糕。因为Swift对自己的类型系统要求严格performSelector这种动态派发完全跳过了Swift的编译期检查运行时全部依赖runtime的消息转发效率比直接调用低了不止一个量级。加上Swift内存管理更激进对OC的1对象处理规则又不完全一致一旦真的遇到copy之类的方法更容易出bug。我的建议很直接新写的Swift代码里不要出现performSelector不要为了少写几行switch就引入动态派发。Xcode的补全提示里你输入的NSSelectorFromString(xxx)越少代码就越安全。5. 最后再分享几个实际操作用的经验如果你只是想要一个既快又稳的落地结论我的建议排序是如果目标是常规业务代码优先函数指针方案如果目标是底层动态调用组件用NSInvocation如果目标是老代码维护可以用pragma关闭警告但必须在代码里留TODO标记最后实在不行才考虑objc_msgSend并且尽量参考成熟源码里的写法别自己拍脑袋组合参数。在实际工程里我们还要区分对performSelector的态度问题和技术问题。技术问题再复杂解法是固定的查文档查源码就能搞定。但态度问题——比如我就是要动态拼接方法名因为这样写代码爽——这种问题只能靠设计评审来避免。我自己在团队里立了一条规矩业务层不接受performSelector组件层可以使用但要过评审基础设施层随意但必须配注释和用例。最后留一个小技巧如果你真的在维护一个无法短期重构的老项目不妨把从字符串拼SEL这个动作收敛到一个独立类里面像DynamicInvoker这样的工具类所有performSelector都走这一个类。这样以后你想批量替换成函数指针或者NSInvocation只需要改一个文件。这也是我踩过坑之后才学到的——动态调用的泛滥本质上是对代码可控性的侵蚀控制它比消灭它更重要。