# iOS 原生面试题精选
覆盖语言基础、系统原理、
UI、并发、架构、性能、安全、测试与发布。每题都有 30 秒速记、代码示例、详细解析和面试官追问,流程类题目配了时序图。
# 1 Objective-C 语言基础
# 1. [obj foo] 这一行在运行时到底发生了什么?找不到方法时还有哪几次补救机会?
30 秒速记
[obj foo]编译后是objc_msgSend(obj, @selector(foo)),调用哪个函数到运行时才定 = 动态绑定- 查找顺序:
isa找到类 → 方法缓存cache→ 本类方法列表 → 沿superclass一路往上查,找到就写进缓存 - 找不到有三次机会:
+resolveInstanceMethod:动态加方法 →-forwardingTargetForSelector:换个接收者 →-methodSignatureForSelector:+-forwardInvocation:完整转发 - 三步都没接住 →
-doesNotRecognizeSelector:→unrecognized selector sent to instance崩溃 - 给
nil发消息不会崩,直接返回0/nil;这是特性,也是很多「怎么没反应」的 问题来源
[obj foo] 在编译期只是变成一次 objc_msgSend(obj, @selector(foo)) 调用,真正执行哪段代码,是运行时顺着 isa 去类里现查的。 先查方法缓存,命中就直接跳过去,这也是消息发送没想象中慢的原因;没命中再查本类方法列表、父类,一路查到 NSObject,找到后写进缓存。全链路都没找到,运行时还给三次机会:先问类要不要动态补一个方法,再问能不能把消息丢给别的对象,最后打包成 NSInvocation 让你自己处理。三次都不接,就是大家熟悉的 unrecognized selector 崩溃。
# 编译器把方法调用变成了什么
Objective-C 的方法调用不是 C++ 那种编译期就定好的虚表跳转,而是「发消息」。比如下面这行:
NSString *s = [user nameWithPrefix:@"Mr."];
// 编译后大致等价于:
NSString *s = ((NSString *(*)(id, SEL, NSString *))objc_msgSend)(user, @selector(nameWithPrefix:), @"Mr.");
objc_msgSend 是汇编写的,它拿到接收者后读 isa(arm64 上是带标志位的 non-pointer isa,要掩码取出类指针),先在类的 cache_t 里按 SEL 做哈希查找。命中就尾调用跳到 IMP,整个过程只比普通函数调用多几条指令。没命中才进 C 实现的慢路径 lookUpImpOrForward:查本类方法列表(排好序的列表用二分),再逐级查父类的缓存和方法列表,找到后把结果填进当前类的缓存,下次同样的调用就走快路径了。
每个方法本质上是一个 C 函数,前两个隐藏参数就是 self 和 _cmd,这也是为什么在方法里能直接用 _cmd 拿到当前选择子。
# 三次补救机会怎么用
@implementation Router
// 第一次:动态解析。返回 YES 后运行时会重新走一遍查找
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(ping)) {
class_addMethod(self, sel, (IMP)pingIMP, "v@:"); // 类型编码:返回 void,参数 self、_cmd
return YES;
}
return [super resolveInstanceMethod:sel];
}
// 第二次:快速转发,把消息原样交给另一个对象,开销小
- (id)forwardingTargetForSelector:(SEL)sel {
if ([self.impl respondsToSelector:sel]) return self.impl;
return [super forwardingTargetForSelector:sel];
}
// 第三次:完整转发,能拿到参数、改参数、转发给多个对象,开销最大
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [super methodSignatureForSelector:sel] ?: [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
- (void)forwardInvocation:(NSInvocation *)inv {
NSLog(@"吞掉未实现的消息: %@", NSStringFromSelector(inv.selector)); // 输出:吞掉未实现的消息: xxx
}
@end
static void pingIMP(id self, SEL _cmd) { NSLog(@"pong"); }
第一步适合「运行时才知道要哪些方法」的场景,@dynamic 属性、Core Data 的属性访问器就是这个思路。第二步常用来做代理转发、多重继承的模拟。第三步最灵活,NSProxy 做 AOP、给 NSTimer 做弱引用代理都靠它。注意第三步的 methodSignatureForSelector: 返回 nil 就直接崩,不会进 forwardInvocation:。
# 边界和坑
给 nil 发消息返回零值:对象返回 nil,数字返回 0,结构体在现代运行时下也是零填充。好处是不用到处判空,坏处是链式调用里某一环是 nil 时不会报错,只是「什么都没发生」。另外 Swift 里纯 Swift 类的方法走虚表或静态派发,不经过 objc_msgSend,只有标了 @objc dynamic 的方法才会走消息机制,这点在 KVO、swizzling 时很关键。
面试官追问
追问 1线上报 -[NSNull length]: unrecognized selector,最常见的原因是什么?
后端返回了 null,JSON 解析出来是 NSNull 单例,代码当成 NSString 去调 length。根治是在模型解析层把 NSNull 转成 nil,别在业务代码里到处判。
追问 2能不能统一 hook forwardInvocation: 把所有 unrecognized selector 吞掉,防止崩溃?
能做,很多防护 SDK 就是在 NSObject 上兜底到 forwardingTargetForSelector: 返回一个桩对象。但我只接受「吞掉 + 上报」,不接受静默吞,否则本该崩出来的逻辑错误会变成数据错乱,更难查。
追问 3消息发送每次都要查表,为什么性能没问题?
靠方法缓存,热路径基本都命中 cache_t,命中后只多几条汇编指令。真正慢的是第一次调用和完整转发,forwardInvocation: 要构造 NSInvocation,比正常调用慢一两个数量级,别放在高频路径。
追问 4respondsToSelector: 返回 YES,调用还是崩了,可能是什么情况?
常见是转发类重写了 respondsToSelector: 说能处理,但转发目标其实没实现,或者转发目标已经被释放变成 nil。反过来,默认的 respondsToSelector: 不知道转发的存在,转发能接住的方法它会返回 NO。写转发类就要让 respondsToSelector: 和真实转发逻辑保持一致。
追问 5Swift 写的类,为什么 perform(#selector(...)) 有时候找不到方法?
方法没暴露给 ObjC 运行时。Swift 4 起继承 NSObject 也不再自动推断 @objc,要手动标 @objc,否则 #selector 都编译不过;想被替换或被 KVO 观察还得加 dynamic。
# 2. Method Swizzling 怎么写才不出事?
30 秒速记
- 原理:交换两个
Method的IMP,之后调A实际跑B的代码 - 放在
+load里 +dispatch_once,只交换一次;交换两次就换回去了 - 先
class_addMethod:本类没实现、方法在父类时,直接exchange会把父类的实现换掉,影响所有兄弟类 - 新方法里调
[self xxx_viewDidAppear:]不是死循环,交换后它指向原实现 - 坑:
_cmd变了、多个库重复hook、Swift纯方法hook不到;能用子类或组合解决的就别swizzle
swizzling 就是在运行时把两个方法的实现对调,常用来做无侵入的埋点、AOP。 写法上有三条硬规矩:放在 +load 里用 dispatch_once 保证只换一次;交换前先 class_addMethod,防止方法其实定义在父类,你一换就把父类的实现改了,所有子类跟着遭殃;新方法里调用自己的名字其实是调原实现,这是故意的。它的问题在于全局生效、难排查,多个库 hook 同一个方法时顺序不可控,所以我只在埋点、监控这类基础设施里用,业务逻辑不碰。
# 一份能放心用的模板
