春招的节奏真的很快,尤其是客户端岗位。3月中旬投完简历,一周后就收到了小红书的笔试邮件,岗位是iOS开发工程师,批次是第二批。我当时还在同时准备其他几家大厂的面试,说实话没有把笔试太当回事,但做完之后复盘了一圈,发现这批笔试题的考察方向在客户端岗里非常具有代表性。它不像有些公司那样堆砌框架API,而是真真切切地在考察iOS底层原理和工程落地能力。如果你也准备投客户端方向,尤其是小红书这种内容社区产品,这套题非常值得拿来练手。
这篇内容我会把第二批笔试的完整情况、题目方向、以及我踩过的坑,原原本本记录下来,重点拆解基础题和编程题背后的考察点,再补充一些开放题的答题思路。无论你是在准备春招还是秋招,只要目标是iOS开发岗,这篇文章应该能帮你少走不少弯路。
1. 笔试前的准备与整体策略
1.1 岗位JD解读:小红书iOS开发岗到底考什么
投递之前我仔细看过小红书的iOS开发岗JD,有几个关键词非常显眼:熟悉Swift和Objective-C、理解Runtime和RunLoop原理、熟悉内存管理机制、有性能优化经验是加分项。说实话,这类描述在很多公司都有,但真正能在笔试环节考出水平的并不多。小红书这批笔试题给我的感觉是:出的题几乎全部围绕JD里的核心要求展开,没有一道偏门题,但每道题都能筛掉一批对底层原理一知半解的人。
比如有一道选择题直接问自动释放池中的对象在什么时候释放,选项里混着“runloop休眠时释放”、“当前函数结束时释放”、“下一次runloop循环时释放”这些看着都对、实际有细微差别的答案。如果你只是背了ARC的结论,没有真正理解autoreleasepool在RunLoop里的运作时机,这道题大概率会选错。
所以准备阶段我给自己定的策略是:不刷偏题怪题,先吃透内存管理和RunLoop这两大块,再补GCD和Block细节,最后过一遍KVC/KVO和消息发送流程。Java和Python那套刷题思路在客户端笔试里只能解决编程题,基础题全拼iOS底子,这个差异化要提前意识到。
1.2 笔试系统与题型分布
这批笔试用的是某在线评测平台,进了房间之后才发现不是纯选择题+编程题的组合,后面还有三道简答题和一道开放性设计题,总共六大题。时间给了120分钟,选择题20道,编程题2道,简答题3道,开放题1道。说实话,这个题量在客户端笔试里不算少,尤其是编程题部分还需要手写完整的类实现,比较费时间。
我当天的时间分配大概是:选择题30分钟,编程题50分钟,简答+开放40分钟。这里要特别提醒一下,千万不要在选择题上恋战。我有个习惯就是每道选择题最多看两分钟,超过就先蒙一个并标记下来,等做完后面的大题再回头检查。因为客户端笔试的大部分分值集中在大题和编程题上,一道选择题纠结十分钟,可能就丢了一道编程题的分,非常不划算。
另外提示一下,这个在线平台支持切出页面查资料,但会留下切屏记录。小红书笔试说明里明确写了“不建议切换页面”,我全程没有切出去过,也不建议大家冒险。毕竟笔试只是第一关,笔试题本身也没有难到必须查资料的程度。
2. 基础题核心考点解析
2.1 内存管理:ARC与自动释放池
记忆里有一道印象特别深的选择题:在ARC环境下,以下哪种情况对象会被立即释放?选项有创建后放入自动释放池、函数返回时、weak引用对象被置空、strong引用超出作用域。这个题考察的其实就是ARC下对象生命周期的判断标准——strong引用计数为0时立即释放,autorelease池中的对象则是延迟释放,但所谓延迟也不是没边。
自动释放池的释放时机才是真正的考察重点。在iOS应用中,主线程的RunLoop会在每次循环结束时自动创建和释放autoreleasepool,所以pool中的对象并不是在创建它的函数结束时就释放,而是要等到当前RunLoop循环结束。这个点可以通过一个小实验验证:
- (void)viewDidLoad { [super viewDidLoad]; __weak id weakObj = nil; @autoreleasepool { id obj = [NSObject new]; weakObj = obj; NSLog(@"pool内:%@", weakObj); } NSLog(@"pool外:%@", weakObj); }上面这段代码,第一个log有值,第二个log是nil。因为obj是在@autoreleasepool块内创建的,出了这个块之后,它所在的那一层autoreleasepool已经释放了,所以weakObj变成了nil。但如果这个obj不是在手动创建的autoreleasepool里,而是ViewController的方法里创建的,那它的释放时机就要看外围的RunLoop绑定的自动释放池什么时候排空。
这一点笔试时最容易混淆。如果你在子线程里创建了一个autorelease对象,子线程不像主线程那样有RunLoop自动管理autoreleasepool,所以需要自己手动创建pool,否则对象会一直延迟释放直到线程退出。这也是为什么AFNetworking等老牌网络库会在子线程里显式加@autoreleasepool的原因。小红书这批题目里虽然没有直接考子线程pool的题,但后续的开放设计题里我用了这个点,应该是加分的。
2.2 RunLoop:被问了两次的重点
RunLoop在基础选择题里出现了两道,而且难度都不低。第一题问的是RunLoop的Source1和Source0的区别,第二题问的是performSelector:onThread:在什么情况下不会执行。
先说Source0和Source1。Source0不主动触发事件,需要调用CFRunLoopSourceSignal(source)来标记,然后CFRunLoopWakeUp(runloop)唤醒RunLoop去处理;Source1则是由内核端口或消息端口触发,能主动唤醒RunLoop。这个区别在iOS 10以后虽然有了新的实现方式,但核心逻辑没变。笔试里只要记住“Source1可以主动唤醒,Source0需要手动触发”基本就够了。
第二道题是经典坑题:在子线程里调用performSelector:onThread:withObject:waitUntilDone:,但目标线程没有开启RunLoop,结果会怎样?答案是目标线程的RunLoop模式没有被run起来,这个Selector根本不会执行。这题我之前面字节的时候也遇到过类似变体,所以一眼就选出来了。如果你对RunLoop没有实操经验,只是背书,很容易选错成“会在当前方法结束后执行”。
准备RunLoop这个知识点,我的建议是配合一个简单的线程保活Demo去看源码逻辑,理解了kCFRunLoopDefaultMode、UITrackingRunLoopMode之间的切换,再回头看这些选择题就会轻松很多。
2.3 KVC/KVO与消息传递
KVO的题目问的是isa-swizzling机制。
这个知识点很多新手容易卡在“KVO是直接修改被观察对象的isa指针”这个表述上。准确来说,KVO动态创建一个子类,然后将被观察对象的isa指针指向这个子类,同时重写被观察属性的setter方法来插入通知逻辑。所以当你对一个对象调用addObserver:forKeyPath:之后,object_getClass(obj)返回的类已经不是原来的类了。题目里有个选项就是“KVO会修改对象所属类”,这是非常典型的干扰项。
关于消息传递的题目也很有水平:给了一行代码[obj performSelector:@selector(doSomething)],问如果obj没有实现doSomething,完整的消息转发流程是什么。这道题看似基础,但只要把resolveInstanceMethod、forwardingTargetForSelector、forwardInvocation:的顺序搞混就完了。我建议把这三个阶段背下来:
- 动态方法解析:调用
+resolveInstanceMethod:或+resolveClassMethod:,允许你在这里用class_addMethod动态添加实现。 - 快速转发:调用
-forwardingTargetForSelector:,返回另一个对象来接手消息。 - 完整转发:先调用
-methodSignatureForSelector:获取方法签名,再调用-forwardInvocation:打包宗卷。
在笔试的简答题里我写了这个流程,并且解释了为什么Runtime要做这三层转发,而不是直接崩溃。这个设计本质是为了给开发者提供“补救机会”,在消息找不到实现之前最多给三次机会。这种思路考官会很认可,因为它体现了你理解Runtime不是背API,而是理解消息机制的设计意图。
2.4 Block与循环引用
Block相关的选择题出了两道。一道是基础题:__block修饰的变量在Block内部修改后,外部能不能感知?答案是可以,但__block的底层原理是把变量包装成一个结构体,Block内部实际上是修改结构体里的字段。另一道题是循环引用:在UIViewController里持有Block,Block里又引用了self,如何打破循环引用?
这个考点本身不新鲜,但是小红书笔试把场景换成了实际项目里的通知回调。问的是:一个UIView的子类在Block中捕获了它所属的UIViewController,这个UIViewController又通过属性强引用了这个view,这时候用__weak和__strong怎么做才安全?
正确写法是先用__weak typeof(self) weakSelf = self,在Block内部如果还需要用,再转成__strong typeof(weakSelf) strongSelf = weakSelf,用strongSelf继续操作。这样既要考虑不产生循环引用,又要防止Block执行过程中对象被提前释放。笔试里能把这个写法解释清楚,比单纯背“用weak避免循环引用”要得分高得多。
3. 编程题复盘
3.1 手写 LRU Cache
编程题第一道是标准的LRU Cache设计题。题目描述:实现一个支持get(int key)和put(int key, int value)的LRU缓存,要求get和put的平均时间复杂度都是O(1)。
这道题我写的是双向链表+哈希表的组合:
class LRUCache { private class Node { let key: Int var value: Int var prev: Node? var next: Node? init(_ key: Int, _ value: Int) { self.key = key self.value = value } } private let capacity: Int private var map = [Int: Node]() private let head = Node(0, 0) private let tail = Node(0, 0) init(_ capacity: Int) { self.capacity = capacity head.next = tail tail.prev = head } func get(_ key: Int) -> Int { guard let node = map[key] else { return -1 } remove(node) insertToHead(node) return node.value } func put(_ key: Int, _ value: Int) { if let node = map[key] { node.value = value remove(node) insertToHead(node) } else { if map.count >= capacity { let lru = tail.prev! remove(lru) map.removeValue(forKey: lru.key) } let node = Node(key, value) map[key] = node insertToHead(node) } } private func remove(_ node: Node) { node.prev?.next = node.next node.next?.prev = node.prev } private func insertToHead(_ node: Node) { node.next = head.next head.next?.prev = node head.next = node node.prev = head } }为什么这里要用双向链表而不是数组?因为数组元素的插入和删除需要移动元素,时间复杂度是O(n),不能满足题目要求。双向链表+哈希表的组合,哈希表负责O(1)的定位,链表负责O(1)的节点移动。这里有个细节值得注意:链表节点同时存key和value,不只是value。因为缓存满了需要淘汰尾部节点时,还要根据节点的key去哈希表里删除对应的键,如果节点里只存value不存key,这一步就做不到了。
提示:如果面试官后续问“能不能用Python的OrderedDict实现”,道理是相同的,但建议你先把双向链表的版本写熟。这不仅是笔试的A题关键,也是面试手撕的高频题。
3.2 链表类题目:反转区间
第二道编程题是链表反转的变体——反转从位置m到n的链表节点,一次遍历完成。这道题在很多题库里出现过,我当时看到还挺惊喜的,因为它比全链表反转多了一个区间边界的处理,考察的是对指针操作的掌控力。
我的Swift解法:
func reverseBetween(_ head: ListNode?, _ m: Int, _ n: Int) -> ListNode? { guard head != nil else { return nil } let dummy = ListNode(0) dummy.next = head var prev: ListNode? = dummy for _ in 1..<m { prev = prev?.next } let start = prev?.next var cur = start?.next for _ in 0..<(n - m) { start?.next = cur?.next cur?.next = prev?.next prev?.next = cur cur = start?.next } return dummy.next }这道题的关键在于dummy节点和prev的位置。引入dummy是为了处理m=1的边界。如果不引入dummy,当需要反转的区间从头节点开始时,头节点本身需要更新,代码就会多出一堆if判断。另外要特别注意:题目要求是一次遍历,所以不能先遍历找出区间再反转,而是要在遍历过程中不断地把当前节点移动到区间头部的位置。上面for _ in 0..<(n - m)这个循环每次移动一个节点,总共执行n-m次,就是一次遍历的含义。
3.3 数组与字符串:大数相加
第三道编程题是字符串表示的大数相加。题目大意:两个字符串表示的非负整数num1和num2,不能使用内置的大整数库,要求直接返回字符串形式的和。
这道题本身不难,但有一个很重要的点:不能把字符串转换成Int后再相加,因为可能溢出。正确处理方式是从两个字符串的最低位开始逐位相加,用一个carry变量记录进位:
func addStrings(_ num1: String, _ num2: String) -> String { var chars1 = Array(num1.reversed()) var chars2 = Array(num2.reversed()) var i = 0 var j = 0 var carry = 0 var result = "" while i < chars1.count || j < chars2.count || carry > 0 { let n1 = i < chars1.count ? Int(String(chars1[i])) ?? 0 : 0 let n2 = j < chars2.count ? Int(String(chars2[j])) ?? 0 : 0 let sum = n1 + n2 + carry result.append(Character(String(sum % 10))) carry = sum / 10 i += 1 j += 1 } return String(result.reversed()) }这道题考察的编程能力其实一般,但有一个隐藏的考察点:对carry的处理。很多人写完循环条件是while i < chars1.count || j < chars2.count,忽略了两个字符串都遍历完之后还有进位的情况,比如"999" + "1"会输出"990"而不是"1000"。我把carry > 0也加进了循环条件,一次就把边界补齐了。
3.4 编程题的边界处理与提交策略
编程题全部做完之后,我总结了一个规律:客户端方向的编程题,难度整体比后端要低,但特别在意边界条件和代码规范性。比如LRU那道题,直接暴露出你有没有处理capacity为0的情况;反转链表那道题,考的是dummy节点的使用习惯;大数相加那道题,考的是进位和循环边界的完整度。
提交策略方面,我建议初学者在笔试时不要追求一上来就在编译器里写完美代码,而是先写成word文档里的草稿,把思路理顺再落到在线编辑器。在线编辑器没有自动补全,而且编译环境不一定有Swift标准库的完整支持。我记得去年参加某家笔试时,在线编辑器默认是C++环境,我投Swift一直编译不过,最后发现右下角有个语言下拉框没切过来。这种低级错误真的会让人心态崩掉。以小红书这批题为例,Swift代码尽量不依赖第三方库,ListNode这种类需要自己定义,核心容错逻辑必须写在主函数里,避免因为运行环境差异导致判题失败。
4. iOS专项与应用题
4.1 架构设计题:MVVM还是MVC
简答题第一道问的是:在开发小红书信息流页面时,你会选择MVVM还是MVC,为什么?如果使用MVVM,数据绑定怎么实现?
这道题很多人会陷入一个误区,就是拼命夸MVVM,贬低MVC。但实际在笔试中,考官更希望看到一个工程师的辩证思考能力。我的答题思路先承认两种架构各有适用场景,然后结合具体场景分析:信息流页面的Controller通常非常臃肿,承载了数据加载、刷新、点击跳转、曝光上报等多重职责,这时候MVVM可以把数据加工逻辑从Controller里剥离出去,让Controller只负责视图层面的调度。
MVVM的数据绑定在iOS里实现方式有很多种,笔试时我提到了三种:
- KVO + 手动刷新,成本最低,但维护起来比较繁琐。
- ReactiveCocoa或RxSwift,功能完整但团队学习成本高。
- 苹果原生Combine框架,适合面向新系统开发的工程,但需要考虑最低版本兼容问题。
小红书App对iOS系统版本有要求,如果面向老版本做兼容,Combine就不太合适,这是架构选型时容易被忽略的现实约束。我在简答题里明确提到这一点,应该是能体现工程经验的加分项。
4.2 性能优化题:列表流畅度该怎么调
第二道简答题是经典的列表卡顿优化题。背景是:信息流页面在滑动时出现掉帧,请你从原理层面分析原因,并给出优化方案。
这道题的答题框架其实非常固定:先谈卡顿的本质,再谈定位手段,最后给具体优化项。卡顿的本质是主线程在短时间内要处理的事情太多,导致两个垂直同步信号之间无法完成一次画面渲染。所以我先写了定位方法——用Instruments的Time Profiler和Core Animation工具看主线程具体消耗在哪里,然后再针对不同原因给方案:
- 如果CPU耗时高:优化图片解码,使用
drawInRect按需绘制,避免在cellForRowAtIndexPath里做复杂的计算。 - 如果GPU耗时高:减少图层混合,把图片给
layer设置shouldRasterize做离屏渲染的缓存,但要注意不要滥用,否则会消耗额外内存。 - 预排版和异步绘制:用YYKit或Texture的思想,把文本的Frame计算放到子线程。
小红书产品本身是图片和视频密集型的App,考官想听到的其实不只是通用的Cell高度缓存,而是你在图片解码、局部更新、按需加载上有没有真正的项目经验。我额外写了一点关于UICollectionView的prefetchingEnabled设置如何在滑动过程中预加载即将出现的cell内容,这也是iOS开发中很实用的优化手段。
4.3 网络层与缓存设计
第三道简答题比较有意思,问的是:设计iOS端的网络图片加载框架,需要考虑哪些方面?这其实是在考你对三级缓存机制的理解。
我先给出了三级缓存的整体结构:内存缓存(NSCache)、磁盘缓存(FileManager或SQLite)、网络请求。然后针对每一级展开细节:
- 内存缓存使用NSCache的好处是系统会自动回收内存。但要注意
countLimit和totalCostLimit的设置,避免缓存无限膨胀。 - 磁盘缓存需要管理过期时间。我们通常按URL的MD5作为文件名,并定期清理过期文件。
- 网络请求需要做合并和优先级控制。当tableView快速滑动时,同一个时刻可能发出大量图片请求,必须实现请求复用和取消机制。
- 图片解码放到子线程完成,避免主线程卡顿。
- 内存警告时
didReceiveMemoryWarning会触发NSCache的自动清理,这里需要及时把内存缓存的一些大对象移除。
这种题目没有标准答案,考察的是你有没有从零搭建过图片加载组件的经验。我在笔试中提到了SDWebImage的架构参考和Kingfisher的部分实现细节,这也能表明我不是只会调库,对底层有了解。
4.4 开放性设计题:小红书信息流页面
最后一道开放题是:如果让你来设计小红书信息流首页,你会怎么组织页面结构?要求从数据层、UI层、交互层三个维度作答。
这个题乍一看像一个产品面试题,但实际上是考察iOS开发的综合素质。我是从工程角度作答的:数据层用分页模型管理feed流数据,服务端返回的字段包含笔记ID、图片URL、用户信息等,客户端需要把它们映射成不同的Cell类型;UI层用UICollectionView实现,因为瀑布流布局是小红书的核心视觉特征,自带的瀑布流布局无法直接支持不同的图片宽高比,需要自定义UICollectionViewLayout来计算每个Item的高度和位置;交互层需要处理点击跳转、评论点赞、图文展示和视频播放的切换逻辑。
当时我做了一个比较关键的取舍:在瀑布流里处理视频封面和图片封面时,应该先通过服务端返回的宽高比来做占位,等实际图片下载完成后再更新,这样能避免页面高度跳动导致的视觉闪烁。这种做法在小红书这类内容是中心化的产品里非常有用,因为它直接影响了用户在feed流里的沉浸体验。这道开放题没有唯一答案,但能否踩在“信息流体验”这个关键点上,是区分候选人的核心标准。
5. 踩坑实录与求职建议
5.1 笔试环境与语言选择的坑
笔试过程中我踩过两个实际的坑,分享出来希望大家不要重蹈覆辙。
第一个坑是编译语言选择。小红书第二批笔试有一道编程题我本来想用Swift写,但平台上默认打开的编辑器是C++,我一开始没注意,写的代码编译报错,白白浪费了五分钟。后来切到Swift才顺利跑通。在线笔试平台的编辑器通常没有本地IDE那么好用,代码补全很弱,所以考试前一定要提前去目标公司的笔试页面熟悉一下操作界面,看看它支持的语言和代码模板。
第二个坑是切屏记录。有些同学可能觉得切到本地IDE写代码没什么问题,但部分平台的防作弊系统会记录切屏次数,切太多会导致成绩作废。我全程没有切出去过,直接把代码写在平台编辑器里。宁愿多写点注释也不太依赖本地调试,因为平台毕竟不是以代码风格评分的。
5.2 时间分配与做题顺序
关于时间分配,我的体会是:选择题卡住就跳过,大题优先做自己有把握的。小红书这套卷子的结构,选择题占比不高,但最后一道开放题占的分值很高,值得留足时间。
我当天的做题顺序是:先快速扫一遍所有题目,把所有“一眼会做”的选择题先填掉,难题标记跳过;然后直接做编程题,因为编程题是刚需高分项,趁头脑清醒把逻辑理清;再回来补选择题中的难题;最后写简答题和开放题。这样安排的好处是:即使在最后时间紧张的情况下,大头的分值也基本保住了。
注意:开放题哪怕不会,也要尽量写思路和关键词,不要留空白。我当时在开放题的数据层设计上写了一些关键字比如“分页模型”“预加载”“缓存淘汰策略”,后来复盘觉得这些关键词就是得分点。对于主观题,把自己怎么设计、为什么这么设计的逻辑链条写清楚,比写一堆实现细节更容易拿分。
5.3 笔试后的复盘与后续节奏
笔试结束后24小时内,我把整份卷子重新回忆并做了一遍整理,尤其是错题和没做好的选择题,把每一个选项都搞懂了才罢休。复盘时我不只是看正确答案,还会往深一层想:如果面试官基于这道选择题追问细节,我会不会答不上来。
后来事实证明这个复盘方向是对的。小红书的面试环节确实会问到笔试里的相关知识点,比如RunLoop的Mode切换和autoreleasepool的释放时机,这两块我在复盘时都重新推导过一遍,面试现场回答得比较顺。所以千万不要把笔试只当考试,它其实是面试提纲的一部分。
5.4 对下一届投递同学的具体建议
如果你准备投小红书iOS岗(或者其他内容社区类App),我的核心建议是:不用去刷太多算法,把iOS基础原理吃透,编程题保持每日手感的水平就够了。重点是下面这几块:
- 内存管理和RunLoop,是选择题的高频,也是面试追问的热点。
- 编程题必练LRU、链表操作、字符串处理,这三类在小红书笔试中都出现了。
- 开放性设计题不要空谈概念,要结合内容产品的实际场景(图片加载、列表优化、缓存机制)来回答。
求职节奏上,春招的批次并不是越早越好。小红书笔试分了多个批次,第二批有时候反而是竞争相对温和的时候,因为很多同学在第一批就投递了简历并在系统里卡着流程。但这不代表你可以延迟准备,笔试的通知通常来得很快,收到邮件后再临时抱佛脚基本来不及。我是在投递之前就按“一周笔试冲刺”的节奏准备的:前三天刷iOS基础题和原理,中间两天练编程题,最后一天看开放设计题和架构题。
另外,记得关注手机号和邮箱消息。小红书笔试通知是通过邮件发送的,而且邮件里会写清楚笔试链接和截止时间。我身边有个同学就是因为邮件进了垃圾箱,错过了第一批笔试,只能等下一批,白白浪费了两周时间。
最后再分享一点个人体会
整套题做下来,我最明显的感觉是:小红书这批笔试并没有刻意刁难候选人,它考察的全都是iOS开发岗日常工作中真正会用到的东西。自动释放池的时机、RunLoop的事件分发、Block的循环引用、图片缓存的设计,这些不是面试造火箭,而是工作拧螺丝时一定会遇到的基础问题。所谓“校招造火箭”的说法,在小红书这套题里是不成立的。
如果你现在也在准备2024春招的iOS岗,我想说的是:不要过度焦虑算法题刷得不够多,也不要花太多时间去追新技术名词。把内存管理、RunLoop、多线程、Block这四大件彻底吃透,再准备一两道架构设计题,你的笔试通过率就已经超过大部分人了。剩下的,就是保持心态稳定,别在考场上被某个不会的选择题打乱节奏。祝大家都能拿到心仪的offer。