
收到笔试通知的时候我正在图书馆刷一道二叉树的递归题。邮件标题写着「小红书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为hellomutableCopyStr为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持有blockblock内部访问了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不能使用NSZonedealloc中不能调用[super dealloc]等等。如果你能在答案里补一句「ARC并不是零开销它会在方法边界插入大量内存管理调用这也是为什么我们做性能优化时要关注方法体大小和调用频率」面试官一般都会认可你有真实项目经验。选择题里有一道就是问__autoreleasing的作用选项设计得很有迷惑性如果你只背过「自动释放」四个字肯定会选错。3.2 KVO的底层实现与常见坑不只是重写setterKVO相关题目在笔试里出现的频率也相当高尤其是围绕底层实现和触发时机。完整的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.3load和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 SetCharacter() 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索引会比较麻烦所以我先把它转成了ArrayCharacter这样可以用整数下标访问。笔试环境里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必考RunLoopMode切换、Timer与滑动冲突、RunLoop在事件处理中的角色高频UIKitUIView/CALayer、frame/bounds、Auto Layout、事件响应链高频网络HTTP缓存策略、DNS解析流程、TCP/UDP、IPv4/IPv6中频数据存储沙盒目录、NSUserDefaults、CoreData、SQLite中低频架构设计MVC/MVVM、组件化、消息机制、数据驱动更新低频但加分基础算法数组、链表、二叉树、字符串、动态规划、滑动窗口笔试必考这份表的价值在于它能帮你快速定位自己当前的知识漏洞。我在笔试前一周就是用这张表逐项过知识点的复习到某一行发现自己不太熟就立刻翻书或者看源码比漫无目的地刷题效率高很多。5.3 给下一届同学的笔试建议结合这次笔试的经验我总结了几条比较实际的建议不一定全面但都是我自己验证过有用的。第一选择题最怕的不是不会而是「看着都对」。如果你平时习惯靠记忆刷题一定要逼自己多问一句「这个选项里的描述和标准定义之间是否有措辞上的偏差」。很多选项错就错在某一个副词或限定语上比如「所有」「一定」「只需要」。第二编程题不要一上来就写代码。先花三分钟在草稿纸上写测试用例尤其是边界条件然后再动手实现。我遇到很多同学笔试没考好不是不会写代码而是写到一半发现自己的方案没法处理空输入或单个元素的情况整段代码推倒重来前面的时间全浪费了。第三遇到不会的题不要空着。在线笔试系统一般不会倒扣分选择题哪怕蒙一个也有概率蒙对编程题如果写不出完整解法至少把暴力破解版的思路写上去或者在注释里写明你的思考方向有些场次的人工评卷环节能看到这些过程性信息能给你加一些印象分。我个人的另一个体会是不要把笔试看成知识检测要把它看成对自己一段时间学习成果的体检。考完对答案那两天我把自己所有错题都放进了一个表格记录「错题内容」「错因」「正确解法」。这个表格在后续的面试复习里帮了很大忙因为我发现很多错误背后的原因是同一类——要么是概念理解停留在表面要么是写代码时忽略边界。把这个根因找出来修掉才是笔试真正的收获。如果你也在准备iOS开发岗的秋招希望这份复盘能给你一些参考。每个人踩过的坑都不一样但把坑整理成自己的经验地图这条路是相通的。祝笔试顺利。