做了这么多年iOS开发,每年春招秋招都会帮团队出题或者面试候选人。2023年小满这条线我印象挺深,第三批笔试整体难度比前两批高了一截,不再只问语法和API调用,开始往原理、架构和实战排障方向倾斜。今天把这批笔试的复盘整理出来,从考点拆解到答题思路,再到我后来对照候选人反馈补的一些经验,一次性聊透。无论你是正要投实习的在校生,还是工作两三年想跳槽的同行,这份内容都能帮你把iOS知识体系重新梳理一遍。
1. 2023年度小满春招iOS研发岗第三批笔试整体设计与考点拆解
1.1 批次定位与题目设置逻辑
第三批笔试安排在春招中后段,这个时间点的候选人通常已经过了一轮简历筛选和部分电话面试,所以笔试不再考察“知不知道”,而是考察“懂不懂”。整张卷子分为六个模块:Objective-C与Runtime、Swift与iOS内存管理、UIKit布局与AOT、网络与数据持久化、多线程与并发、架构设计。每模块约三到四道题,题型以简答加场景设计为主,部分题目要求直接写代码或补全代码。
从出题逻辑来看,这套笔试明显参考了当时字节、美团、快手等一线大厂的中级开发考核标准。相比前两批,增加了一个很关键的变化:所有题目都带了一个“场景壳”。比如不直接问“block有几种类型”,而是问“在NSOperationQueue的block中修改外部局部变量,为什么报错,如何修改”。这种问法对候选人很考验,因为背八股文的人容易答非所问,真正写过代码的人反而能快速定位到__block关键字。
1.2 需要提前掌握的知识体系
结合我这些年的面试经验和实际带团队的经历,备考这套笔试前至少要摸清以下知识框架:
- Objective-C内存管理:ARC/MRC、循环引用、weak底层原理、autoreleasepool、dealloc时序
- iOS运行时体系:isa指针、类对象与元类、消息发送/转发、关联对象、Method Swizzling、KVO底层实现
- Swift与OC的差异化记忆:值类型与引用类型、协议派发、String/Array底层、闭包捕获列表
- UIKit核心机制:AutoLayout约束计算、UIStackView、UICollectionView自定义布局、离屏渲染、UIView和CALayer关系
- 网络与存储:HTTP/HTTPS握手、NSURLSession、DNS解析、ATS、SQLite与CoreData选型、沙盒目录
- 并发编程:GCD、NSOperation、线程锁、内存屏障、并发队列和串行队列、dispatch_group/信号量
- 架构与工程化:MVC/MVVM/组件化、模块化、大产品性能监控、崩溃分析、包体积优化
下图是我自己梳理的各模块分值占比,方便做复习时间分配。
2. 核心细节解析:Objective-C、Swift与内存管理高频考点
2.1 消息机制与isa指针的底层逻辑
这批笔试中有一道让我印象深刻的题:“解释实例方法、类方法分别存在哪里,消息发送的关键路径是什么”。这题看起来基础,实际很考验掌握深度。很多人知道方法存在类对象里,但说不清类方法存在元类里,更难说清对象object_getClass(obj)和obj.class的微妙区别。
- 实例方法存在对象的类对象中,即
obj->isa指向的Class结构中,这个Class的方法列表存储可供该实例调用的实例方法。 - 类方法存在元类中。调用
[SomeClass classMethod]时,底层走的是objc_msgSend(objc_getMetaClass("SomeClass"), @selector(classMethod)),真正接收消息的主体是元类对象,而不是类对象本身。 - 如果没有命中缓存和方法列表,会走消息转发三件套:
resolveInstanceMethod、forwardingTargetForSelector、forwardInvocation。
实际开发中用到这套知识最多的场景是热修方案、防崩溃SDK设计和AOP埋点。比如,我做崩溃防护SDK时,就是利用forwardInvocation把未识别的方法安全吞掉,避免线上Crash。这个方法需要特别注意一点,如果同时做了Method Swizzling,务必要保证IMP的线程安全。
2.2 Autoreleasepool与dealloc时序误区
很多候选人拿到“自动释放池什么时机释放”这道题,张嘴就是“runloop循环结束”,严格说这是不对的。更准确的说法是:
- 在没有显式创建
@autoreleasepool的情况下,系统会在线程的runloop每次迭代结束时自动创建和释放一个自动释放池。 - 但注意,释放池的释放并不是runloop退出,而是runloop进入sleep之前、处理事件源之后发生的。
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__等Observer回调批次,就会触发_objc_autoreleasePoolPop。
另外还有个高频误区:dealloc中不能调用属性的setter方法。原因是对象处于正在释放状态,setter有可能触发KVO通知,引起额外复用的风险。正确做法是直接对实例变量做置空操作。
2.3 Swift闭包捕获列表与循环引用的场景案例
这轮笔试有一道代码题,考的是Swift中UIView动画闭包的内存管理。题目大意是:在一个UIViewController里持有NSObject子类的实例,并在该实例的闭包里使用self,问会不会循环引用、怎么解决。
答案除了常见的[weak self],还要注意一个细节:如果只是单次执行的闭包,比如UIView.animate,闭包执行完会释放,就算用self也不会循环引用;但若是存储型闭包,比如属性里面保存了一个闭包,那必须用捕获列表打破循环。很多候选人忽略了这个前提,直接答weak,实际对场景判断是有缺陷的。
Swift闭包捕获列表还有另一个坑:如果捕获列表里写了[unowned self],而self在闭包执行前已经释放,会直接崩溃。实战中我遇到过多起unowned导致的闪退,排查方式是在崩溃堆栈里看到SIGSEGV和闭包关键字基本就能锁定。
3. 实操过程与核心环节实现:UI、网络与存储题解
3.1 UIKit布局与AutoLayout自适应方案
“UIStackView在动态列表中的使用”是这套笔试的一道重点题。实际开发中UIStackView确实能极大简化约束,但它也有两个容易踩的坑:
- 在UIScrollView里使用UIStackView时,如果stackView的宽度需要跟随内容变化,必须给stackView设置完整的约束链,否则内容无法驱动contentSize。
- UIStackView的
distribution选择。fillEqually会让所有子视图宽度一致,但如果某个子视图内部有需要撑满的label,容易发生压缩或约束报错。正确的做法是根据内容优先级设置setContentHuggingPriority和setContentCompressionResistancePriority。
我当时给候选人的加分点是:能说出用UIStackView配合UICollectionView,利用系统提供的UICollectionViewCompositionalLayout进行更精细的自适应布局。这是iOS 13之后推荐的做法,也是对大型列表最友好的方案。
3.2 网络层:从AFNetworking底层到ATS配置
笔试里的网络题不再是背HTTP状态码,而是考“HTTPS在iOS下如何进行证书校验”和“AFNetworking的证书校验过程”。比较完整的回答流程是:
- 系统层面:NSURLSession在TLS握手阶段会先做服务端证书链校验,如果证书无效直接报错。
- 业务层面:可以自定义
URLSession:didReceiveChallenge:completionHandler:,在代理方法里对challenge做二次校验,比如校验证书公钥、域名、甚至证书Hash。 - 如果使用AFNetworking,它的核心配置在
AFSecurityPolicy,默认是AFSSLPinningModeNone,也就是只做系统校验;想要严格校验可以设置为AFSSLPinningModeCertificate或AFSSLPinningModePublicKey。
还有一道ATS相关的题:为什么iOS9之后要配置Info.plist里的NSAppTransportSecurity。这个是为了强制HTTPS连接。但要注意,不要为了图省事直接NSAllowsArbitraryLoads设为true,App Store审核有较大概率被拒。合理的做法是只对需要兼容的第三方域名做例外处理,并且限制在Debug环境。
3.3 磁盘缓存、数据库选型与沙盒目录解析
这轮笔试还考了一个实战问题:“图片缓存和列表数据缓存分别用什么方案”。最佳实践是分三层:
- 内存缓存:NSCache,注意它和NSDictionary的区别是当内存紧张时系统会自动回收部分缓存。NSCache不是线程安全的,但它是系统封装的线程安全版本,性能比手动加锁好很多。
- 磁盘小文件:基于FileManager按路径存储,适合放用户头像等小图,但需要自己控制淘汰策略。
- 数据库:适合存储结构化数据,比如消息记录、离线缓存。如果数据量大且查询复杂,选SQLite;如果数据量小并且偏好使用ORM,可以选CoreData。要注意CoreData在多线程场景下,必须使用私有上下文或主上下文共享模式,否则会报Crashes。
我建议所有候选人都能把沙盒目录回答清楚:
| 目录 | 用途 | 是否会被系统备份 |
|---|---|---|
| Documents | 用户文档、重要数据 | 是 |
| Library/Caches | 缓存数据,可重新下载 | 否 |
| Library/Preferences | 偏好设置 | 是 |
| tmp | 临时文件 | 否 |
笔试题目通常会问“哪些数据会被同步到iCloud”,这正是坑点所在。
4. 并发编程、RunLoop与安全机制实战解析
4.1 多线程锁选择:从os_unfair_lock到性能对比
笔试中遇到多线程题,最常见的陷阱就是让候选人解释“自旋锁和互斥锁的区别”。虽然OSSpinLock已经在iOS 10被废弃,但很多老项目还在用,需要清楚它的优先级反转问题和替代方案。
os_unfair_lock:苹果推荐的替代自旋锁的锁,如果线程未获锁会进入休眠,不会忙等。pthread_mutex:POSIX标准互斥锁,支持递归锁和条件变量,适合复杂多线程同步。NSLock:是对pthread_mutex的OC封装,效率稍低于直接用底层的。@synchronized:使用对象作为递归锁,方便但代价较高,尤其是性能要求高的场景不能滥用。dispatch_semaphore:信号量,常用于线程数量控制。NSRecursiveLock:解决同一线程重复加锁导致死锁的问题。
实际排障时,我会先用Instruments的Thread Sanitizer进行检测。它能在编译期和运行期检测出数据竞争,然后根据崩溃堆栈判断锁范围内是否有IO操作或耗时操作。如果发现锁内做了数据库查询,那性能一定上不去,正确方案是锁外提前处理数据。
4.2 RunLoop与线程保活的完整流程
题目上画了一个图,要求标注RunLoop在哪个阶段处理Timer、在哪个阶段处理Source。真实开发里,我最常用RunLoop的地方有两个:
- 高性能图片加载。在主线程滑动列表时,通过
CFRunLoopAddObserver监听即将进入休眠的状态,并在该状态批量加载剩余图片,从而保证滑动帧率不掉。 - 常驻后台线程。用
NSThread做长时间任务时,给线程添加一个source或timer,让runloop存活,避免线程执行完就被回收。
补充一个坑:RunLoop不是线程安全的,跨线程操作时必须加锁,并且只能操作处于kCFRunLoopCommonModes的mode。
4.3 iOS签名与证书机制、上架与内部发布流程
这部分笔试不是问怎么在开发者后台点按钮,而是考察背后的原理。完整流程如下:
- 开发者证书:生成CSR文件并上传到Apple开发者中心,经过Apple后台签名后生成
.cer证书。此证书包含了开发者的公钥,私钥保留在Mac的钥匙串中。 - App ID和Entitlements:在开发者中心注册App ID,并配置能力,例如推送、iCloud等。
- Provisioning Profile(描述文件):将证书、App ID和设备UDID绑定生成
.mobileprovision文件,打包时嵌入到App中。 - 代码签名产物:通过codesign将上述组合签名到App二进制和相关资源。系统通过验证描述文件和证书链,来确定App是否被授权在对应设备上运行。
笔试里常问“企业证书和个人证书的区别”。这里必须强调:只有企业开发者账号能创建In-House描述文件,支持内部分发,不需要上架App Store,但该证书无法用于上架。个人开发者账号无法生成用于免上架分发的证书,除非通过TestFlight做外部测试。TestFlight最多支持90天,且受到名额限制。
当年我用企业证书发布内部App时,最怕证书到期。每次到期会导致所有企业包无法打开。后来我写了脚本在证书到期前30天自动发送钉钉提醒,并维护两个证书交替使用,降低断档风险。笔试题目如果问“证书过期了怎么排查”,可以从以下三点回答:
- 查看安装失败日志,解码描述文件里的
ExpirationDate。 - 通过
security cms -D -i embedded.mobileprovision查看描述文件详细信息。 - 系统日志中搜索
provision关键字,会看到“provisioning profile expired”之类描述。
4.4 iOS 26的UIApplication.shared.setAlternateIconName系统确认弹框问题
第三批笔试拿到iOS 26相关的题,正好赶上系统版本新特性。题面问:“调用setAlternateIconName切换App图标时,系统会弹出确认框,如果不想弹框直接切换,应该怎么做”。
实际上,即使在iOS 26之前,setAlternateIconName本身就会弹系统确认框,这是防止恶意切换App图标的措施。iOS 26起,苹果仍然保留这个确认框。而如果确认框弹出时机有误,常见诱因是调用了beginBackgroundTask或CFRunLoopRun导致主线程卡住,导致回调没有回来。
针对这个需求,最好的做法是:
- 在
Info.plist中声明所有可切换的图标名称。 - 调用前确保App处于活跃状态,不要在
applicationDidEnterBackground中触发。 - 如果想实现无感切换,可以在用户点击时先隐藏UI,然后调用切换方法,用预加载的方式确保图标资源已存在,等系统确认框消失后设置过渡动画。
5. 典型错误复盘:OC/Runtime与架构设计避坑指南
5.1 候选人常犯的Objective-C错误
这批笔试批改下来,很多错误非常一致:
- 把
__weak与__block混用。在ARC下,如果block内部需要修改外部变量,必须使用__block;如果只是防止循环引用,用__weak。两者解决的问题不同,可以同时存在。 - 在
dealloc中调用[super dealloc]。ARC环境禁止手动调用,只有MRC才需要,新增的面试者尤其容易混。 - 认为
weak指针指向的对象释放后,指针会自动置空。这句话严格说只在ARC下成立,而且对象内存必须支持弱引用;如果是裸对象内存分配或C++类型的包装,不一定能自动置nil。
5.2 架构设计题:如何设计一个高可扩展的IM聊天模块
这套笔试的压轴题是:“假设你现在要开发一个IM模块,支持文本、图片、语音、视频、系统通知等消息类型,后续还会不断扩展。你会如何设计消息模型和UI展示层”。
5.2.1 设计一个合理的数据模型
正确的做法是采用“协议 + 基类”方式。定义一个MessageModel协议,规定所有消息必须返回messageId、fromUser、timestamp、contentType等字段。具体消息类型各自对应一个类,比如TextMessageModel、ImageMessageModel、VoiceMessageModel。
使用协议的好处是,以后新增一种消息类型,不需要修改已有的列表逻辑,只要新创建一个类并遵循协议,注册到工厂中即可。如果使用一个庞大的基类,每新增一种消息都会污染基类,最终变成上帝类。
面试时可以顺手补充一段伪代码:
protocol MessageModel { var messageId: String { get set } var timestamp: TimeInterval { get set } var contentType: MessageContentType { get } func cellIdentifier() -> String }5.2.2 扩展性更优的UI层设计
消息UI使用UITableView或UICollectionView实现。每个cell根据contentType返回不同的cell类型,用复用ID区分。这里有一个关键点:不要每次在cellForItemAt里判断上百种类型,建议使用工厂模式生成cellViewModel。
// 工厂模式 + (MessageBaseCellViewModel *)viewModelWithModel:(MessageModel *)model;如果业务继续膨胀,可以引入模板方法模式,让每个子类cell负责自己的布局更新。这样新增消息类型时,只修改工厂和新增cell即可。
5.2.3 涉及存储和网络的扩展点
如果IM还要支持离线消息,需要设计本地数据库表。我通常用SQLite存储消息列表,表结构至少包含:
- localId自增主键
- messageId唯一索引
- from/to/roomId
- contentType
- contentData二进制
- status字段(发送中/成功/失败/已读)
- createTime
发送消息时先写库,再走WebSocket服务端;成功后更新状态。如果集成第三方云服务,比如融云、环信,它们的SDK通常直接提供消息缓存,本地只需做一层薄封装,避免重复造轮子。
5.3 内存问题定位:从Leaks到Allocations
笔试中有道实战题给了段代码,要求找出内存泄漏。核心就是养成先运行Leaks工具,再分析Allocations历史的习惯。Leaks适合定位循环引用和未释放对象,Allocations适合定位大块内存增长。
针对循环引用,最常用的是在dealloc方法里打印日志:
- (void)dealloc { NSLog(@"%@ dealloc", NSStringFromClass(self.class)); }如果跳转页面返回后没有打印对应的dealloc,说明对象仍然被持有,接下来需要检查block、delegate、NSTimer等持有关系。
6. 常见问题与排查技巧实录
6.1 遇到“unrecognized selector sent to instance”怎么办
这是iOS开发中最常见的崩溃类型之一。排障思路如下:
- 找到崩溃日志中的
selector名称和class名称。 - 检查是否在分类中实现了该方法,但目标类没有实现。
- 检查是否在子类中声明了属性,但没有自动合成实例变量。
- 检查消息是否发给了僵尸对象。开启Zombie Objects后,详细堆栈能给出释放前的调用顺序。
如果项目中大量使用字符串转方法名调用,可以用NSSelectorFromString包装一层安全检测,防止把不存在的selector传给系统。
6.2 页面卡顿的定位与优化实践
工作中遇到过很多“页面滑动掉帧”的案例。通用优化流程是:
- 打开Instruments的Core Animation调试,观察FPS。
- 主线程耗时操作通过Time Profiler定位。
- 优先检查是否有大量离屏渲染,例如对图片设置了太大的
cornerRadius,或在Cell上频繁使用shadowPath。 - 将图片解码从主线程移到子线程,使用
CATiledLayer或YYAsyncLayer异步绘制。 - 对列表数据源做增量刷新,不要全量reloadData。
6.3 关于iOS 17/18的兼容性适配问题
如果目标用户涉及iOS 17以上,有两点值得提前准备:
- iOS 17对隐私权限进一步收紧,特别是相册和定位。如果App没有合理使用系统弹窗,审核会被拒。
- iOS 18起,WebView与本地资源的交互增加沙盒限制,必须正确配置
WKWebView的loadFileURL,并允许读取临时目录。
7. 我的备考建议与项目管理反思
7.1 准备周期和刷题方法
我建议候选人把备考周期控制在三到四周。前两周以知识点扫盲和手写核心代码为主,第三周开始集中做真实笔试模拟,第四周专门整理错题,尤其是Runtime和并发相关。
具体执行:
- 每天手写3个API实现或场景代码,比如自定义UIStackView的自适应布局、线程安全单例。
- 每两天用Instruments对示例项目做一次内存和卡顿分析。
- 参加社区模拟面试,让同伴随机抽题考察表达能力。
7.2 面试答题技巧
笔试中遇到不会的题目,不要空着。尽量写出自己的思路,包括:
- 问题发生的可能原因。
- 你打算如何定位。
- 可能的解决方案和备选方案。
考官更看重分析问题的框架,而不是一次性答出完美答案。比如“为什么页面启动慢了”,可以分阶段回答:main函数之前做了什么、main函数之后到首帧渲染之间做了什么、网络请求是否有阻塞。
7.3 总结经验:从一份笔试看iOS工程师能力模型
这轮笔试最核心的导向不是筛选记忆力,而是工程化思考能力。候选人需要明白:iOS开发早就过了“会用UIKit写页面”的阶段,现在需要的是理解底层机制、掌握性能调优、能独立做架构决策的工程师。
我强烈建议在工作中养成写技术复盘的习惯,不管是崩溃排查、性能优化还是组件封装,及时记录,并把每个“踩过坑”的点沉淀成文档或小Demo。等到换工作面试时,这些都是你最宝贵的素材。
最后再分享一个我自己的小技巧:笔试前没必要背大量API文档,但一定要亲手实现几个核心机制。比如手动实现一个KVO替代方案、手写一个线程安全的图片缓存器。这些代码量不多,但能把Runtime、内存管理和并发知识穿透成一条线。无论后台怎么出题,你应对起来都会从容很多。