2023年小满春招第二批iOS研发岗笔试,我前后帮好几个朋友做过复盘,自己也专门把题目拆开重新做了一遍。今天把这些观察整理出来,希望能给正在准备iOS面试的人一个清晰的路标。这套笔试覆盖了OC底层的Runtime和内存管理,也涉及并发、RunLoop、网络层、架构设计、性能优化乃至Swift混编,既有纯记忆点,也有需要现场推演的场景题。无论你是应届生还是工作两三年的开发者,都可以拿它当一次自检。
为了不剧透原题,我按考点模块来拆,尽量还原当时我看到的答题思路,把每类题背后的考察意图和候选人的常见失误都讲清楚。内容偏底层,但我会用尽量直白的方式说明白,边讲边给实操建议。
1. 这批笔试的整体考察思路与设计侧重
1.1 第二批笔试的定位差异
春招笔试通常不止一批,第一批往往承担“广筛”的任务,题目会偏基础、偏概念判断,只要数据库、网络、iOS基础有大问题就被刷掉。到了第二批,竞争池已经收缩了很多,出题人更关注的是“有没有深度理解能力”和“能不能在真实现场解决复杂问题”。
我在复盘这套题时明显感觉到,硬背八股文是过不去的。比如某个题看起来在问NSDictionary的底层结构,但再往下追一层就会牵扯到哈希表实现、isEqual:和hash的关系、可变与不可变容器的存储差异。如果只会背“NSDictionary用哈希表实现”,到第二个追问就卡住了。这种答法在第一批也许能混过去,在第二批很危险。
另一个特点是题目之间会互相呼应,前面某道题留下的信息,后面场景题会再次用到。这要求你答题时保持一致性。比如内存管理部分的答案,会影响你对循环引用场景题的处理方式。如果前面说“weak能解决循环引用”,后面却答不出weak在什么场景下会失效,批卷的人立刻能看出你的理解是碎片化的。
第二批笔试对候选人的时间分配也有隐性的考验。题量不算少,但分值并不均匀。我见过好几个候选人前面几道主观题写得很长,从架构扯到音视频,结果后面两道代码题只剩十几分钟,连核心思路都没写完整。这在笔试里非常吃亏。
1.2 考点权重与答题策略
如果把这套笔试的考点按出现频率和分值排一下,大概是这样的顺序:
| 考察方向 | 出现形式 | 推荐投入时间占比 |
|---|---|---|
| OC语言与Runtime | 概念题 + 代码推断 | 25% |
| 内存管理与Block | 改错题 + 循环引用场景 | 20% |
| 并发与RunLoop | 概念题 + 多线程场景题 | 20% |
| 网络层与架构设计 | 设计题 + 开放性作答 | 15% |
| 性能优化与Swift | 指标题 + 混编题 | 15% |
| 综合排查 | 场景复盘 | 5% |
这个占比说明,底层基本功仍然是大头,架构和网络虽然分值不低,但更偏“表达与取舍”,不需要写太多代码。所以答题策略上,我建议先把语言、内存、并发这三块啃透,再花时间整理架构题的答题框架。
时间分配也很关键。拿到试卷先快速浏览一遍,把会做的题在脑海里面标记好,先做代码题再做开放性设计题。代码题是硬分数,开放性题只要结构合理、表达清晰,分差不会太大。我自己做这套卷子时,是倒着做的,先把后半部分的代码改错和场景题处理掉,再回头写概念题。原因很简单,概念题很多是纯记忆,做完了容易被后面的时间压力影响心态。
2. 核心考点拆解:iOS语言与内存管理部分
2.1 OC Runtime 底层题怎么答才不显得背题
Runtime在iOS笔试里几乎必考。常见问法包括:消息发送的流程是什么?objc_msgSend做了哪些事?isa指针的作用是什么?class和meta-class的关系是什么?
这类题想拿高分,不能只把流程背出来,还要解释“为什么这样设计”。我的答题套路是:先把流程一句话带过,消息发送会先查isa指向的类,再查方法缓存,缓存没命中就查方法列表,再向上查父类,最终没找到会走消息转发机制。然后立刻补一段自己的理解,说明这个流程其实是一种动态查找,让对象在运行期可以改变行为,这也是OC能实现Method Swizzling、KVO这些“魔法”的根本原因。
再往下,要能接住追问。笔试里有一道小题是问“objc_msgSend是直接调用还是递归查找”。正确答案是直接拿到IMP然后跳转,这个过程已经由编译器生成的汇编代码完成了方法查找和跳转,而不是调用函数去递归遍历方法列表。很多候选人在这里会犹豫,说明平时只看了流程图,没看底层实现。
补充一个容易踩的坑:objc_msgSend在x86_64和arm64架构下,参数传递规则不一样,stret(结构体返回)的场景也有差异。笔试不一定会问得这么细,但如果你主动提到“不同架构下消息发送的签名不同”,会让批卷人觉得你不是纯背诵,而是真看过相关博客或文档。
关于isa指针,现在的实现已经是指针或非指针联合体了。非指针isa会把引用计数、weak标记等信息压缩到同一个64位空间里,这就是为什么在老版本的Runtime源码里能看到isa_t这个联合体。答题时可以提一句:isa不单纯指向类对象,它还能携带对象的部分内存管理状态,这是为了节省内存和提升缓存命中率。
2.2 内存管理的高频变形题
内存管理在第二批次笔试里出现得很刁钻,不会直接问“ARC是什么”,而是给你一段代码,让你判断是否有内存问题。
典型案例是这样的:
- (void)viewDidLoad { [super viewDidLoad]; NSMutableArray *array = [[NSMutableArray alloc] init]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [array addObject:@"hello"]; }); }问:array会在block执行前被释放吗?
答案是不会。Block会对捕获的__strong对象做一次copy持有,array在block内部被强引用,所以它的生命周期会被延长到block执行完毕。但如果你在block执行前手动把array设为nil,那就另说,说明外部引用被断开了。这个点还可以延伸:如果array是用__weak修饰的,block内部捕获时不会强持有,3秒后它可能已经释放了,给一个已经释放的对象发消息会怎样?这里就牵扯到野指针的排查思路了。
内存管理部分还有一个高频变形题:autorelease对象什么时候释放?很多候选人背了“runloop循环结束时”,但不够准确。ARC下,一个autorelease对象通常会在当前autoreleasepool被drain时释放,而主线程的runloop会在每次事件循环结束后自动drain一次。如果是在自己创建的@autoreleasepool里,那你得知道它可以手动控制释放时机。把这个逻辑链答出来,比单纯背结论要好得多。
关于内存问题的排查,笔试里出现了一道类似“怎么定位循环引用”的题。最好的答案不只是“用Instruments的Leaks”,而是结合LLVM的静态分析、Instruments的Leaks模板、debug时打印dealloc日志、以及使用Malloc Stack追踪堆栈。答出两三种手段,并且能说明各自适用场景,就能拿到不错的分数。
2.3 Block 循环引用:从原理到解法
循环引用是iOS面试的“万金油”考点,但在小满这批笔试题里,它换了一层皮:代码里既有self,又有_ivar,还有局部变量,问哪种写法会产生循环引用。
typedef void (^Block)(void); @implementation MyClass { NSString *_name; } - (void)setupBlock { __weak typeof(self) weakSelf = self; self.block = ^{ __strong typeof(weakSelf) strongSelf = weakSelf; NSLog(@"%@", strongSelf->_name); }; } @end这段代码整体上是安全的,但它的安全程度取决于weakSelf是否被strongSelf承接。如果不承接,在block执行期间self可能已经释放,访问_name就是野指针;如果承接了,strongSelf在block执行期间保证了self存活,退出block后释放,不会形成新的循环引用。答这个题的关键是:__weak打破循环,__strong保证执行期安全,两者是配合关系,不是二选一。
还有一类隐藏题:self.block里捕获了父对象的属性self.parentView,而不是self,这样会循环引用吗?很多人以为只要不写self就不会循环。实际上如果parentView是self的一个强引用属性,而block又被self持有,那么self -> block -> parentView -> self依然形成环。笔试里这道题考的就是“不要把引用链条看得太浅”。
3. 并发与RunLoop:最容易问穿的模块
3.1 GCD 与 NSOperation 的选择题
并发这块最经典的问题是“什么时候用GCD,什么时候用NSOperation”。笔试虽然不要求你写出完整的使用过程,但你需要把选型依据讲清楚。
我给的思路是:如果只是“丢一个任务到后台执行,完成后回主线程更新UI”,GCD是首选,简洁直接。但如果要做任务编排、依赖关系、取消操作、最大并发数控制,NSOperationQueue更合适,因为NSOperation本质是对任务和状态的封装,支持addDependency、cancel、setQueuePriority这些能力。用GCD也能模拟依赖,但需要借助dispatch_barrier或信号量,写起来别扭,还容易埋坑。
另外一道典型的题是“dispatch_queue_create创建的队列是串行还是并发”。答案是:通过dispatch_queue_create第二个参数传入DISPATCH_QUEUE_SERIAL就是串行,传入DISPATCH_QUEUE_CONCURRENT就是并发,如果传NULL默认是串行。这个点本身不难,但它经常和“dispatch_get_main_queue和主线程的关系”混在一起问,你要能区分清楚:主队列是一个特殊的串行队列,它绑定了主线程,但串行队列并不一定运行在主线程。
3.2 RunLoop 机制与线程保活
RunLoop这部分的笔试题目,问法是“如何让一个后台线程长期存活并接收任务”。很多候选人第一反应是“用performSelector:onThread:”,但答不出后台线程的RunLoop需要手动开启。
后台线程默认不自动开启RunLoop,所以你想让它反复接收任务,得自己启动:
- (void)threadEntryPoint { @autoreleasepool { NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } }run方法是永久运行,只有收到停止通知才会退出。如果你在业务代码里看到“子线程的RunLoop必须add一个source或timer才会跑起来”,这个说法不完全准确,run确实会尝试运行,但因为没有输入源或timer,runloop会直接返回,造成线程无事可做。所以为了让它“活着”,必须注册一个NSMachPort或者添加一个timer。
答题时可以延伸一下RunLoop的几种运行模式。NSDefaultRunLoopMode在发生界面滑动时会暂停,UITrackingRunLoopMode用于跟踪触摸事件,NSRunLoopCommonModes是一组可标记的模式集合。如果你在主线程用NSTimer刷新UI,不加NSRunLoopCommonModes,滚动的时候定时器会卡顿。
3.3 多线程同步与资源竞争场景题
这块的场景题很典型:有多个网络请求并发回来,需要等所有请求完成后再统一刷新UI,怎么实现?
参考答案有好几种:
dispatch_group_t+dispatch_group_notifydispatch_semaphore_t,设置初始信号量为0,每个请求完成时signal,最后waitNSOperationQueue设置maxConcurrentOperationCount,再设置依赖关系
dispatch_group是最常用的。但笔试往往会给一个坑:如果在子线程执行dispatch_group_wait,会阻塞当前线程,如果这个方法很不巧跑在主线程,就会卡界面。所以更稳妥的做法是用dispatch_group_notify,直接指定回调队列。
另一个容易错的是信号量的signal和wait顺序。我见过有候选人把signal放在请求发起前,这样最终结果根本不是等待N个请求,而是直接通过了。记一个简单的原则:signal必须放在任务完成后的回调里,wait放在需要等待所有任务完成的那个线程上。
4. 网络层与架构设计:从答题到落地
4.1 网络层设计思路:URLSession 之上还有什么
网络层的笔试通常不是让你写请求代码,而是给你一个需求:App需要统一处理登录态失效、统一参数签名、统一错误提示,还要支持请求重试和取消,你怎么设计网络层?
这是个开放题,不用答成唯一标准,但至少要体现分层思想。我的答案是:
底层用URLSession做真实网络传输,但上层不要直接依赖它,而是封装一个NetworkManager。NetworkManager负责把上层传进来的Request对象拼接成URLRequest,加上公共参数、加密签名、用户token,通过中间件依次处理,最后发出去。Response统一解析,把网络错误、业务错误、解析错误分开返回。
强调一个点:超时时间设置。URLSessionConfiguration里有一堆参数,timeoutIntervalForRequest和timeoutIntervalForResource很多人混淆。前者是请求发起后等待返回的超时时间,后者是资源加载的整体超时。在实际业务中,上传接口的超时要明显大于普通GET请求,不能用一个全局配置套所有接口。如果笔试里问到性能相关,这个细节很加分。
4.2 架构选型:MVC/MVVM/组件化的判断标准
架构题几乎是所有中大型公司笔试的常客。小满这套题里有一道:“一个模块从MVC演进到MVVM,你觉得解决了什么问题,又引入了什么新问题?”
MVC的核心问题是Controller太重。UI事件、数据请求、模型转换、页面跳转都堆在一起,代码超过一千行以后,维护成本直线上升。MVVM把数据加工和业务校验放进ViewModel,页面只负责绑定和渲染,这样Controller的代码量会减少,逻辑也更方便测试。
但MVVM不是万能的。引入ViewModel后,数据流会变复杂,bind逻辑如果不规范,照样会出现难以排查的问题。比如列表页的数据源是由多个请求合并得到的,ViewModel内部必须理清请求顺序和失败重试逻辑,否则页面很容易处于“缺数据”的状态。最好的答题方式是:不站队,说清楚什么场景下用MVC足够,什么场景下值得上MVVM。
组件化也是常见追问。答题时不要一上来就说“按业务拆模块”。组件化最重要的是回答“拆到什么粒度”和“模块之间怎么通信”。拆太细会导致工程文件爆炸,拆太粗又起不到隔离作用。通信方案通常有Router注册、Block回调、NSNotification解耦,具体选型要看业务是强依赖还是弱依赖。
4.3 常见业务场景的架构取舍
笔试里有一道场景题:首页要同时展示多个业务模块的数据,有的来自缓存,有的来自网络,有的来自推送预取,你会怎么设计这个数据流?
这种题没有固定答案,但候选人答得好不好,差距很大。正确思路是先区分数据来源和更新时机,再设计统一的ViewModel聚合层。每个子模块提供自己的数据Provider,首页的ViewModel通过组合的方式把Provider的数据聚合起来,分别监听变化,再一次性刷新UI。
能主动提“缓存策略”的候选人会占优势。比如内存缓存和磁盘缓存分别放什么,内存缓存用什么淘汰策略,磁盘缓存要不要做版本隔离。NSCache适合做内存缓存,它可以在系统内存紧张时自动释放,磁盘缓存则要考虑文件大小和过期时间。答题时把“缓存命中率”和“缓存新鲜度”这对矛盾提出来,能体现你对业务的思考。
5. 性能优化与Swift混合开发
5.1 启动时间、卡顿、内存水位优化
小满这批笔试的性能优化题比较接地气,没有问底层汇编级的问题,主要围绕启动速度和列表流畅度。
启动时间优化的答题框架可以分三步:度量、拆解、治理。首先用Instruments的App Launch模板拿到启动耗时,拆成main函数前和main函数后。main前主要看动态库加载数量、+load方法、static初始化。main后看AppDelegate里做了什么耗时操作。一个很常见的坑是:很多初始化代码写在didFinishLaunchingWithOptions里,而且是同步执行,这会让首屏迟迟展示不出来。优化手段是延迟加载、放到子线程、用NSURLSession预加载数据。
卡顿优化要围绕掉帧原理回答。屏幕每秒刷新60次,每次刷新间隔16.7ms,如果主线程在某个RunLoop周期内的任务超过这个时间,就会出现掉帧。答题要点是区分CPU和GPU的耗时。CPU负责布局计算、图片解码、文本排版,GPU负责合成渲染。常见的卡顿原因是主线程做了大量IO、复杂布局、离屏渲染。解决手段包括AsyncDisplayKit/Texture方案里的异步布局、CornerRadius合并、drawRect重写等。
内存水位优化的关键是回答“怎么发现内存压力”和“怎么降低内存峰值”。发现层面可以用Xcode Memory Gauge和Instruments Allocations,降低峰值可以从图片采样、@autoreleasepool包裹循环、避免在循环内创建大量临时对象入手。
5.2 Swift与OC混编的考察深度
2023年的iOS笔试题,Swift已经不局限于“会不会用”,而是更看重“能不能理解混编工程”。
混编的底层原理其实不复杂:OC和Swift都依赖runtime,但在工程里是两套编译单元。Swift文件暴露给OC需要生成-Swift.h头文件,OC暴露给Swift需要Bridging-Header.h。笔试里考过“@objc关键字什么时候必须加”。答案是需要暴露给OC调用的Swift方法或属性必须加@objc,如果还要被OC运行时动态派发,可能还需要@objc dynamic。
另外,Swift的泛型和OC的id之间不能直接对应,OC里只能把Swift泛型类当作id来用,这会导致类型信息丢失。如果混编项目里要传[String: Any]字典给OC,通常要转成NSDictionary。类似这种“类型转换的坑”,在笔试里只要踩准一两个点,就能明显拉开和其他候选人的差距。
5.3 系统能力边界与跨端思考
还有一个让我印象深刻的题,问的是“如果核心模块要用跨端方案,iOS保留哪些部分”。这道题其实不是考技术选型,而是考你对系统能力边界的判断。
候选人如果只回答“用Flutter重写整个App”,我觉得不是最优解。比较合理的回答是:把UI层和轻量业务逻辑交给跨端框架,但涉及系统级能力的地方保留原生实现。比如蓝牙、相机采集、推送token获取、账户安全模块,这些对系统API依赖很强的地方放到原生,否则跨端桥接会变成维护噩梦。
答题时如果能说一句“每次桥接调用都有成本,频繁跨线程/跨语言传大量数据也会成为性能瓶颈”,会让批卷人觉得你有真实项目经验,而不是只会看教程。
6. 笔试中的常见问题与排查技巧实录
6.1 候选人容易踩的答题坑
结合我看了很多份答卷的经验,踩坑主要集中在下面几类:
| 坑点 | 具体表现 | 改进方法 |
|---|---|---|
| 概念回答过短 | 只写结论,不写推导 | 补充一句话“为什么这样做” |
| 代码题不写异常分支 | 只写happy path | 主动考虑失效场景 |
| 开放性题没有结构 | 想到哪写到哪 | 用“思路-方案-取舍”三段式 |
| 时间分配失衡 | 前面题写太多,后面空着 | 先扫全卷,后做代码题 |
| 忽略笔试题干细节 | 没发现需要合并两个请求 | 圈出题目里的关键约束 |
最可惜的一类情况是,代码题整体思路是对的,但没考虑线程安全问题。比如多个请求并发刷新同一个数据源,如果没有保护,会出现数据竞争。笔试中哪怕只是用一句话提示“这里需要加锁或用串行队列保证数据源安全”,也会比单纯写业务逻辑更强。
6.2 答题时如何控制节奏和深挖程度
很多候选人担心笔试答得太浅,就一直往外延伸,结果适得其反。我的经验是:一道题写到体系完整就够了,不要无限扩展。什么算完整?就是你提出了结论、给出了理由、补充了一两个边界条件,然后就收住。比如内存管理题,答到“ARC下编译器自动插入保留和释放,因此不需要手动管理引用计数”已经能拿基础分,再补充“但Core Foundation对象不归ARC管,需要手动CFRelease”,这就是加分项。加分之后就不用再往深挖了,除非你确定有余力。
如果遇到不会的题,不要空着,至少要写出你的初步推断。笔试题很多时候考的是分析能力,不是唯一的正确答案。空着等于放弃,写一个你认为可能的机制,哪怕不完美,也能让批卷人看到你思考的路径。
我建议候选人平时刷题时,按两套方式练习:第一轮,按知识点刷,把OC、Swift、内存、网络、并发逐个击破;第二轮,直接做整卷模拟,限定时间,模拟真实笔试的节奏。第二轮比第一轮重要,因为很多人在单点知识上有积累,但缺乏组合运用和取舍判断的训练。
最后说点个人体会
做完这套笔试题,我最大感触是:iOS面试已经很少有那种“背一道是一道”的简单题了,出题人越来越关心你能不能把知识点串成业务方案。比如内存管理、RunLoop、多线程,单独看都是老生常谈,但放在“网络请求并发回调刷新UI”的场景里,就能看出你对整套机制的理解深度。
如果你正在准备类似的笔试,我建议把重点放在场景化练习上,不要只盯着单一概念的结论。多做那种“给一段代码,找出问题并修复”的题目,多想想“为什么这样设计”。另外就是控制答题节奏,模拟几套整卷,别让分值最高的代码题毁在时间不够上。这套方法无论是对春招、秋招,还是社招面试,都很受用。