2018年秋招那会儿,AI四小龙风头正劲,商汤科技更是不少iOS开发岗候选人心里的香饽饽。我当时投的是iOS开发工程师,收到的第一轮筛选就是笔试,标题里写着"第一场",收到邮件那一刻心里还算踏实,毕竟笔试总比简历直接挂掉强。但真正打开答题页,看到那套卷子的构成时,我才意识到这场笔试和一般互联网公司的iOS笔试题有点不一样——它更看重你平时对底层原理的积累程度,而不是背多少API。
那场笔试给我留下的最深刻印象是:坑不在题目本身,而在于你对iOS的理解够不够"全"。这里说的"全",不是知道多少个API,而是从内存管理到并发模型再到UI渲染,能不能像查户口一样追问到底。这篇文章就以我的参赛回忆为线索,把那次笔试里的高频考点、答题思路和踩坑教训一一拆开,给准备iOS校招的同学做个参考。
1. 参加这场笔试前,我做了哪些准备
1.1 2018年iOS技术栈的复习重点
2018年正好处在iOS 11普及、Swift 4发布的节点,笔试出题范围大体上还是围绕OC为主。我当时把复习重心分成了四块:内存管理、多线程、Runtime和UI机制。现在回看,这几块确实就是笔试的大头,只不过当时没想到会是这么具体的问答。
内存管理方面,我重点过了一遍MRC到ARC的演进,尤其关注weak底层实现、autoreleasepool的释放时机、copy在不同场景下的行为。当时觉得这些知识点"背下来就行",但后来笔试里的选择题会变着花样考,比如给一段代码问dealloc里能不能访问weak属性,或者把一个可变数组赋值给copy属性会发生什么。如果只是背结论,没有把底层逻辑串起来,很容易掉坑。
并发这块,我把GCD和NSOperation对比着复习。2018年面试官很喜欢问函数式并发和命令式并发的区别,笔试里则会具体到dispatch_queue_create的参数含义、dispatch_after在哪里执行、死锁产生的条件。这里有个技巧:把GCD的队列、任务、线程三者的关系画成图,理解任务是怎么被放进队列、又怎么被线程取走的,很多选择题就能迎刃而解。
1.2 商汤笔试的环境与题型印象
那场笔试是线上进行的,需要在规定时间内完成。印象中整套题分为单选、多选、简答和编程题,时长大致是两个半小时。编程题不是单独在一个OJ里跑,而是在答题页里直接写代码,手动检查,这种模式对代码格式的要求更高,因为没法用编译器帮你纠错。
还有一个印象点是,商汤毕竟是AI公司,整套笔试里对算法和数据结构的要求不低。即使岗位是iOS开发,编程题也没有降低难度,该考的二叉树、动态规划一个不少。我当时在iOS专项题上花的时间太多,导致编程题最后一道没来得及写,这是一个很痛的教训。所以后面我会专门讲时间分配的问题。
2. 选择题里的高频陷阱:内存、并发与UI
2.1 内存管理:MRC转ARC的底层细节
选择题的内存管理部分,2018年那批题普遍考得很细。有一道题大概是给了一段代码,问最终打印结果:
__weak typeof(self) weakSelf = self; dispatch_async(dispatch_get_global_queue(0, 0), ^{ [NSThread sleepForTimeInterval:1.0]; NSLog(@"%@", weakSelf); });然后设置self在0.5秒后释放,问日志输出什么。表面上考weak,实际上考的是weak引用的对象在释放后会被置nil这个特性。很多人知道weak会自动置nil,但忽略了题目里的全局异步队列是常驻的,self释放后weakSelf变成了nil,输出就是(null),而不是野指针崩溃。这个题目背后的原理是weak表结构:系统通过SideTable维护对象的weak引用列表,对象dealloc时会调用clearDeallocating把弱引用全部置nil。
还有一道题,给出一段MRC风格的代码:
- (void)setName:(NSString *)name { [name retain]; [_name release]; _name = name; }问这段代码在什么情况下会有bug。答案是当name和_name指向同一内存时,先retain后release是安全的;但如果先release后retain,且两者是同一对象,对象可能在release时被释放,再retain就会崩溃。这个知识点虽然不是ARC下的日常场景,但能看出你有没有真正理解引用计数的本质。
2.2 GCD死锁与线程保活
并发相关的选择题,最常考的就是死锁。有一道题问:
dispatch_queue_t queue = dispatch_queue_create("com.test.serial", DISPATCH_QUEUE_SERIAL); dispatch_sync(queue, ^{ NSLog(@"1"); [self process]; });如果process内部又对同一个queue执行dispatch_sync,会发生什么。答案就是死锁,因为串行队列不能对自身同步派发。但题目会把这个逻辑包裹成三层调用,让你看不清。遇到这种题,最简单的判断方法是看当前执行的任务在哪个队列,目标队列是不是同一个串行队列,如果是,sync就会阻塞等待当前任务完成,也就是自己等自己。
还有一个容易错的点:dispatch_after不是延迟到指定时间后一定执行,而是到点后把block提交到目标队列。如果目标队列是主队列,主线程正在跑一个长任务,那dispatch_after的block会排在该长任务之后。选择题里会伪装成"dispatch_after一定按时执行"的错误选项。这个点需要结合RunLoop的机制来理解。
2.3 UI相关:layoutSubviews与自动布局
UI部分的题量不大,但很爱考与布局触发时机相关的细节。比如问什么时候会触发layoutSubviews:首次显示、addSubview后、视图frame变化、滑动UIScrollView等。正确答案是frame变化和滚动时都有可能。这里有个容易忽略的:如果直接设置transform,不会触发layoutSubviews,因为transform不会改变center和bounds的绑定关系。这点我记得很清楚,因为当时答错了。
自动布局方面,考了UILabel的intrinsicContentSize。题目问为什么UILabel不需要设置宽高约束也能确定大小。这涉及系统调用sizeThatFits和preferredMaxLayoutWidth的时机。如果你只是UI层面使用但没看过内部机制,很容易把"约束完整"和"约束满足"搞混。
3. 简答题要的不是正确答案,而是思考过程
3.1 Block循环引用为什么是高频题
简答题第一道就是经典的Block循环引用。题目描述很简单:一个ViewController持有block属性,block内部使用了self,为什么dealloc不执行?这个知识点几乎人人都会,但笔试想考察的是你怎么组织答案。
我当时给出的回答分三步:第一,block内部拷贝到堆上后,会对捕获的OC对象做retain或copy,self被强引用;第二,ViewController持有block属性,形成self -> block -> self的环;第三,在合适的时机使用weakSelf打破环路。然后我补充了weakSelf为什么只是"在block内弱引用self",以及如果block内需要延时使用self,还要配合strongSelf来防止执行到一半self被释放。这一步补充我觉得是加分项,因为很多答案只写"用weak self"四个字,没有说明原因和边界。
还有一个衍生问题:为什么block使用实例变量会隐式强引用self?比如:
^{ _name = @"x"; }这一行相当于在block里捕获了self,即使没有显式写self,也会强引用self。如果属性是weak的话,结果会不一样。答到这个环节,才能看出你是真的懂了block对捕获变量的处理规则,还是只背了模板。
3.2 RunLoop与KVO的底层追问链
RunLoop那道题问的是:为什么主线程的RunLoop会持续运行,而自定义的NSThread里的RunLoop默认不运行?这个问题可以从mach_msg和事件循环来回答:主线程的RunLoop在启动时会进入do while循环,等待mach_msg接收事件;没有Source/ Timer时,RunLoop会立刻返回,所以子线程默认不开启。
然后面试题又延伸:performSelector:withObject:afterDelay内部依赖什么?答案是依赖RunLoop的Timer。这就解释了为什么在子线程上调用该方法可能没反应,因为子线程RunLoop没有运行。笔试简答里如果只让你答"RunLoop是什么",那很简单;但商汤的题更希望你能说清楚RunLoop、Timer和事件响应三者是怎么协作的。
KVO那题让写一个"如何手动触发KVO"。标准答法是调用willChangeValueForKey和didChangeValueForKey配对。但真正隐藏的考点是:KVO在底层通过isa-swizzling重写setter,触发机制依赖被观察对象的setter方法。如果直接给成员变量赋值而不是走setter,KVO不会自动触发。所以手动触发KVO实际上是在触发通知而非"手动改值"。这个区分能看出你是否理解KVO的实现原理。
4. 编程题与算法题:限时40分钟的取舍
4.1 字符串与数组的边界条件
编程题第一道是常见字符串处理:实现字符串反转,但要求每个单词不反转,大写的单词也保持原样(相当于只翻转单词顺序)。比如输入"hello world",输出"world hello"。这题不难,但很考边界处理。题目提到用OC实现,我先按空格切分,再倒序拼接。需要注意连续多个空格的情况,还有字符串首尾有空格的情况。当时我用componentsSeparatedByString切分,然后过滤空串,最后重新拼接。如果是在LeetCode上,直接用split就能处理,但笔试图里没有这些便利,需要自己实现。
这道题真正的坑在于,如果字符串里有标点符号,要求你保留标点位置。比如"Hello, world!",输出"world! Hello,"。这种要求在白纸手写代码时很容易忽略,因为要处理"单词"和"非单词"的边界。我当时只处理了空格,漏掉了标点。笔试后跟同学交流,才发现他们有的用CharacterSet来判断字母数字,这样更通用。
4.2 二叉树的递归与非递归
第二道编程题考了二叉树的前序遍历,要求分别用递归和非递归实现。递归很简单,但非递归需要借助栈。我当时用栈保存节点,每次弹出节点访问它,然后先把右孩子入栈,再入左孩子,这样出栈顺序才是根左右。这题在白纸写代码时容易犯一个错:忘记要把右孩子先入栈。如果换了顺序,输出就会变成根右左。
另一道进阶题型是二叉树中序遍历的下一个节点。题目给了父节点指针,问给定一棵二叉树和其中一个节点,如何找出中序遍历序列的下一个节点。这是一个经典的剑指Offer题,需要分场景讨论:如果该节点有右子树,则下一个节点是右子树中最左的节点;如果没有右子树,则向上找到第一个作为左子树的父节点。这种题考察的点不光是遍历,还有对树结构形态的理解。笔试里我在这题上面卡了较长时间,因为白纸上画树太慢,导致最后一道动态规划题只写了一半。
5. 交卷后的复盘:哪些题我后悔没答好
5.1 现场思路与标准解法的差距
交卷之后,我第一时间把整张卷子回想了一遍,对照自己在关键题目上的思路,发现最大的遗憾不是不会,而是在几个地方强行用"看起来高级"的方法,反而绕远了。
比如有一道选择题问:如何避免多次按钮点击导致重复push。选项里有几个奇怪的方案。我当时选了用GCD的dispatch_once,但其实正确思路应该是用ignoreEvent标志或者重新设置enabled为NO。dispatch_once只适合单例,用在按钮事件上会产生"后续点击永远不执行"的副作用。这种题考的是iOS事件响应链和UIButton状态管理的实际经验,如果没有真正做过业务,容易选错。
还有一道多选问哪些方式可以优化tableView滚动卡顿。列出的选项包括:按需加载图片、异步绘制cell、避免透明图层、减少subview数量、使用constraint的优先级调整等。我选了前四项,漏了"减少subview数量",其实这也是很重要的一个优化点。这个题不算难,但需要你平时有意识地分析cell渲染流程上的性能瓶颈。
5.2 给后来人的备考建议
经历过这一场笔试,我最想强调的两点,一个是不要只刷LeetCode,另一个是别忽视白板代码的规范性。
第一点,算法题确实有,但占比没有想象中那么高。整套卷子更看重你对iOS基础原理的掌握,尤其在简答和选择题里,会反复考你某个现象背后的机制。你可以用"过一遍知识点串讲"的方式来复习,比如从点击屏幕到手势识别,再到处理响应链的完整链路,中间涉及UIWindow、HitTest、RunLoop的哪个阶段,把每个环节都讲给自己听。能讲明白,笔试就能拿到大部分分数。
第二点,白板代码和IDE里写代码完全是两种感觉,没有补全、没有编译提示、没有运行验证。在答题页里写代码时,必须注意基本语法和缩进,尤其是for循环的边界条件,最好在写完后自己手动"模拟执行"一遍,检查数组越界或空指针。我在写字符串单词反转时就因为没检查首尾空格,扣了一些分。编译器能帮你抓的错误,在笔试里全靠自己盯。
如果你正在准备iOS校招,我建议把复习资料整理成三份:一份是OC基础与内存管理的知识点清单,一份是iOS面试高频题的手写答案,一份是算法题库的易错集。每份都不需要太长,但一定要按照你自己的理解来写。只有你能用自己的语言讲清楚,考场里才能顺手写出来。
那场笔试最终我没能走到面试,但回头看,它就像一次全面体检,把我当时对iOS底层的"知其然不知其所以然"暴露得干干净净。后来我把做错的题、答偏的思路一个个整理进笔记,用接下来的两个月逐个攻克,再去面其他公司时明显从容了许多。如果你也想投商汤或者其他以算法和底层见长的公司,我的建议很直接:先把自己最常用的那套iOS机制,从表面问到第三层,问到你答不出为止,再上考场。