收到笔试通知的时候,我正在图书馆刷一道二叉树的递归题。邮件标题写着「小红书2023校园招聘-第二批在线笔试(iOS开发岗)」,时间是那周的周三晚上七点,九十道选择题加两道编程题,总共一个半小时。说实话,看到「第二批」这三个字,心里反而踏实了一点——至少说明不是第一次开考,题型和难度有迹可循,前一批考过的同学分享出来的面经也够我临时抱佛脚。当时我手头有一份自己整理的iOS考点清单,从Runtime消息转发到Auto Layout的约束优先级都有覆盖,想着就算遇到偏题,也至少能排除掉几个错误选项。
考完之后我花了两个晚上把所有题重新过了一遍,发现自己错的几道题不是不会,而是被选项里的干扰项带偏了。这篇就当成一份完整的复盘记录来写,把这场笔试的题型分布、高频考点、我的答题思路和踩过的坑都拆开讲一遍。如果你也在准备客户端方向或者iOS开发岗的校招笔试,这份复盘应该能帮你少走一些弯路。
1. 笔试基本盘——时间分配与题型配比是战略问题
1.1 这场笔试到底考了什么
小红书iOS开发岗的这场笔试和很多大厂客户端岗一样,采用的是牛客网在线笔试系统,总共一个半小时。从我的回忆来看,题量大约是单选题二十道左右、多选题少量,再加上两道编程题。实际的数字可能因场次有微调,但大体框架就是这样。
题型分布可以粗略分成三块:计算机基础、iOS专项、两道算法题。
计算机基础部分主要考察数据结构和操作系统常识,比如链表和数组的优缺点、进程和线程的区别、死锁产生的四个必要条件、TCP三次握手状态变化。这一块对于计算机科班的同学来说基本是送分题,但如果你像我一样平时把精力都放在iOS框架上,反而容易在这里翻车。我记得有一道关于页表和多级页表的题,选项描述很接近,我靠排除法才选对。
iOS专项才是这场笔试的压轴大菜。选择题里出现了Objective-C的消息机制、ARC内存管理、weak的实现原理、RunLoop的Mode切换、GCD串行队列死锁、block捕获变量、KVO触发时机、UIView和CALayer的区别等。这一块考得不算偏,但每个选项都设计得很细,光靠背概念不够,得能推演具体场景下的执行结果。
编程题两道都是LeetCode中等偏下难度的题目,一道是字符串相关的滑动窗口,一道是二叉树的层序遍历变体。我后面会单独开一章详细复盘这两道题的解法。
1.2 我的做题顺序与时间策略
我当时的策略是先花四十分钟把选择题全部做完,有多选的题先标记不确定的选项,然后立刻跳到编程题,最后如果有剩余时间再回头检查选择题。这个顺序在事后看是对的,因为编程题一旦卡住,时间消耗会非常大,留到最后容易手忙脚乱。
实际考场上,我在选择题部分花了大约四十五分钟,编程题第一道用了十二分钟,第二道因为题目描述里有一个很关键的边界条件我一开始没注意到,前前后后大概花了二十分钟,最后还剩下十来分钟复查。复查过程中改了两道选择题的答案,其中一道是「block捕获外部变量时,如果没有__block修饰,捕获的是值还是引用」——我一开始选了值,后来结合局部变量和静态变量的区别,改成了「捕获时值拷贝,但静态变量捕获的是指针」。这个知识点我在复习阶段专门整理过,所以印象比较深。
给后面同学的一个建议是:不管卷子简单还是难,先花一两分钟把整张卷子扫一遍,确认编程题的题面长度和考察方向。有些编程题描述很长,扫一眼能帮你提前在脑子里预热数据结构和算法,不至于做到一半才去翻前面的选择题。
1.3 小红书iOS岗笔试的专属特点
和其他厂客户端岗笔试相比,小红书的题有很明显的社区内容App色彩。选择题里考UIView和CALayer的时候,题干会写成类似「在信息流场景中,如果需要频繁修改圆角,以下哪种做法更合理」这样的形式。这种问法其实已经不是单纯考某个API,而是在考你面对真实渲染性能问题时的工程判断。
我在考场上遇到了一道和图片加载相关的选择题,大意是:在UITableView中展示网络图片,如果不做任何处理,用户快速滚动时会看到图片错位闪烁,以下哪种方案能从根本上解决这个问题。选项里包含了「在willDisplayCell中设置image」「使用带URL判定的异步加载库」「对图片进行圆角裁剪」「在cell复用前清空旧图」。我选了「使用带URL判定的异步加载库」加「在cell复用前清空旧图」,后来复盘确认这应该是对的。这种题的解题关键不在于你知道SDWebImage这个库,而在于你理解cell复用机制和异步回调之间的竞态关系。
所以如果你准备小红书的iOS岗,除了把语言基础和算法刷熟,一定要把信息流列表优化、图片加载与缓存、页面流畅度这些和UGC内容场景强相关的知识点过一遍。笔试不会直接考你SDWebImage怎么用,但会用各种方式考察你知不知道它解决了什么问题。
2. 选择题考点复盘——从Runtime底层到RunLoop的Mode切换
2.1 Objective-C语言基础与Runtime高频点
选择题里语言基础部分考得很细。有一道题考察isKindOfClass和isMemberOfClass的区别,题干要求判断多个表达式的值。前者会遍历继承链判断是否属于某个类或其子类,后者只判断是否是目标类本身。这类题以前在面试题里见过很多次,但笔试里放在选项里和NSObject、NSProxy混在一起,还是容易看花眼。
还有一道经典的copy和mutableCopy问题,题干给了一段代码:
NSMutableString *str = [NSMutableString stringWithString:@"hello"]; NSString *copyStr = [str copy]; NSMutableString *mutableCopyStr = [str mutableCopy]; [str appendString:@" world"];问最后copyStr和mutableCopyStr的值。答案是copyStr为hello,mutableCopyStr为hello world。copy对可变对象返回不可变拷贝,mutableCopy返回可变拷贝,前者是深拷贝,后者也是深拷贝。但如果是[copyStr copy]对不可变对象执行,就是浅拷贝了。这个区分非常重要,笔试里出现的概率非常高。
Runtime部分还考察了消息转发流程。题目问:当向一个对象发送它没有实现的消息时,完整的处理顺序是什么。正确顺序是:resolveInstanceMethod:->forwardingTargetForSelector:->forwardInvocation:-> 最终抛出doesNotRecognizeSelector:异常。我复习的时候把这条链路背得很熟,所以看到这道题基本是秒选。
2.2 ARC与内存管理:weak的底层不是一句「弱引用」就完事
iOS内存管理的题目在整张卷子里占了很大比重。有一道题问__weak修饰的变量在对象释放后为什么能自动置为nil。这题的完整答案涉及Runtime源码:对象释放时,objc_clear_deallocating()会查找SideTable中的weak_table_t,遍历所有指向该对象的弱引用指针,将它们置为nil,然后再把对应的weak_entry_t从弱引用表中移除。
笔试选项里给了一个干扰项,写的是「释放时ARC会扫描所有weak变量并置nil」。这个说法听着没问题,但关键差别在于「扫描所有weak变量」和「通过全局weak表精准查找并只处理对应对象的弱引用」是完全不同的实现。前者是O(n)的全局扫描,后者是哈希查找加链表的精准操作。出题人用这种措辞上的细微差异来筛选真正读过源码的人,所以复习的时候不能只记结论,要理解实现路径。
循环引用这道题也考了,题干是一个ViewController持有block,block内部访问了self的成员变量。问会不会造成循环引用。答案是会,因为_ivar的访问等价于self->_ivar,会隐式捕获self。正确的解决方式是__weak typeof(self) weakSelf = self,然后在block内部再__strong持有这个弱引用,避免执行过程中对象被提前释放。
2.3 GCD与RunLoop:代码跑不跑得起来,一眼就能看出来
多线程部分的题非常实在,给一段GCD代码让你判断是否死锁。我印象最深的一道是:
dispatch_queue_t queue = dispatch_queue_create("com.test.queue", DISPATCH_QUEUE_SERIAL); dispatch_sync(queue, ^{ NSLog(@"A"); dispatch_sync(queue, ^{ NSLog(@"B"); }); });这段代码会死锁。原因是在一个串行队列上用dispatch_sync派发任务到同一个串行队列,当前任务在等待新任务执行,而新任务排在当前任务后面,因此永远不会被执行,形成了自己等自己的局面。这个考点几乎每场iOS笔试都会出现,我把它记成了「串行队列同步派发到自身=死锁」的口诀。
RunLoop部分考了一道很实际的题:在UITableView滚动过程中,NSTimer不触发的原因是什么。答案是滚动时RunLoop切换到了UITrackingRunLoopMode,而NSTimer默认被添加在kCFRunLoopDefaultMode(即NSDefaultRunLoopMode),滚动模式下定时器所在的Mode不被RunLoop处理。解决办法有两个,要么把Timer添加到NSRunLoopCommonModes,要么用CADisplayLink或者在scrollView的delegate里动态添加和移除。这道题我选对了,因为在实习的时候真遇到过一次列表滚动导致倒计时暂停的bug,当时排查了很久才定位到Mode切换上。
2.4 UIKit、布局与渲染性能:客户端岗位的隐藏分水岭
在iOS圈里流传着一种说法,做业务做得久的开发,最容易忽视的就是UIView与CALayer的关系。这场笔试恰好就考了一道:UIView和CALayer的区别,哪些说法正确。
比较稳妥的答案是:UIView继承自UIResponder,可以响应触摸事件;CALayer继承自NSObject,负责内容的绘制和动画渲染;UIView的layerClass可以被子类重写;UIView的显示层级本质上依托于CALayer的层级结构。有个选项写的是「CALayer可以响应触摸事件」,这个显然不对,Layer没有hitTest:以外的事件响应链机制,触摸事件是UIView的职责。
还有一道关于frame和bounds的题,问旋转后两个属性是否相同。答案是:如果没有对View做仿射变换,frame和bounds的size是一样的;一旦做了旋转,frame描述的是旋转后视图在父视图坐标系中的外接矩形,而bounds仍然是视图自身的坐标系,两者的size可能不一致。这是iOS面试的祖传考点,笔试也已经考了很多年。
2.5 我在选择题上踩的坑
考场上我错了一道和autoreleasepool有关的题。题目问:在ARC环境下,以下关于@autoreleasepool的说法哪个是错误的。选项中有「标记为__autoreleasing的对象会被注册到最近的autoreleasepool」「@autoreleasepool可以嵌套,嵌套时对象会在最近的pool中自动释放」「for循环中创建大量临时对象时,可以用@autoreleasepool降低峰值内存」「ARC下无法手动调用autorelease方法,所以@autoreleasepool没有意义」。前三个都是对的,第四个是错的,但我一开始被「ARC下无法手动调用autorelease方法」这个描述一带,差点选成「@autoreleasepool没有意义」是正确说法。好在复查的时候我意识到,ARC下虽然不能手动autorelease,但@autoreleasepool仍然有降低峰值内存的实际作用,比如在循环里创建大量图片对象时。这种题就是典型的「选项本身都是对的,但你得有能力判断哪一句话的结论是错的」。
3. 简答题不是简答——iOS高频问答题的答题框架
3.1 说说你对ARC的理解,怎么答才能和背八股的人区分开
虽然这张卷子没有专门的问答题,但小红书很多批次会有一道简答或填空,而且选择题里大量题目其实是在考察问你答题的能力。所以我把复习阶段积累的几个高频问答框架也放进来,这些在后续面试中同样能用。
回答ARC相关题目,至少要分三层来答:第一层是ARC做了什么——在编译期自动在合适的位置插入retain、release、autorelease调用,而不是运行时通过垃圾回收机制管理内存;第二层是所有权修饰符——__strong、__weak、__unsafe_unretained、__autoreleasing,分别适用什么场景;第三层是ARC的限制,比如不能直接调用retain/release/autorelease,不能使用NSZone,dealloc中不能调用[super dealloc],等等。
如果你能在答案里补一句「ARC并不是零开销,它会在方法边界插入大量内存管理调用,这也是为什么我们做性能优化时要关注方法体大小和调用频率」,面试官一般都会认可你有真实项目经验。选择题里有一道就是问__autoreleasing的作用,选项设计得很有迷惑性,如果你只背过「自动释放」四个字,肯定会选错。
3.2 KVO的底层实现与常见坑:不只是重写setter
KVO相关题目在笔试里出现的频率也相当高,尤其是围绕底层实现和触发时机。完整的KVO实现机制是:当一个对象被添加观察者后,Runtime会动态创建一个该类的子类,并在子类中重写被观察属性的setter方法。重写后的setter会先调用willChangeValueForKey:,然后执行父类的getter来获取旧值,再赋新值,最后调用didChangeValueForKey:。当didChangeValueForKey:被调用时,系统会通知观察者触发observeValueForKeyPath:ofObject:change:context:。
这个过程中有几个容易被问到的坑:一是KVO触发的前提是属性通过setter方式修改,直接修改成员变量不会触发;二是手动触发需要调用willChangeValueForKey:和didChangeValueForKey:;三是在iOS 11之前,如果类没有对KVO做适配,在子类重写class方法时可能被系统动态替换导致isKindOfClass:判断失效。笔试选择题里考了其中两个点,算是比较常规的。
实际工程里还有一个坑:NSKeyValueObservingOptions中的NSKeyValueObservingOptionNew和NSKeyValueObservingOptionOld对应change字典里的new和old,如果忘了传这两个选项,回调里拿不到新旧值,很多新人会在这里卡很久。面试或笔试简答的时候把这点作为一个「实际开发中容易忽略的细节」提出来,会显得很有经验。
3.3+load和+initialize:一个容易混淆的加载时机题
判断题里有一道是:+load方法是在类第一次被使用时调用的。这句话是错的。+load是在类被加载到Runtime时调用,也就是APP启动、镜像加载阶段,而且不管有没有被使用都会被调用。+initialize才是在类第一次被使用时——准确说是第一次收到消息前——调用。
这两个方法还有一个重要的区别:+load不会走继承机制,父类的+load和子类的+load是分别被调用的,而且会先调用父类;+initialize则遵循覆盖规则,如果子类没有实现,会直接调用父类的+initialize,而且这个调用是线程安全的。
如果试卷里让你分析「父类和子类同时实现+load,先执行哪个」这种题,我的记忆方式是:+load映射的是镜像加载顺序,父类优先;+initialize反映的是继承体系内的消息查找,子类如果有实现就用自己的,没有就用父类的,整个过程由Runtime保证线程安全,但如果有多个线程同时向该类发消息,+initialize可能被调用多次,这个点在苹果官方文档里也提到过,笔试选项喜欢拿它做文章。
3.4 Method Swizzling的注意点
Method Swizzling虽然不是这场笔试的大头,但有一道多选提到了它。正确选项里有几个关键约束:第一,Swizzling通常建议在+load方法中执行,因为+load在类加载阶段调用,能保证全局只执行一次且时机最早;第二,如果使用dispatch_once确保线程安全,其实在+load场景下dispatch_once不是严格必须的,但在其他场景中必须有;第三,Swizzling后,如果外部代码同时交换了同一个方法,多个Swizzling之间的顺序可能导致不可预期问题;第四,不要Swizzling基础类的不稳定API,因为系统版本升级可能导致实现变化。
我见过很多人回答Method Swizzling只记得method_exchangeImplementations这个函数,但笔试问的是原理和注意事项。所以复习的时候,与其背API,不如理解Swizzling的本质——它是利用Runtime的方法查找机制,交换两个SEL对应的IMP指针。理解了这一点,自然就能推导出它的局限性。
4. 两道编程题的完整复盘与代码演示
4.1 字符串最长不重复子串:滑动窗口的边界处理
编程题第一道是字符串题,题目描述大概是:给定一个字符串s,请你找出其中不含有重复字符的最长子串的长度。
这道题我比较熟,属于滑动窗口的经典题型。解题思路是维护一个窗口,窗口内始终是「不包含重复字符」的子串。使用HashSet或字典记录窗口内出现过的字符,右指针不断向右扩展,如果遇到重复字符,左指针就向右收缩直到窗口内不再包含该重复字符。每次移动右指针时更新最大长度。
我用的Swift实现如下:
func lengthOfLongestSubstring(_ s: String) -> Int { let chars = Array(s) var window = Set<Character>() var left = 0 var maxLength = 0 for right in 0..<chars.count { while window.contains(chars[right]) { window.remove(chars[left]) left += 1 } window.insert(chars[right]) maxLength = max(maxLength, right - left + 1) } return maxLength }时间复杂度是O(n),空间复杂度O(min(n, 字符集大小))。这段代码要注意的地方是:在Swift里直接用String索引会比较麻烦,所以我先把它转成了Array<Character>,这样可以用整数下标访问。笔试环境里Swift的版本可能比较新,但转数组的方式永远是最稳妥的。
这道题提交之后我很快就跑通了。考场上容易犯的错误是:在遇到重复字符时,只移动一位左指针而不是循环移动到不重复为止,这样会导致窗口内仍然存在重复字符,后续判断全部失真。我当时写的是while window.contains(chars[right]),确保处理完重复字符后窗口一定是干净的。
4.2 二叉树层序遍历变体:从BFS到结果组织
编程题第二道是二叉树层序遍历的变体。题目要求是按层输出二叉树节点,但输出顺序和普通层序遍历略有不同——「奇数层从左到右,偶数层从右到左」,也就是常见的「之字形层序遍历」。
解题思路依然是BFS,用一个队列维护当前层的节点。为了实现从右到左输出,有两种做法:第一种是正常层序遍历,按层序号决定是否反转当前层结果;第二种是使用双端队列,在入队和出队时调整方向。笔试环境下我选择了最简单的反转法,因为代码可读性高,不容易在方向处理时出错。
我的Swift实现:
func zigzagLevelOrder(_ root: TreeNode?) -> [[Int]] { guard let root = root else { return [] } var result = [[Int]]() var queue: [TreeNode] = [root] var isLeftToRight = true while !queue.isEmpty { let levelSize = queue.count var levelValues = [Int]() for _ in 0..<levelSize { let node = queue.removeFirst() levelValues.append(node.val) if let left = node.left { queue.append(left) } if let right = node.right { queue.append(right) } } if !isLeftToRight { levelValues.reverse() } result.append(levelValues) isLeftToRight.toggle() } return result }这道题的易错点有两个:一个是在处理每层节点个数时,必须在一开始就记录queue.count,否则在循环中不断入队会导致层边界漂移,把下一层的节点也混进当前层;另一个是removeFirst()操作在Swift数组上是O(n)的,严格来说应该用deque或者索引游标来避免大量移动开销。笔试的数据规模通常不大,编译器能过,但我印象里LeetCode上如果把这段代码跑在深度很大的数据上,性能确实会差一些。所以在代码里加点注释、说明「笔试场景下可接受,生产环境建议换用双端队列」是加分项。
4.3 编程题的时间分配与边界条件自查
我复盘时的一个体会是:这两道题本身不难,难的是在笔试那种紧张状态下想到边界条件。
第一道题的边界情况是空字符串,第二道题是根节点为空。这两个判断我都写在了函数开头,所以没出问题。更需要注意的边界条件包括:字符串只有单个字符、整棵树只有一个节点、所有字符都重复、树有大量单侧节点等。
关于时间分配,我建议编程题按「五分钟读题+确认边界 -> 十分钟写核心代码 -> 五分钟自测用例」的节奏来。自测用例很重要,笔试环境虽然不支持完整的测试用例编写,但你可以在心里跑一遍少量用例,比如输入"abcabcbb",推演滑动窗口的过程。如果你能手动跑通三个用例,基本就能发现大部分低级逻辑错误。
5. 从这场笔试反推小红书的考察偏好与备考思路
5.1 小红书iOS岗在笔试环节到底想要什么
考完之后我花了不少时间整理这次笔试的考点分布,发现小红书的出题风格有几个明确的特点。
第一是基础扎实看得非常重。这里的基础不仅仅是iOS里Runtime、RunLoop这些东西,还包括数据结构和操作系统。选择题里有一道关于进程间通信方式的题目,管道、消息队列、共享内存、信号量都出现了,考察你对每种方式特点的理解,而不只是知道名词。这个基础能力决定了你进入团队之后能不能快速上手业务,而不是遇到问题只能靠搜索引擎。
第二是客户端场景理解深。体现在题目中就是题干会绑定一个真实场景。比如讲图片加载优化,它不会问「SDWebImage的下载流程是什么」,而是描述一个列表快速滚动导致图片错位的问题。要答对这种题,你需要在平时写代码时有意识地做过UI渲染、网络请求、cell复用这三者之间竞态处理,光靠背答案是不够的。
第三是考察解题思维的完整性。编程题没有特别偏的算法,但边界条件、时间和空间复杂度、代码规范这些细节要求很高。我猜想他们筛选的不是「能写出来」的人,而是「能考虑周全」的人。毕竟到了真实业务里,一个case没处理好就是线上事故,这种工程素养笔试环节就开始看了。
5.2 我整理的iOS岗高频知识点复盘表
这是我考完以后整理的一张知识点自查表,按「是否掌握」做了标记。你在准备笔试时也可以按这个结构建立自己的复习清单:
| 知识点域 | 具体考点 | 掌握程度 |
|---|---|---|
| Objective-C语言 | 消息发送与转发流程、isa指针、类结构、category与extension | 重点复习 |
| 内存管理 | ARC、所有权修饰符、weak底层、autoreleasepool、循环引用 | 必考 |
| 多线程 | GCD串行/并发队列、同步/异步派发、死锁、信号量、NSOperation | 必考 |
| RunLoop | Mode切换、Timer与滑动冲突、RunLoop在事件处理中的角色 | 高频 |
| UIKit | UIView/CALayer、frame/bounds、Auto Layout、事件响应链 | 高频 |
| 网络 | HTTP缓存策略、DNS解析流程、TCP/UDP、IPv4/IPv6 | 中频 |
| 数据存储 | 沙盒目录、NSUserDefaults、CoreData、SQLite | 中低频 |
| 架构设计 | MVC/MVVM、组件化、消息机制、数据驱动更新 | 低频但加分 |
| 基础算法 | 数组、链表、二叉树、字符串、动态规划、滑动窗口 | 笔试必考 |
这份表的价值在于,它能帮你快速定位自己当前的知识漏洞。我在笔试前一周就是用这张表逐项过知识点的,复习到某一行发现自己不太熟,就立刻翻书或者看源码,比漫无目的地刷题效率高很多。
5.3 给下一届同学的笔试建议
结合这次笔试的经验,我总结了几条比较实际的建议,不一定全面,但都是我自己验证过有用的。
第一,选择题最怕的不是不会,而是「看着都对」。如果你平时习惯靠记忆刷题,一定要逼自己多问一句「这个选项里的描述,和标准定义之间是否有措辞上的偏差」。很多选项错就错在某一个副词或限定语上,比如「所有」「一定」「只需要」。
第二,编程题不要一上来就写代码。先花三分钟在草稿纸上写测试用例,尤其是边界条件,然后再动手实现。我遇到很多同学笔试没考好,不是不会写代码,而是写到一半发现自己的方案没法处理空输入或单个元素的情况,整段代码推倒重来,前面的时间全浪费了。
第三,遇到不会的题不要空着。在线笔试系统一般不会倒扣分,选择题哪怕蒙一个,也有概率蒙对;编程题如果写不出完整解法,至少把暴力破解版的思路写上去,或者在注释里写明你的思考方向,有些场次的人工评卷环节能看到这些过程性信息,能给你加一些印象分。
我个人的另一个体会是:不要把笔试看成知识检测,要把它看成对自己一段时间学习成果的体检。考完对答案那两天,我把自己所有错题都放进了一个表格,记录「错题内容」「错因」「正确解法」。这个表格在后续的面试复习里帮了很大忙,因为我发现很多错误背后的原因是同一类——要么是概念理解停留在表面,要么是写代码时忽略边界。把这个根因找出来修掉,才是笔试真正的收获。
如果你也在准备iOS开发岗的秋招,希望这份复盘能给你一些参考。每个人踩过的坑都不一样,但把坑整理成自己的经验地图,这条路是相通的。祝笔试顺利。