“货拉拉2018秋招iOS工程师笔试题卷二(A)”——光看这个标题,很多准备跳槽的iOS开发可能心里会咯噔一下。货运物流场景下的App开发,和一般互联网公司的业务系统相比,对实时性、地图交互、弱网处理的要求高出一大截。这套卷子虽然是2018年的秋招题,但iOS开发的核心知识点演进速度并没有想象中那么快,内存管理、多线程、网络层、架构设计这些根上的东西,放到今天依然是面试考察的重点。对于正在准备iOS面试的初级工程师,或者想从业务开发转向基础架构方向的人来说,拆解这套题背后的考点,比单纯刷题更有价值。
这篇文章不打算一道题一道题地给标准答案,那样没意义。我更想做的,是从这套笔试卷子映射出的考察逻辑出发,把iOS开发面试中那些高频、深入、容易翻车的知识点摊开揉碎,讲清楚原理,也讲清楚面试官到底在考察什么。
1. 物流货运App的iOS端,到底在做什么技术活
看这张卷子之前,建议先理解货拉拉的业务场景。货运匹配平台的核心链路是:用户下单、司机接单、货物运输、在线支付、评价售后。这条链路里,iOS端承担的角色远不止一个展示页面那么简单。
- 地图与定位:全程轨迹追踪、司机位置实时上报、围栏计算,这些功能极度依赖地图SDK和系统定位服务的深度结合。
- IM与消息推送:用户和司机的实时沟通、订单状态变更通知,需要WebSocket长连接和APNs推送的双通道保障。
- 弱网容错:司机在行驶途中经常经过地下停车场、隧道、偏远路段,网络状况极差,App必须做好请求重试、数据缓存、离线包管理。
- 性能监控:货运场景下,司机端长期在前台运行,耗电、内存、CPU占用都是关键指标,HomeGuard式的后台保活策略虽然敏感,但合理的前台性能优化是基本功。
理解了这层业务背景,再看笔试题,你会发现它考察的方向其实和业务高度耦合。面试官想找的不是一个只会写UI的工程师,而是一个能在这个复杂场景下独立解决问题的人。
所以这套卷子的知识点分布,基本围绕语言特性、并发处理、网络知识、架构设计和实战经验这几个维度展开。这也是我在带团队面试时最关注的能力模型:基础扎不扎实,决定了你在生产环境遇到诡异问题时有没有排查方向。
2. 语言基础考点:OC特性与内存管理的底层逻辑
2.1 属性关键字不只是“背答案”那么简单
笔试卷子里几乎必考的一类题,就是@property的修饰词选择。atomic和nonatomic的区别、assign和weak的区别、copy和strong的区别,看起来是送分题,但深入问下去,很多人会露馅。
我见过不少简历上写着“精通iOS开发”的候选人,问到atomic时只会说“线程安全”,再往深问一层“它保证的是什么样的安全”就答不上来了。
atomic的原理是在生成getter/setter时加自旋锁,保证的是属性的读写操作原子性,也就是你读到的值要么是旧值要么是新值,不会读到一个中间状态。但它不能保证对象内部属性的线程安全。比如一个NSMutableArray的count和objectAtIndex:操作,即使属性声明为atomic,多线程同时读写这个数组依然会崩溃。真正的解决方案是使用pthread_rwlock或者dispatch_queue做数据同步。
copy和strong的选择也是高频考点。为什么NSString类型的属性要用copy?因为外部可能传入一个NSMutableString,如果属性是strong,这个可变字符串后续被修改,属性指向的对象也跟着变了,就会出现数据错乱。copy会生成一个不可变副本,把属性与外部对象隔离。同样的道理适用于NSArray和NSDictionary。这里有个小坑,自定义对象实现了NSCopying协议时,copy修饰符要求这个对象必须正确实现copyWithZone:,否则运行时会崩溃。
2.2 内存管理:从Retain Count到AutoRelease
苹果的内存管理从MRC时代演进到ARC时代,底层逻辑其实是统一的。ARC在编译期帮我们插入了合适的retain、release、autorelease调用,运行时还是靠引用计数在维持对象生命周期。
面试里经典的考察点是循环引用。常见的出题场景:Block里使用self、NSTimer的target强引用、Delegate的持有关系写错方向。很多人知道Block里用__weak打破循环,但有一个隐蔽场景容易忽略——Block被对象强持有,Block内部又捕获了这个对象的成员变量,即使你用了__weak typeof(self) weakSelf = self;,如果Block内部访问的是_ivar(成员变量),系统会直接强引用self,weakSelf失效。
// 错误写法:即使有weakSelf,_name访问仍会强引用self __weak typeof(self) weakSelf = self; self.block = ^{ NSLog(@"%@", _name); // 显式访问成员变量,weakSelf形同虚设 }; // 正确写法:先通过weakSelf拿到self,再访问属性 __weak typeof(self) weakSelf = self; self.block = ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (strongSelf) { NSLog(@"%@", strongSelf.name); } };这个__strong修饰符的使用也是加分项。它防止在Block执行过程中,对象在中间某个时刻被提前释放,保证了执行期间对象的存活。
2.3 Category和Extension的底层实现差异
分类是OC中一个极具特色的机制。Category可以给现有类添加方法,但不能直接添加成员变量。原因是成员变量对应的是类的实例变量布局,而分类在运行时是合并进类的方法列表里的,并不参与class_ivar的布局调整。
面试题里常常问“为什么Category不能添加属性”,更准确的说法是“Category可以声明@property,但不会自动生成成员变量和getter/setter实现”。如果用objc_setAssociatedObject和objc_getAssociatedObject手动实现关联对象,就能曲线达成“给分类添加属性”的效果。
关联对象的使用有几个注意事项:
objc_setAssociatedObject的key需要是一个静态指针常量,不能直接用字符串字面量,否则多个地方用相同的key会互相覆盖。- 关联对象释放时机是被关联对象
dealloc时,如果关联策略用了OBJC_ASSOCIATION_RETAIN_NONATOMIC,系统会负责释放关联对象,但要注意避免在对象释放后被其他线程访问。 - 关联对象不能滥用,它走的是全局映射表,频繁使用会影响性能。
// 给UIView添加一个业务标识属性 static char kBusinessKey; @implementation UIView (Business) - (NSString *)businessKey { return objc_getAssociatedObject(self, &kBusinessKey); } - (void)setBusinessKey:(NSString *)businessKey { objc_setAssociatedObject(self, &kBusinessKey, businessKey, OBJC_ASSOCIATION_COPY_NONATOMIC); } @end3. 多线程与并发:面试必考的GCD深水区
3.1 队列与任务的组合逻辑
GCD的核心概念是队列和任务。队列分串行和并发,任务分同步和异步,组合出来就有四种情况。很多面试题喜欢问“输出顺序是什么”,考察的就是对这四种组合的理解。
最经典的坑是dispatch_sync和dispatch_get_main_queue的组合。在主线程上执行下面的代码:
dispatch_sync(dispatch_get_main_queue(), ^{ NSLog(@"Hello"); });这行代码会直接死锁。原因很简单:主线程正在执行dispatch_sync,同步任务需要立即执行,但执行这个任务需要主线程,而主线程被dispatch_sync占住了,任务永远没有机会执行。
类似的死锁场景还有递归锁的使用不当。比如在串行队列里用dispatch_sync向同一个队列提交任务,一样会死锁。判断一个GCD调用会不会死锁,核心思路是:当前任务所在的队列,是否和提交任务的队列是同一个队列,并且当前任务用的是同步API。理解了这一点,大部分死锁问题就能规避。
3.2 栅栏方法与读写锁的取舍
dispatch_barrier_async是处理多读单写场景的经典方案。读操作可以并发,写操作必须独占。用栅栏方法实现,能保证写入任务提交之前的所有读任务完成,完成之后再执行读任务。
- (id)readDataForKey:(NSString *)key { __block id result = nil; dispatch_sync(self.concurrentQueue, ^{ result = [self.dictionary objectForKey:key]; }); return result; } - (void)writeData:(id)data forKey:(NSString *)key { dispatch_barrier_async(self.concurrentQueue, ^{ [self.dictionary setObject:data forKey:key]; }); }这里面有两个细节值得注意:
- 并发队列不要用
dispatch_get_global_queue,因为全局队列会被其他模块共用,栅栏方法就失去意义了。 dispatch_barrier_sync和dispatch_barrier_async的区别在于是否阻塞当前线程。写操作如果不需要立即获取结果,用async是更好的选择,不会阻塞UI线程。
不过,GCD的栅栏实现也有局限。它只适用于你自己创建的并发队列,如果写操作的频率很高,性能会下降。更灵活的方案是使用pthread_rwlock_t,读锁共享、写锁独占,在线程竞争激烈的场景下吞吐量更高。
3.3 NSOperation的依赖与取消机制
NSOperationQueue是GCD的更高层封装。笔试题里常问它和GCD的区别,有一种说法是“NSOperationQueue是GCD的封装”,这个表述不太准确。NSOperation和NSOperationQueue是基于GCD实现的,但额外提供了依赖关系、最大并发数控制、暂停恢复、取消操作等能力。
依赖关系是面试的高频考察点。假设有三个网络请求:A获取用户信息,B获取用户订单列表,C需要同时使用A和B的结果做展示,那么C应该依赖A和B都完成。实现方式是:
NSOperation *aOp = [NSBlockOperation blockOperationWithBlock:^{ // 请求A }]; NSOperation *bOp = [NSBlockOperation blockOperationWithBlock:^{ // 请求B }]; NSOperation *cOp = [NSBlockOperation blockOperationWithBlock:^{ // 并发执行完成后,处理A和B的结果 }]; [cOp addDependency:aOp]; [cOp addDependency:bOp]; NSOperationQueue *queue = [[NSOperationQueue alloc] init]; [queue addOperations:@[aOp, bOp, cOp] waitUntilFinished:NO];有一个容易忽略的问题:cOp执行时,A和B的任务确实完成了,但数据如何传递给C?如果直接使用共享的可变字典,需要保证线程安全。更优雅的方案是使用NSOperation的子类,把上一个操作的输出作为当前操作的输入,通过自定义数据属性来传递,配合completionBlock做最终处理。
取消操作也是容易踩坑的地方。调用[operation cancel]并不会停止正在执行的代码,它只是在operation.isCancelled返回YES而已。规范的做法是在执行的Block里手动检查取消状态:
for (int i = 0; i < 10000; i++) { if (op.isCancelled) { break; } // 每执行一段耗时操作,检查一次取消标记 }如果忽略这个检查,取消操作就是纸上谈兵,任务依然会执行完。
4. 网络层与数据安全:每一层都在考察什么
4.1 TCP三次握手和四次挥手,不能只背口诀
笔试题几乎必考TCP连接管理。但单纯背出“三次握手、四次挥手”顺序,最多拿一半分。面试官通常会追问:为什么是三次而不是两次?为什么挥手是四次而不是三次?
三次握手的核心目的是同步序列号,确保双方都知道对方的初始序列号。如果只有两次握手,服务端无法确认客户端是否收到了自己的SYN+ACK报文,可能导致半连接状态。四次挥手多出来的那一次,是因为TCP的支持全双工通信,每一方的FIN都只能关闭自己这一侧的传输方向,所以需要双方各发一次FIN和ACK。
还有一个容易混淆的考点是TIME_WAIT状态。主动关闭连接的一方会进入TIME_WAIT状态,持续2MSL(Maximum Segment Lifetime,报文最大生存时间)。这个状态存在的理由有二:
- 防止最后一个ACK丢失,导致被动关闭方重发FIN,而主动方已经关闭了,收不到重发的FIN。
- 确保本次连接的所有报文在网络中消失,避免复用相同端口和IP的新连接收到旧数据。
TIME_WAIT数量过多的问题在生产环境很常见,尤其是高并发短连接场景。解决思路包括使用长连接、开启SO_REUSEADDR、调整内核参数net.ipv4.tcp_tw_reuse等。但要注意,tcp_tw_reuse只对客户端生效,服务端不能依赖它。
4.2 HTTPS握手过程中,客户端到底校验了什么
HTTPS的握手过程是面试的高频题。完整的TLS握手涉及证书验证、密钥交换、对称加密协商三个阶段。对于iOS开发来说,重点不只是握手的流程,还有客户端如何校验证书。
证书校验分为几个层级:
- 证书链校验:从叶子证书开始,逐级向上验证到根证书,确保证书链完整。
- 有效期校验:
SecTrustEvaluateWithError返回true,代表证书在有效期内。 - 域名校验:证书里的
CN或Subject Alternative Name需要和请求的域名匹配。 - OCSP吊销校验:可选,但推荐做,用来确保证书没有被吊销。
iOS 13之后,NSURLSession默认要求ATS(App Transport Security)通过,也就是必须使用HTTPS。开发阶段的临时豁免NSAllowsArbitraryLoads可以打开,但上架审核时如果被检测到,有被拒的风险。我的建议是,开发阶段用NSExceptionDomains给特定域名开白名单,而不是全局禁用ATS。
有一类面试题会问:AFNetworking的证书校验是怎么实现的?答案是它封装了AFSecurityPolicy,提供三种模式:无校验、白名单校验、公钥校验。生产环境推荐公钥校验模式,这样即使证书过期或者更换了证书(只要公钥不变),App依然能够正常工作。但公钥校验的缺点是私钥泄露时整个App的通信防护都失效。
4.3 DNS解析的坑:从HTTPDNS到Happy Eyeballs
物流货运App对网络延迟极度敏感。DNS解析是连接的起点,但它也经常成为性能瓶颈。传统DNS有两个问题:
- 解析慢:UDP的53端口如果被运营商劫持或者缓存污染,可能导致连接失败。
- 解析结果不准确:CDN调度需要判断用户所在的运营商网络,但有些DNS服务器返回的IP不是最优节点。
所以现在大厂的应用普遍使用HTTPDNS方案。iOS端接入HTTPDNS,通常是在NSURLProtocol里拦截请求,把URL的域名替换成HTTPDNS返回的IP,同时在Host头字段保留原始域名。这样既绕过了本地DNS,又让服务端能够识别域名。
这个方案在面试中也经常出现,关键是理解它的实现原理,而不是只会调SDK。HTTPDNS要做到精确替换,需要处理HTTPS下的证书校验问题,因为服务端的证书是和域名绑定的,IP直连时验证证书会失败。解决方案是自定义SecTrust的校验策略,用原始的host去校验证书。
5. iOS架构设计:笔试中的加分题和团队协作分水岭
5.1 MVC、MVVM、MVP到底差在哪
架构设计题几乎是高级岗位的必考题。笔试卷子里可能会出现一个场景描述,让你设计一个订单详情页面的架构,考察的就是你对几种架构模式的理解和取舍。
MVC(Model-View-Controller)在iOS中是最经典的架构,但实际开发中容易退化成Massive View Controller。原因在于Controller承载了过多的职责:网络请求、数据处理、UI更新、事件响应、页面跳转,全部堆在一起,一个文件动辄上千行。
MVVM的核心改进是把业务逻辑和视图状态绑定拆到了ViewModel层。View只负责展示和发送交互事件,ViewModel持有模型数据并暴露给View可绑定的属性,View和ViewModel之间通过Block、Delegate或响应式框架(如RAC、Combine)通信。这样做的好处是ViewModel不依赖UIKit,可以纯单元测试。
但MVVM也有坑。使用RAC或Combine时,数据绑定链如果处理不好,会产生调试困难的问题。我的经验是:不需要追求100%的绑定,在关键数据流上做绑定就行,复杂的业务逻辑还是用显式的方法调用更清晰。
架构选型没有银弹,面试官想听的是你能针对具体场景分析优劣,而不是背出某个架构的定义。一个合格的回答应该包含:
- 说出当前场景的核心痛点(比如页面逻辑复杂、多人协作容易冲突、测试覆盖难)。
- 说明你选择的架构如何解决这些痛点。
- 承认这个架构的局限性,以及你用什么手段弥补。
5.2 组件化改造:不是为了炫技,是为了解决真实问题
谈到iOS架构,组件化是一个绕不开的话题。货运业务经过多年迭代,App体积变大、模块增多,业务线之间的代码耦合越来越严重,所以组件化改造在笔试和面试中频繁出现。
组件化常用方案有CocoaPods私有库和Lotusoot、CTMediator这类中间件方案。中间件方案的核心是通过运行时发现服务、调用服务,解除业务模块之间的直接依赖。比如模块A要调用模块B的某个功能,A不直接依赖B的头文件,而是通过URL Router或者Protocol匹配的方式,找到可以处理这个调用的模块。
这套机制在解耦业务线的同时,也引入了一些问题:
- 动态发现机制依赖编译期注册表,调试时如果注册表没刷新,会出现“服务找不到”的诡异bug。
- 隐式的调用方式,让静态分析和代码跳转变得困难。
- 参数类型不安全,所有参数以字典形式传递,编译期无法发现类型错误。
所以我的建议是:小团队、小规模项目不需要一上来就组件化,先把模块的接口设计清楚,控制好依赖方向,比引入复杂的中间件更有效。中大型项目做组件化的目的,一定是解决真实存在的协作效率问题,而不是因为别人都在做。
5.3 启动优化和卡顿优化,笔试里的实战题
性能优化是笔试卷里最具实战色彩的部分。启动时间、FPS流畅度、内存占用,每一个指标背后都有一整套优化方法论。
启动优化有两个阶段:pre-main阶段和post-main阶段。pre-main阶段主要包括dyld加载动态库、图片资源解压、+load方法执行、C++全局对象构造。启动优化措施包括:
- 减少动态库数量,将多个Pod库合并成framework或者静态库。
- 清理用不到的
+load方法,特别是组件化后各模块在+load里注册表的逻辑,可以改为懒加载注册。 - 把
didFinishLaunchingWithOptions里的非关键业务延后到首帧渲染完成后执行。 - 图片资源尽量使用
[UIImage imageNamed:]之外的加载方式,避免主线程解码。
卡顿优化的根因通常是主线程执行了耗时操作。排查手段包括使用Time Profiler、Instruments做采样分析,或者通过CADisplayLink监控FPS,把卡顿的堆栈上报到平台。常见的卡顿源头无非是:同步网络请求、大量正则匹配、字符串拼接、图片解码、复杂布局计算(layoutSubviews、constraintWithVisualFormat)。每一个都有对应的优化手段,但归根结底的原则是——能异步就异步,能懒加载就懒加载,能缓存就缓存。
6. 从笔试题到技术终面:那些容易被忽略的隐性考察点
6.1 考察的是能力模型,不是答案本身
很多人以为笔试就是做题,背熟知识点就够了。但真正的行家透过笔试题,看的是你解决问题时的思维路径。
比如一道关于TableView卡顿优化的题,候选人的回答可能是“减少cell高度计算、异步加载图片、复用cell”,这些是标准答案,能拿及格分。但高分答案是进一步说清楚:“cell高度的计算逻辑在何时执行,有没有可能预计算;图片解码是否已经放到子线程,解码结果有没有缓存;快速滑动时是否复用了cell导致图片错乱,是否有根据indexPath做校验”。后者体现的是你在真实项目中踩过坑,才能真正关心这些细节。
笔试卷里的开放题,比如“设计一个日志系统”“说一个你印象最深的线上bug”,其实都是给候选人展示自己能力模型的机会。我的建议是,写这类题目时不要泛泛而谈,采用“背景-动作-结果”的结构:这个问题的业务背景是什么,我做了什么分析,最终怎么解决的,效果如何。这个结构也适用于面试时的自我介绍和项目介绍。
6.2 竞态条件和线程安全:考试套壳的深层考点
与iOS直接相关的并发线上问题,有一个高频场景是竞态条件。多线程同时读写同一个可变字典、同一个计数器、同一个网络请求回调,都容易产生难以复现的闪退。笔试卷子里经常用一个看似简单的例子来考察:
@property (nonatomic, strong) NSMutableDictionary *cache; // 线程A self.cache[key] = valueA; // 线程B id value = self.cache[otherKey];这段代码在nonatomic修饰下,两个线程同时操作字典,会出现两种情况:读取到未完整的值、或者触发EXC_BAD_ACCESS。即使把属性改成atomic,这里的问题依然存在,因为atomic只保证属性的setter/getter原子性,并不保证NSMutableDictionary的底层容器线程安全。
正确做法是把字典接在并发队列后面,用读写锁保护。这是面试中的送分题,但也是生产环境的高频事故,值得重点记忆。
6.3 手写代码的常见坑和解题套路
大部分iOS笔试包含手写代码环节。核心类型是算法题或手工实现API。这里总结几个容易翻车的点:
- 边界条件:数组越界、空数组、长度为1的数组。
- 变量命名:不要用
a、b、tmp这样的命名,用有业务含义的名字。 - 时间复杂度:别写出
O(n^2)的解法还不自知。 - 内存管理:手写Block或闭包时,记得处理循环引用。
- 考虑线程安全:如果题目明确说多线程访问,补充锁或队列保护。
一个常见的解题思路模板:先想清楚输入输出,再写核心逻辑,最后补边界判断。写完之后主动说出时间和空间复杂度,以及潜在优化空间。这些细节在人工阅卷时非常加分。
7. 实战复盘:一份2018真题卷里的iOS高频知识点速查表
既然这是一篇围绕笔试题的深度拆解文章,最后整理一份高频考点速查表,方便备考的朋友按图索骥。下面这些考点,是我根据多套iOS笔试题和多年面试经验归纳出来的,涵盖范围比较全面。
| 知识点 | 高频考察形式 | 容易翻车点 | 推荐解决思路 |
|---|---|---|---|
| 属性修饰符 | 选错copy/strong/weak | 可变对象传入后数据被篡改 | 字符串、数组、字典用copy |
| 循环引用 | Block、NSTimer、Delegate | 访问成员变量导致weakSelf失效 | __weak + __strong组合 |
| 关联对象 | 分类添加属性 | key值冲突 | 使用静态指针常量做key |
| GCD死锁 | dispatch_sync提交到主队列 | 代码直接卡死 | 判断队列关系 |
| 读写锁 | 多线程读写数据 | 并发队列用全局队列 | 自建并发队列+barrier |
| NSOperation取消 | 调用cancel后任务仍执行 | 未检查isCancelled | 循环内手动检查取消标记 |
| TCP状态 | TIME_WAIT和CLOSE_WAIT | 只背四次挥手顺序 | 结合场景理解 |
| HTTPS证书校验 | ATS、证书链、公钥校验 | 忽略域名校验 | 注意自定义SecTrust |
| HTTPDNS | 域名替换和Host头 | HTTPS证书验证失败 | 自定义证书校验策略 |
| 架构模式 | MVC/MVVM选型 | Controller膨胀 | 按业务复杂度合理选型 |
| 启动优化 | pre-main耗时 | +load方法过多 | 懒加载注册 |
| 卡顿优化 | 主线程耗时操作 | 图片解码在主线程 | 异步解码+缓存 |
这份速查表的核心价值,是帮你快速定位自己的薄弱环节。每一个知识点背后,都对应着一个真实业务场景。你能把场景讲清楚,比单纯记住答案更有说服力。
如果你正在准备iOS面试,我的建议是:不要只刷题,把每个知识点放到一个具体的业务场景里,想清楚“这个技术到底解决什么问题、不用的后果是什么”。这样做的好处是双重的,面试时能言之有物,入职后也能更快地上手解决实际问题。
我个人带团队的经验是,笔试能反映候选人的知识广度,但真正决定能不能干活的是看他遇到具体问题时的排查思路。比如一个线上卡顿问题,他是直接猜、肉眼盯代码,还是用Instruments采样、用火焰图定位,这两种思维方式在笔试开放题中会体现得非常明显。写这套卷子的解题反思时,不妨多问自己几个“为什么”,把每个考点背后的原理吃透,这样不管面试官从哪个角度切入,你都能接得上话。