2018年那个秋天,迅雷的校招笔试邀请发到我邮箱时,我正蹲在实验室里翻Objective-C的Property源码。当时iOS岗在线笔试基本还是OC的天下,Swift 4刚推出不久,卷子上那些读代码题,清一色是block、runtime、内存管理这几个老面孔。迅雷校招iOS在线笔试B卷,难度在当时的互联网校招里属于中等偏上,题型和大厂没有本质区别:客观选择题、手写编程题、再加一两道“设计一个组件”的开放题。今天不打算去复述某场考试的原题,而是想把这类型笔试背后真正想考察的知识体系拆开,结合我当年的备考经历,给准备走iOS开发这条路的同学做一份全景复盘。无论你是正在投简历的应届生,还是想补基础的转行党,这篇文章都能帮你少走不少弯路。
1. 一场校招笔试到底在考什么
1.1 考卷结构:从40分客观题到30分设计题
校招笔试不是面面俱到地考你背了多少API,而是用一份时间有限的卷子,快速筛出两类人:一类是基础扎实、能理解底层逻辑的;另一类是平时只对着Interface Builder拖控件、面试前临时背两天题目的。2018年那会儿,移动开发行业已经进入“会写界面不稀缺、能解决性能问题才值钱”的阶段,所以卷面里iOS相关知识点考得非常密集,尤其在Objective-C语言本身的底层机制上。
从当年各个渠道汇总的笔试回忆来看,典型B卷的比例大致是这样的:
- 客观选择题(约40%):OC语言、Runtime、内存管理、Foundation框架、多线程、网络。
- 手写编程题(约30%):算法题或者简单iOS代码题,有些要求在在线编辑器里补全代码。
- 开放设计题(约30%):描述问题排查思路、设计图片缓存、分析TableView卡顿原因等。
这种分布即使在今天依然有参考价值,因为考点背后的判断逻辑没变:基础扎不扎实、代码写不写得出来、遇到问题能不能系统性思考。
1.2 答题节奏:先写代码还是先做选择
在线笔试系统一般会给60到90分钟。我的建议是拿到卷子先花5分钟把全部题目扫一遍,标记每道题的大致耗时,而不是直接埋头做第一道选择题。这样做的原因是手写代码题最容易出现“看懂了但写不完”的情况,而开放题往往需要组织语言,如果没有时间预算,很容易草草两句话就提交了。
我自己的时间分配方案是这样的:
- 前5分钟:整体扫题,给每道题标一个预计耗时。
- 编程题优先写,每道控制在15分钟以内,先把核心思路和代码骨架写出来。
- 客观题控制在25分钟以内,遇到卡壳的先标记,不恋战。
- 最后20分钟留给开放题和检查,开放题先写关键词再补充细节。
不要在一道选择题上死磕太久。很多在线笔试系统交卷后不能回头修改,所以做完一个题型就及时把答案填好,避免最后慌乱中交白卷。踩过的坑都知道,有些题目不是不会,而是因为时间分配出问题,反而把能拿的分丢掉了。
2. 客观题里的高频考点:OC语言与内存管理
2.1 Objective-C的本质:对象、isa指针、消息传递
笔试选择题里特别喜欢问“Objective-C对象本质”这一类的题目。比如给你一段代码,问NSObject实例底层到底长什么样,答案的核心就落在isa指针上。每一个OC对象都可以看作一个结构体,结构体第一个成员就是isa,它指向这个对象的类对象,类对象里存放着方法列表、属性列表、协议列表等信息。运行时通过isa才能确定一个对象能响应哪些方法。
这里最关键的是要理解消息机制。在OC里,调用方法并不是直接跳到函数地址,而是给对象发送一条消息。比如[self doSomething]这行代码,编译之后会转换成objc_msgSend(self, @selector(doSomething))。运行时沿着isa找到类,再在方法列表里查找对应的SEL,找到就调用IMP,找不到就进入消息转发流程。
当年笔试很喜欢考这一类题:某个对象调用了一个不存在的方法,会发生什么?答案不是简单一句“崩溃”,而是要经过resolveInstanceMethod:、forwardingTargetForSelector:、methodSignatureForSelector:/forwardInvocation:这几个阶段。只有这些阶段都没有兜住,最后才会触发doesNotRecognizeSelector:,然后才crash。很多人做错,就是只记住了“找不到方法就崩溃”,丢掉了中间那一整段转发细节。
2.2 属性修饰符:copy、strong、weak、assign到底怎么选
应届生做属性修饰符的题最容易折在copy和strong上。NSString、NSArray、NSDictionary这些有可变子类的类型,属性通常建议用copy。原因在于copy可以把外部可能传进来的NSMutableString复制一份,避免外部后续修改字符串内容时,你的属性值被意外改动。你可以这样理解:别人传给你一个可变对象,你想保存的是当前内容的快照,而不是一个随时可能在别处被改掉的引用。strong是持有并增加引用计数,但持有的是同一个可变对象,之后对象一变,你的属性也跟着变,很容易引入隐蔽的bug。
block属性使用copy也是老生常谈。ARC下编译器会自动处理block的复制,但很多老工程为了规范和兼容历史代码,仍然会显式写上copy。weak和assign的区别也要说清楚:assign一般用于基本数据类型,weak用于对象且不持有,对象被释放后weak会自动置为nil。如果assign修饰的是对象,对象释放后指针不会置nil,再访问就成了野指针,这是崩溃的高发原因。
atomic与nonatomic这里有个经典误区。很多人以为atomic就代表线程安全了,实际上它只能保证getter/setter的原子性,不保证复合操作的安全。你两个线程同时给一个NSMutableArray添加元素,即使这个数组属性是atomic的,也一样会因为线程竞争出问题。所以如果笔试里问“atomic能保证线程安全吗”,应该把边界说清楚:它保护的只是单次读取或单次写入,不是整个业务逻辑。
2.3 Block与循环引用:一考一个准
Block是当年笔试的常客,因为它能同时考语言特性和内存管理。Block可以捕获外部变量,本质上是一个带有捕获变量的函数对象。按存储位置可以分成三种:全局Block、栈Block、堆Block。全局Block不捕获外部变量,栈Block在栈上,堆Block在堆上,ARC下大部分Block会被自动copy到堆上。底层题目经常会问某段代码里block是什么类型,这个判断能力要具备。
循环引用的经典场景是:self持有block,block内部又引用了self。因为block捕获self时默认是强引用,于是形成一个环,谁都没法释放。解决办法是外面用__weak typeof(self) weakSelf = self,在block内部再用__strong typeof(weakSelf) strongSelf = weakSelf,保证block执行期间self不会提前释放。前者是防止循环引用,后者是防止执行到一半时self已经被释放。这两个修饰符配合使用的写法,笔试和面试官都特别喜欢追问,值得认真理解。
提示:你可能会问,block内部为什么还要先用weak再转strong,直接用strong不是又循环引用了?这里要区分清楚:strongSelf是block执行期间的一个局部强引用,block执行完就释放,不存在持有周期问题,它只保证当前这一次调用的安全,并不会破坏用weak打破的循环引用结构。
3. Runtime与底层机制:真正拉开分差的地方
3.1 ARC下的引用计数与弱引用实现
ARC不是垃圾回收,它仍然是引用计数,只不过编译器在合适的时机帮你插入retain、release代码。你需要清楚对象什么时候被持有、什么时候被释放,否则排查内存问题时依然会一头雾水。weak的实现机制是面试里最常追问的点:系统维护一个全局的weak表,以对象地址为key,value是对应所有弱引用指针地址的数组。对象dealloc时,系统根据这个对象的地址找到数组,把这些weak指针全部置为nil,所以弱引用不会产生野指针。
autoreleasepool也要有清晰认知。主线程的RunLoop每次迭代会自动创建和释放自动释放池,但如果你在循环里创建了大量临时对象,这些对象可能都会被加入同一个池子,导致内存峰值变高。比较典型的场景是处理大量图片或字符串时,可以在循环内部手动添加@autoreleasepool块,让临时对象尽早释放。这不仅是笔试考点,也是实际优化中常见的处理方式。
3.2 Runtime三件套:关联对象、方法交换、动态方法
Runtime是所有iOS底层题目的重头戏。关联对象属于“给一个已存在的类在不修改源码的情况下挂属性”的能力,底层是通过AssociationsHashMap实现的。笔试里如果问关联对象是不是真正塞到类里变成成员变量了,答案是否定的,它只是在一个全局map中以对象指针为key存了值、策略和持有关系。因为关联对象不受正常dealloc流程管理,释放时机和普通成员变量不同,所以使用时要格外注意内存管理策略。
方法交换,也就是Method Swizzling,考到的概率非常高。交换之后,两个方法的IMP就互换了。常见的问题是:交换方法应该在什么时候做?答案是load方法里,并且要用dispatch_once保护,确保只执行一次。还要注意父类方法不能随便交换,否则子类行为会变得不可预测;也不要重复交换同一个方法,否则可能出现递归调用死循环。这些细节在做动态埋点、AOP框架时特别重要。
动态添加方法的题,一般是问“如果一个对象收到未知方法会怎么样”。除了走消息转发流程,你还可以在resolveInstanceMethod:里用class_addMethod动态添加实现。需要熟悉class_addMethod的参数含义:cls、sel、imp、types。其中types是方法签名,用编码字符串表示参数和返回类型,这曾经在热修复方案里非常流行。
3.3 KVO与KVC:理解黑魔法的边界
KVO是iOS面试里绕不开的内容,常考的是底层原理。系统在第一次对某个对象的属性添加观察者时,会动态创建一个子类,名字通常带有NSKVONotifying_前缀,然后把原对象的isa指针指向这个子类。子类内部重写setter,在设置新值前后调用willChangeValueForKey:和didChangeValueForKey:发送通知。这就是为什么直接修改成员变量不触发KVO,而通过setter修改才会触发。遇到“KVO没响应”的问题,最先排查的就是有没有走setter方法。
KVC的查找规则也要记清楚。调用setValue:forKey:时,系统会先找setKey:方法,找不到再看有没有_key或key的实例变量,再不行就调用setValue:forUndefinedKey:,如果这个方法也没重写,就会崩。取值顺序类似:先找key、isKey、_key等方法,再看实例变量。这套规则在笔试题里经常以读代码的形式出现,让你判断某行代码会不会crash,没背熟很容易选错。
4. 手写编程题与开放题的拿分思路
4.1 手写单例:别只写一个sharedInstance
单例可能是手写题里出现频率最高的一道。标准答案是dispatch_once版本,但只写一个sharedInstance只能拿基础分。面试官真正想看的是你会不会把“唯一实例”这件事做严谨,比如在ARC下重写allocWithZone:、copyWithZone:、mutableCopyWithZone:,防止别人通过alloc或copy再创建出第二个实例。虽然业务开发中不一定要求这么严格,但笔试里写到这个层面,至少说明你理解单例的本质。
实现时要注意线程安全。dispatch_once能保证初始化阶段只执行一次,而且性能最高。不要为了省事用@synchronized来做初始化,那会让所有访问单例的线程都去抢同一把锁,效率很差。下面是当年比较标准的一份写法:
@interface APIManager : NSObject + (instancetype)sharedInstance; @end @implementation APIManager + (instancetype)sharedInstance { static APIManager *instance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[APIManager alloc] init]; }); return instance; } + (instancetype)allocWithZone:(struct _NSZone *)zone { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [super allocWithZone:zone]; }); return instance; } - (id)copyWithZone:(NSZone *)zone { return instance; } - (id)mutableCopyWithZone:(NSZone *)zone { return instance; } @end这里需要说明的是,实例变量需要声明成静态变量,并配合allocWithZone:里的dispatch_once,才能保证alloc路径也被拦截。
4.2 算法题:数据结构基本功得分不靠背题
在线笔试的算法题通常不会特别偏,但也不会简单地让你写个冒泡排序。比较常见的方向有字符串反转、链表反转、数组去重、二分查找、二叉树层次遍历、最大子序和等。以2018年前后的难度来看,LeetCode的简单到中等题基本够用,关键是能在在线编辑器里写出能跑、边界条件正确的代码,而不是只会背题。
拿反转链表举例,核心思路是三个指针prev、curr、next,遍历时不断改变next指向。很多人在纸上画得明白,一写到代码就漏了边界:比如链表为空、只有一个节点的情况。如果题目要求时间复杂度O(n),空间复杂度O(1),你得在代码或注释里体现出来。在线判题系统对代码正确性要求很高,别以为只写思路不写代码也行,有些卷子的编程题必须全部跑通才能拿分。
还有一种策略是用运行时间和空间来换正确性。在线笔试环境里,正确性永远比炫技重要。先写一个能过的朴素解法,再考虑优化。不要一开始就挑战最优解,结果边界条件没处理好,最后连基础分都丢了。我当年见过不少同学栽在这个心态上。
4.3 开放题设计:图片缓存库怎么答才像老手
开放题一般不会问“你背过哪个第三方库”,而是问“你会怎么设计”。最经典的题目是设计一个图片缓存库。这个题目的最佳切入点其实是SDWebImage的架构,但你不用提库名,直接讲设计思路就行。
回答时最好分层来讲。第一层是内存缓存,用NSCache而不是NSMutableDictionary,因为NSCache支持自动淘汰、线程安全,在内存紧张时会自动释放对象,这是很重要的优势。第二层是磁盘缓存,负责持久化,常见做法是把图片数据写到沙盒的Caches目录里,文件名不建议直接用URL字符串,因为URL里有斜杠和其他特殊字符,最好用MD5或哈希值做文件名。第三层是网络下载,用NSURLSession发请求,下载前先查内存缓存和磁盘缓存,命中就直接返回,避免重复下载。
还有一个加分的细节是并发控制。同一个URL出现在多个TableViewCell里,如果每个cell都发起一次网络请求,既浪费流量又卡界面。可以考虑用一个以URL为标识的下载管理容器,相同URL的下载任务只发起一次,所有等待的调用方通过回调或通知接收结果。另外图片解压和渲染也很关键,大图在主线程解码会掉帧,一般放到子线程处理。这个点在性能优化类开放题里非常加分。
5. 高频易错细节与备考点滴
5.1 一份容易丢分的细节速查表
把平时收集的高频送命题整理成速查表,方便临考前再看一眼:
| 考点 | 常见错误 | 正确理解 |
|---|---|---|
| weak与assign | 认为两个一样 | weak对象释放后自动置nil,用于对象;assign多用于基本类型 |
| copy语义 | 以为copy一定产生新对象 | 不可变对象调用copy是浅拷贝,可变对象调用copy是深拷贝;mutableCopy一定是深拷贝 |
| 消息转发 | 找不到方法立即崩溃 | 先进入resolveInstanceMethod等转发流程,全部未处理才崩溃 |
| block属性 | 用assign修饰 | 应该用copy或strong,保证block从栈上搬到堆上 |
| NSInteger | 以为固定4字节 | 64位下为8字节,与int混用时要注意溢出问题 |
| 深拷贝浅拷贝 | 以为copyWithZone都是深拷贝 | 要看具体类是否实现了NSCopying协议及协议行为 |
5.2 笔试后的面试延伸准备
笔试通过之后,真正的挑战才刚开始。面试官通常会把笔试错题拿出来追问,同时围绕简历上的项目深挖。比如简历里写过用了SDWebImage,那就要能说清楚它的缓存策略和下载流程;写过做过性能优化,就得能说出Instrument里Leaks和Allocations怎么用,遇到内存泄漏怎么定位。这个阶段不要疯狂背面试题,多问自己“为什么这样写”和“如果不这样写会怎样”,效果比什么资料都管用。
我当时的做法是把笔试错题整理成一个文档,每题后面补一段原理解释,再用自己的话重新讲一遍。这个过程很痛苦,但收获也最大。很多知识点当时以为自己懂了,真正写下来才发现逻辑是断的。面试前两周把这些题反复过两遍,回答问题时就顺多了。
5.3 备考时间安排与资料参考
如果目标是互联网大厂的iOS开发岗,备考时间建议安排在两个月左右。第一个月把Objective-C语言基础和底层原理过一遍,包括消息机制、Runtime、内存管理、多线程;第二个月开始刷算法题、复盘笔试错题,同时投简历。刷题频率建议每天两到三道LeetCode,保持手感和对边界条件的敏感度。资料方面,Objective-C方向看《Objective-C高级编程:iOS与OS X多线程和内存管理》和《Effective Objective-C 2.0》就够了,这两本覆盖面很稳。如果当时你已经开始写Swift,Apple官方文档和WWDC Session也是值得长期跟的内容。
我对“2018迅雷校园招聘iOS在线笔试B卷”这类回忆版题目总体上是有好感的,它们像一面镜子,照出的是某一个时间段里行业对iOS工程师的普遍期望。现在偶尔帮学弟学妹辅导时,我常说不要迷信题库,要把题目背后的原理拆开。这张卷子考的东西到现在依然有效,因为苹果再怎么改API,对象、引用计数、消息机制、Runtime这些底座不会变。笔试真正的意义不是让你把题目背下来,而是逼你把这套底层逻辑彻底打通。我当年也是靠突击刷题换来面试机会,但真正让我在后来写代码时不慌的,反而是静下心把weak表和消息转发读明白的那几个下午。