字节跳动2018校招iOS方向(第四批)这套题,我在准备校招时翻了不下五遍。它不像有些公司的题库那样纯粹堆概念,而是从内存管理、多线程、Runtime、UI布局、签名上架这些真正影响线上质量的点里挑问题,每一道都能往下追问三层。回头看,这套题其实是对iOS开发基本功的一次全面体检。这篇文章我就按第四批的考察顺序,把每道题对应的原理、面试时会被追问的细节、以及我后来在实际项目中踩过的坑都串起来讲一遍。适合正在准备iOS校招、实习面试的同学,也适合刚工作一两年想系统补基础的人参考。
1. 第四批到底考了什么:一套题背后的iOS知识地图
字节跳动2018校招iOS方向的笔试和面试,和现在很多大厂的出题思路并没什么本质区别。它不会直接问"ARC是什么"这种背概念题,而是给你一段代码,问你某个对象什么时候释放、某个闭包会不会造成循环引用、在某些场景下为什么界面卡住。第四批的题目在当年流传得很广,主要原因就是它的考察范围覆盖了从OC语言特性到系统底层机制,再到UI布局和工程化上线的完整链路,恰好卡在"会用"和"懂原理"之间。很多同学在上机题和选择题上失败,并不是因为没写过iOS,而是没把知识点串成体系。
我当时把网上能搜到的第四批回忆版题目汇总后,做了一张表格,按模块拆开看会非常清楚:
| 考察模块 | 典型考法 | 高频追问点 |
|---|---|---|
| 内存管理 | weak变量为什么自动置nil | SideTable结构、释放流程、循环引用场景 |
| 多线程 | 主队列dispatch_sync是否死锁 | 队列与线程关系、GCD底层机制 |
| Runloop | Timer在列表滑动时为什么暂停 | 运行模式、常驻线程、自动释放池 |
| Runtime | unrecognized selector崩溃流程 | 消息转发、动态方法解析、元类 |
| UI布局 | 用UIStackView实现标签流式排列 | Autolayout约束、抗压抗拉伸 |
| 响应链 | 点击事件如何找到处理者 | hitTest、pointInside、nextResponder |
| 工程化 | 描述文件报错、真机无法运行 | 证书、Provisioning Profile、上架流程 |
这张表不是让大家照着背,而是要理解:每一行左侧是面试题的外壳,中间是考察点,右侧才是面试官真正想听到的内容。很多人挂在第三轮技术面,往往是因为右侧答不出来。
1.1 笔试的题型与模块分布
第四批的笔试形式和其他批次类似,基本是选择题加在线编程题。选择题里iOS方向会混入一部分数据结构、计算机网络和操作系统的通用题,然后是iOS专项题。iOS专项题里,内存管理和多线程出现的频率最高,Runtime和Runloop紧跟其后,UI布局和事件响应每年都会有一两道,但往往不是直接问API,而是给你一个场景问你怎么调。这套题的价值在于:它很早就把"底层原理"和"工程落地"绑定在了一起。比如不问你Runloop是什么,而是问"为什么在列表滚动时,用NSTimer做的轮播图会卡顿",这种问题如果只背概念,现场很容易被追问到露馅。
1.2 复盘时我是怎么用这套题的
我建议拿到这套题之后,不要急着看答案,更不要只看别人整理的结论。第一步是自己限时做一遍,主观题也逼自己写出来。第二步是每道题画图,比如循环引用就把对象关系图、引用计数变化画出来;Runloop就把当前线程的mode、source、timer、observer画成状态图。第三步是把每道题扩展成一个小Demo,在Xcode里跑一遍,验证自己的理解。我当时把"weak置nil"、"消息转发"、"UIStackView标签布局"这三个点各写了一个测试工程,做完之后再去面试,明显感觉心里踏实得多。很多人说题目旧,但知识树是新的,2018年的校招题放在今天依然是很好的练习题。
2. 先看答案再看源码:weak自动置nil背后的SideTable与循环引用排查
第四批有一道经典选择题:定义一个__weak修饰的UIViewController变量,指向某个对象后,当这个对象被释放,weak变量会自动变成nil。问为什么。这道题看着简单,但答案的深度能直接区分出你是背过八股还是看过源码。很多人只会说"因为weak不持有对象,对象释放后系统会清空",但面试官继续问:系统是怎么知道哪些weak指针指向这个对象的?它在哪存这些信息?就接不上来了。
2.1 从"weak变量自动变成nil"扯出底层原理
先说结论:runtime维护了一张全局的weak表,以对象地址为key,以所有指向该对象的weak指针地址数组为value。当对象要释放时,runtime会通过objc_clear_deallocating找到这张表里对应的记录,把所有weak指针都置成nil,然后删除记录。
这张表并不是一个简单的字典,它的结构是SideTable里挂着一个weak_table_t,里面又包含一个个weak_entry_t。每个entry记录了一个对象地址对应的所有weak引用位置。可以理解成:每个对象身上挂了一个"订阅名单",当它被销毁时,系统拿着名单挨个打电话通知:"我没了,你们把指向我的指针清空吧。" 这个机制也解释了为什么weak比unsafe_unretained安全:unsafe_unretained没有这个通知过程,对象释放后指针还在,下一次访问就是野指针崩溃。
这里还有一个容易被追问的点:__weak变量在访问时会有什么额外动作?runtime为了保证线程安全,访问weak变量时会先objc_loadWeakRetained,把它临时retain一次,等使用完再释放。所以如果在多线程环境下,用__weak变量也可能在很短的时间内改变状态,这也是为什么很多场景下需要用__strong再接管一次,防止对象在方法执行到一半时被释放。
2.2 dealloc没打印?一条完整的泄漏排查链路
校招面试不会只考原理,还会问"线上怎么排查循环引用"。这题在第四批里以另一种形式出现过:给一段Block和NSTimer混用的代码,问会不会漏释放。我当时在项目里真的遇到过:一个首页的弹窗控制器点击关闭后,dealloc一直不打印,页面反复进入退出,内存一直涨。排查链路大概是这样的:
第一步,在控制器的dealloc里加日志,发现退出后日志没有输出,说明对象没有被释放。第二步,打开Instruments的Leaks工具跑一遍,能直接看到循环引用链。但Leaks在模拟器和真机上有时候检测不灵敏,所以我同时用了MLeaksFinder,它会在控制器消失后延迟几秒检查对象是否存活,如果没有释放直接弹窗提示。第三步,检查所有Block,最常见问题是Block里直接调用了self的方法,或通过属性间接访问了self.view、_ivar,导致Block持有self。第四步,检查NSTimer和CADisplayLink,这两个如果放在控制器里,必须要在dealloc之前invalidate,否则Timer持有target,永远释放不掉。第五步,检查delegate,如果delegate被用strong修饰,也会形成A持有的B又反过来持有A的循环引用,正确写法应该是weak或assign。
修复时先用__weak typeof(self) weakSelf = self,在Block里需要持续访问的地方再用__strong typeof(weakSelf) strongSelf = weakSelf,防止执行过程中对象被释放。这条链路解决的不只是这一道面试题,而是所有iOS开发者迟早会遇到的线上问题。面试时能把排查顺序讲清楚,比单纯说"用weak"要加分得多。
3. 并发不是背概念:主队列死锁、Runloop常驻线程与NSOperation依赖
多线程部分是第四批的重头戏。GCD、NSOperation、Runloop几乎是必考项,而且喜欢放在一起考。我记得有一道题是:在主线程上执行dispatch_sync(dispatch_get_main_queue(), ^{});,会死锁吗?很多人凭印象答"不会",因为在主队列里只是加了一个任务,但实际跑一下就知道,程序会卡死。这道题的坑在于,它同时考察了队列和线程的概念,以及同步、异步的本质区别。
3.1 主队列dispatch_sync为什么一定死锁
先明确一点:主队列是串行队列,主线程是这个队列上唯一的工作线程。dispatch_sync的含义是"把任务提交到指定队列,并且等待这个任务执行完毕后再继续返回"。当你在主线程上调用dispatch_sync(dispatch_get_main_queue(), ^{...})时,当前主线程正在执行的任务还没有结束,Block被放到主队列尾部排队;而Block要执行,必须等主线程空闲;主线程要空闲,必须等当前这个包含dispatch_sync调用的任务结束。两边互相等,就死锁了。
这不是"主队列里有任务"导致的,而是"同步等待 + 同一个串行队列"导致的。换个场景:如果你在另一个串行队列A里,再向A提交同步任务,同样会死锁。但如果是向主队列dispatch_async,就不会死锁,因为异步提交不需要等待Block执行完,当前任务可以直接结束,主线程随后再去执行Block。面试官一般还会追问"同步提交到并行队列会死锁吗",答案是不会,因为并行队列可以同时执行多个任务,新提交的Block可以立即被其他线程拿走去执行,不需要等当前任务结束。
3.2 Runloop、Timer和自动释放池的关系
Runloop在这套题里的经典考法是:为什么在UIScrollView滚动时,NSTimer会不执行?因为NSTimer默认被添加到了NSDefaultRunLoopMode模式下,而用户拖拽滚动时,主线程的Runloop会切换到UITrackingRunLoopMode,不在默认模式下的Timer就不会被触发。解决方案是把Timer加到NSRunLoopCommonModes,这样无论是在默认模式还是追踪模式下都能执行。这个点要真正理解,而不能只记答案,因为面试官紧接着会问"Runloop有哪些模式、source和observer分别干什么"。
Runloop的作用本质上是让线程在有事可做时快速处理事件,没事做时进入睡眠状态,避免占用CPU。主线程的Runloop默认启动,维护了source0(普通事件)、source1(Mach port消息)、timer、observer。observer可以监听Runloop的进入、即将处理Timer、即将处理Source、即将休眠、被唤醒、退出等状态。自动释放池和Runloop也有关系:主线程的autoreleasepool会在每次Runloop循环结束时被释放,所以大量临时对象不会一直积压到内存爆掉。如果面试官再深挖,可能会问常驻线程怎么做,写法是给子线程的Runloop添加一个Port或者Timer,然后调用run方法,这样线程不会执行完就退出。
3.3 NSOperationQueue被忽略的编排能力
GCD能覆盖大部分并发需求,但面试题里出现NSOperation往往是为了考察任务依赖和取消。NSOperationQueue是建立在GCD之上的更高层抽象,它的核心优势是可以设置addDependency:,让一个操作依赖另一个操作完成后再执行,这在GCD里实现起来要自己加信号量或group,比较麻烦。此外,NSOperation可以设置最大并发数,可以取消操作,还可以用KVO监听操作状态。面试时如果能把GCD和NSOperation的使用场景区分开,会显得你对并发理解更完整:简单、轻量的并发任务用GCD,需要复杂依赖管理、需要取消、需要关注任务状态的用NSOperationQueue。这个答案虽然没有黑科技,但胜在思路清晰,符合面试官对校招生的期望。
4. 从unrecognized selector崩溃开始,把Runtime动态性讲透
Runtime是iOS面试的深水区,第四批也不例外。几乎每个iOS方向的候选人都会被问到"向一个对象发送不存在的方法会怎样"。很多人直接回答"崩溃,unrecognized selector",但这个回答只对了最外层。Runtime真正考的是对象在收到消息之后,系统做了哪些查找和补救动作,这整个过程决定了你对OC动态性的理解深度。
4.1 消息发送与消息转发的完整路径
OC的方法调用本质上是objc_msgSend。第一步,通过对象的isa找到类对象;第二步,在类的缓存和方法列表中查找方法实现;如果当前类找不到,就沿着superclass逐级向上找;如果一路找到NSObject都找不到,系统并不会立刻崩溃,而是先进入动态方法解析阶段,也就是+resolveInstanceMethod:。如果这个阶段没有添加实现,系统会尝试把消息转发给其他对象,调用forwardingTargetForSelector:;如果这个返回nil,系统还会进入完整的消息转发流程,调用methodSignatureForSelector:和forwardInvocation:,把方法签名和参数包装成NSInvocation交给别的对象处理。只有当所有阶段都没人能处理时,才会抛出unrecognized selector异常。
这个流程可以用一个很生活的类比:你想让同事帮忙打印一份文件,先自己找打印机;找不到就问问隔壁团队有没有打印机;隔壁也没有,但有个团队说"我们不会打印,但可以帮你联系外面的打印店";最后外面也没人接,你才上楼找领导说这活干不了。面试时把这个链路讲完整,给一个实际应用场景,基本就稳了。场景可以有:用消息转发实现多继承效果,或者做防崩拦截,把unrecognized selector的异常记录下来。
4.2 isa、类对象、元类:类方法到底存在哪
面试官追问消息查找时,通常会抛出一个更底层的问题:实例方法的isa指向类对象,那类方法的isa指向什么?答案是元类(Meta Class)。实例对象的方法存在类对象里,类方法存在元类里。类对象的isa指向元类,元类的isa指向根元类,根元类的isa指向自己。superclass这条线则负责继承查找:子类的类对象通过superclass找到父类的类对象,去查找父类的实例方法;子类的元类通过superclass找到父类的元类,去查找父类的类方法。理解这张"类-元类-根元类"结构图,才能真正看懂objc_msgSend为什么能统一处理实例方法和类方法。面试时能画出这张图,和你只是口头说"isa指向类"完全不一样。
4.3 Method Swizzling的使用场景与交换避坑
Runtime在工程里最常见的应用是Method Swizzling,比如页面统计、防止按钮重复点击。第四批不一定会直接要求手写,但如果聊到Runtime,面试官会问"你用过Swizzling吗,有什么坑"。写起来不复杂,在load方法里用class_getInstanceMethod拿到两个Method,再method_exchangeImplementations交换实现,最后用dispatch_once保证只交换一次。
代码大概长这样:
+ (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Method original = class_getInstanceMethod(self, @selector(viewDidAppear:)); Method swizzled = class_getInstanceMethod(self, @selector(swizzled_viewDidAppear:)); method_exchangeImplementations(original, swizzled); }); }坑在于:第一,如果父类和子类都做了交换,可能重复交换导致无限递归,所以交换前要判断方法是否已经交换过,或者只交换一次。第二,交换后的方法里要记得调用原来的实现,否则会破坏原有逻辑。第三,load方法被调用时其他类的load不保证执行顺序,如果Swizzling涉及多个类,要小心依赖顺序。第四,尽量不要在initialize里做Swizzling,因为它可能被子类触发多次。这些细节都是实际项目中总结出来的,面试时主动说出来,会让面试官觉得你不只是看过API,而是真的有线上经验。
5. UIStackView与Autolayout:一道"标签流布局"题背后的布局体系
UI布局类题目在字节跳动2018校招iOS方向(第四批)里出现得很巧,正好赶上UIStackView已经普及但很多人还没用熟的阶段。面试题一般给一个需求:页面上有一组标签,数量不确定,每个标签的宽度随文字变化,要让它们按从左到右、从上到下的方式排列。如果按老思路,要么手动算frame,要么写一堆约束,很容易翻车;用UIStackView就简洁很多,但要用好也有讲究。
5.1 用UIStackView手撸标签布局的正确姿势
UIStackView是iOS 9引入的一个容器视图,它把Autolayout约束封装成了简单的属性配置。用代码创建标签流布局,核心是给每个标签设置好内容约束,然后加到stackView的arrangedSubviews里,stackView会自动处理排列。横向一行排不下时,外面再套一个垂直方向的stackView,每次横向stackView装满了就另起一行,这样能得到一个类似流式布局的效果。
UIStackView *stackView = [[UIStackView alloc] init]; stackView.axis = UILayoutConstraintAxisHorizontal; stackView.alignment = UIStackViewAlignmentTop; stackView.distribution = UIStackViewDistributionFillProportionally; stackView.spacing = 8.0;这里要特别注意distribution的取值区别:fill会拉伸某个视图填满剩余空间,fillEqually会让所有子视图等宽,fillProportionally按内容比例分配,equalSpacing只保证间隔相等,equalCentering按子视图中心点等距排列。面试时如果能说清楚fillProportionally和fillEqually的区别,基本就能压住场面。实际开发中还有一个坑:UIStackView的间距在iOS 11之前不支持针对不同子视图设置不同间距,只能通过插入透明Spacer或手动约束处理,现在虽然支持setCustomSpacing:afterView:了,但部署版本低于iOS 11时还是要做兼容。
5.2 高度自适应与content hugging:布局题的标准答法
另一道高频布局题是:UILabel的内容长度不固定,怎么让它自动撑开父视图。条件其实很简单:UILabel的numberOfLines设为0,上下左右约束齐全,不要设置固定高度。如果放在UITableViewCell里,还需要开启自动估算行高:
tableView.estimatedRowHeight = 100; tableView.rowHeight = UITableViewAutomaticDimension;但Auto Layout面试题真正想考的是Content Hugging和Compression Resistance。这两个概念用大白话讲就是:当视图空间富余时,谁更愿意被拉伸;当空间不够时,谁更不愿意被压缩。UILabel默认的抗压缩优先级较高,所以它一般不会轻易被裁切;UIButton的默认优先级相对低,就会出现内容显示不全的问题。setContentHuggingPriority:forAxis:和setContentCompressionResistancePriority:forAxis:就是用来调整这个优先级的。很多新手在UIStackView里发现某个Label被拉得很宽、另一个缩得很窄,原因就是没有设置这两个优先级。记住一个原则:内容型控件(Label、Button)通常需要提高抗压缩优先级,容器型视图需要降低抗拉伸优先级,布局才能稳定。
5.3 从hitTest到响应链:事件传递的隐藏考点
布局题经常和事件传递一起考,因为两者都涉及视图层级。点一个按钮,系统是怎么知道该让按钮响应而不是让上面的透明View响应?答案是UIView的hitTest:withEvent:方法。事件传递从最底层的UIWindow开始,先调用hitTest判断点击点是否在window内,然后逆序遍历子视图,对每个子视图调用pointInside:withEvent:。如果点在某个子视图内部,就继续深入到子视图的子视图去查找,直到找到最深的那个视图,最后返回它作为first responder。这就是为什么一个透明但userInteractionEnabled为YES的覆盖层View会挡住下面按钮的点击。面试时还会追问"ViewController的view的nextResponder是谁",答案是UIViewController,因为UIView通过nextResponder把事件向上传递给了控制器。
6. 证书、签名、上架和线上排查:iOS工程化里的"送命题"
前几轮聊原理聊得再好,工程化问题回答不上来,面试同样会减分。字节跳动2018校招iOS方向第四批里也有这类题,因为它和一个iOS开发者能不能独立交付直接相关。最典型的问题是:真机调试时报错"no valid provisioning profile found",应该怎么排查。
6.1 Provisioning Profile到底是什么,报错怎么查
先说结论:Provisioning Profile(描述文件)是一份绑定关系文件,它把"AppID、开发/发布证书、设备UDID(开发时)、Entitlements权限"这四样东西绑在一起,告诉系统和Xcode"这个App在哪些设备上、用哪个身份、能使用哪些能力"。你拿到一台新设备去调试,如果描述文件里没有这台设备的UDID,就会报上面那个错。
排查顺序也很固定。第一步,确认Xcode的Signing & Capabilities页面里Team选择正确,Bundle Identifier和AppID一致。第二步,打开Apple开发者后台,检查证书和描述文件是否过期,尤其是证书到期后描述文件会失效。第三步,把真机的UDID添加到描述文件里并重新下载。第四步,确认使用的是开发证书而不是发布证书,开发描述文件而不是App Store描述文件。如果Xcode开启了自动签名,一般只需要登录账号并勾选Automatically manage signing,但在企业内网环境或者需要特殊Entitlements时,还是会遇到手动签名问题。上架时还有一个相关的坑:App Store Connect里收到的"Missing required icon"等审核问题,往往都和Assets.xcassets里的图标尺寸不全有关,这些不会直接写在笔试里,但面试官问到"你上架过吗"时都是送分点。
6.2 动态库、App瘦身与启动优化
工程化题目还有一个方向是性能优化,和动态库、启动耗时直接相关。面试题常见的是"为什么App里的动态库不能太多"。动态库在启动时由dyld加载,加载过程包括解析、映射、绑定符号等,数量越多、文件越大,启动时间就越长。这也就是为什么许多大型App会做动态库合并,甚至把一些基础库静态化。App瘦身方面,还能提App Thinning、Bitcode和按需资源,不过Bitcode在后来已经不再是必选项,现场回答时提到"这是当时的优化思路"会更准确。启动优化可以往二进制重排上引,因为启动时最耗时的是Page Fault,通过把启动路径上的方法集中到同一页,能减少缺页中断。不过校招面试一般不要求你实现,能说出思路就可以。
6.3 排查线上问题:从抓包到符号化Crash
这一节虽然在2018年校招里不一定直接考,但面试官通常会问"线上出了崩溃,你怎么定位"。回答主线应该是:先收集崩溃日志,然后解析内存地址到具体函数。拿到.ips或者.crash文件后,用xcrun atos命令结合dSYM符号文件还原调用栈,这是最直接的方式。调用格式大概是:
xcrun atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x0000000100abcdef其中-l后面是加载地址,最后一个参数是崩溃日志里的地址。只有dSYM文件和构建版本完全对应,才能还原出可读的调用栈,所以App打包时归档并保存dSYM是上架前的硬要求。网络接口问题就靠抓包工具,比如用Charles开启SSL解密,安装并信任证书后看请求和响应,能快速判断是参数问题、接口异常还是网络环境问题。这些经验不是面试前临时能背熟的,需要在真实项目中至少完整走一遍。
7. 给正在准备iOS校招的人:我踩过的坑和最后划的重点
把字节跳动2018校招iOS方向(第四批)这套题全部拆完之后,我最想说的是:不要只刷题,要把每道题变成一张知识网。比如从weak自动置nil出发,能扯出SideTable、引用计数、循环引用、MLeaksFinder、Block捕获变量,这一整条链其实就是一个对象从创建到释放的生命周期过程。面试官不会要求你背源码,但你至少要能说出"为什么"和"如果不这样会怎样"。
在准备过程中有几点体会。第一,编程题不要只在本地编译通过就完,牛客网这类在线评测系统对输入输出格式要求很严格,很多人挂在校招笔试不是因为算法不会,而是因为没处理多行输入、没考虑边界条件。第二,所有原理都要动手验证,我当年为了搞懂Runloop,真的在工程里写了一个常驻子线程,用addPort让它不退出,然后打印每一次Observer回调,调试完才彻底明白。第三,表达上先给结论再展开细节,比如面试官问"Timer不执行怎么办",你可以先说"因为Runloop切换到了Tracking模式,Timer不在当前模式,解决办法是加入CommonModes",然后再说底层原因,这样面试官会很快抓住你的思路。
最后一个建议是给时间不多的人的优先级排序:内存管理、Runtime、Runloop、多线程这四个模块是iOS面试的基石,优先搞透;UIStackView和Autolayout是工程里最常用的布局手段,一定要会用;证书签名和上架属于"必须经历过一次"的知识点,自己找个真机项目完整走一遍,比背十篇文档都管用。我当时在项目里反复刷这套题,最大的收获不是押中了多少原题,而是终于把平时写代码时那些"感觉是这样"的模糊理解,变成了能讲清楚、能写出来、能扛住追问的确定性知识。