题目 1:消息发送机制
标准答案
OC 中方法调用的本质是 objc_msgSend(receiver, selector, ...),整个过程分三大阶段:
阶段一:消息发送
-
检查 receiver 是否为 nil,是则直接返回 0/nil
-
通过 receiver 的 isa 指针找到其 Class(arm64 下 isa 是 Non-pointer isa,需要
& ISA_MASK取出类指针) -
在 Class 的
cache_t方法缓存中查找(汇编实现,hash 算法 =selector & mask,开放寻址线性探测) -
缓存未命中,在 Class 的
method_list中查找(已排序用二分查找,未排序用线性查找) -
当前类未找到,沿 superclass 链逐级向上查找(每级先查缓存再查方法列表)
-
找到后将
SEL → IMP缓存到当前类的cache_t中,然后调用 IMP -
直到 NSObject 都未找到 → 进入阶段二
阶段二:动态方法解析
-
调用
+resolveInstanceMethod:(实例方法)或+resolveClassMethod:(类方法) -
开发者可以在此用
class_addMethod动态添加方法 -
添加后重走阶段一,且通过
triedResolver标志保证只解析一次 -
若仍未找到 → 进入阶段三
阶段三:消息转发
-
快速转发:
-forwardingTargetForSelector:返回另一个对象接管消息 -
完整转发:
-methodSignatureForSelector:返回方法签名 →-forwardInvocation:处理 NSInvocation -
都未处理 →
doesNotRecognizeSelector:→ crash
追问 1:objc_msgSend 为什么用汇编实现?
-
极致性能:消息发送是 OC 最频繁的操作(每秒调用百万次),汇编能精确控制每一条指令,C 语言做不到
-
参数透明转发:objc_msgSend 需要将任意数量、任意类型的参数原封不动地传递给最终的 IMP。C 函数无法实现"不知道有多少参数但全部转发"的功能,而汇编可以直接保持寄存器状态不变然后 jmp
-
尾调用优化:汇编中找到 IMP 后用
br x17(间接跳转)而非bl(函数调用),不会创建新的栈帧,直接复用 objc_msgSend 的栈帧 -
避免不必要的寄存器保存/恢复:C 编译器会插入 prologue/epilogue(保存/恢复 callee-saved registers),汇编可以跳过
追问 2:cache_t 的哈希冲突如何解决?
使用开放寻址法中的线性探测。初始 index = selector & mask,如果该位置的 bucket.sel 不匹配,则 index = (index - 1) & mask(向前探测,而非向后),直到找到匹配的 sel 或遇到空 slot(sel == 0)。
**为什么向前探测(index - 1)? ** 在 arm64 下,bucket_t 的内存布局是 {IMP, SEL},向前遍历可以利用 ldp 指令同时加载两个连续 64 位值(一条指令读取 IMP + SEL),减少内存访问次数。
追问 3:扩容时为什么清空缓存而不是 rehash?
-
方法调用具有时间局部性:最近调用的方法更可能再次被调用,旧缓存中的冷方法价值不大
-
rehash 开销大:需要遍历旧 buckets、重新计算 hash、插入新 buckets,而扩容后新缓存很快就会被重新填充
-
简化实现:清空操作是 O(1)(memset 或 free + malloc),rehash 是 O(n)
题目 2:weak 的实现原理
标准答案
****赋值过程(** __**weak id weakObj = obj** ): **
-
编译器将赋值转换为
objc_storeWeak(&weakObj, obj)调用 -
根据 obj 的地址哈希,找到对应的 SideTable(全局有 64 个 SideTable,
StripedMap通过(addr >> 4) ^ (addr >> 9)) % 64取模定位) -
在 SideTable 的
weak_table_t(弱引用表)中以 obj 地址为 key 查找weak_entry_t -
如果不存在 → 创建新的
weak_entry_t,插入 weak_table -
将
&weakObj(weak 指针变量本身的地址)添加到weak_entry_t的引用数组中 -
同时设置 obj 的
isa.weakly_referenced = 1
**对象释放时自动置 nil: **
-
dealloc→_objc_rootDealloc→rootDealloc→object_dispose→objc_destructInstance -
clearDeallocating→clearDeallocating_slow -
检查
isa.weakly_referenced,如果为 1: -
在
weak_table中找到该对象的weak_entry_t -
遍历引用数组,将每个 weak 指针(
*referrer)置为nil -
从
weak_table中移除该weak_entry_t -
如果
isa.has_sidetable_rc,同时清除 SideTable 中的引用计数
追问 1:SideTable 为什么有 64 个?
**减少锁竞争。 ** 每个 SideTable 有独立的 spinlock_t 锁。如果全局只有一个 SideTable,所有对象的 retain/release/weak 操作都要争抢同一把锁,多线程下性能极差。64 个 SideTable 将竞争分散到不同的锁上。为什么是 64?太少竞争仍然激烈,太多浪费内存(每个 SideTable 包含一把锁 + 两个哈希表),64 是经验最优值。
追问 2:weak_entry_t 为什么有 inline 优化?
weak_entry_t 内部使用 union:当弱引用指针 ≤ 4 个时用内联数组(inline_referrers[4]),超过 4 个时切换为动态数组(referrers 指针 + hash 结构)。
原因:大多数对象的 weak 引用者不超过 4 个(通常就 1-2 个 delegate/weak self)。内联数组避免了额外的 malloc 开销和指针间接寻址,提升缓存命中率。
题目 3:Block 的底层实现
标准答案
Block 本质是一个 OC 对象(结构体),包含 isa、FuncPtr(函数指针)、Flags、Descriptor(大小 + copy/dispose 函数)以及捕获的变量。
**三种类型: **
| 类型 | 位置 | 条件 |
|------|------|------|
| __NSGlobalBlock__ | 数据段(.data) | 不捕获 auto 变量 |
| __NSStackBlock__ | 栈 | 捕获了 auto 变量(MRC 或 ARC 下未被强引用) |
| __NSMallocBlock__ | 堆 | Stack Block 调用了 copy(ARC 下赋值给 __strong 变量自动 copy) |****
**变量捕获规则: **
-
局部 auto 变量 → 值捕获(结构体内保存副本)
-
局部 static 变量 → 地址捕获(结构体内保存指针)
-
全局变量 → 不捕获(直接访问)
-
self→ 值捕获(捕获的是 self 这个指针的值)
****** __**block** 修饰符: ** 将变量包装成 ** __**Block_byref_xxx** 结构体,结构体中有 ** __**forwarding** 指针。Block 捕获的是该结构体的指针。
追问 1:__forwarding 指针的两种状态?****
-
Block 在栈上时:栈上
byref.__forwarding指向栈上的自身 -
Block copy 到堆上后:
__block变量也被 copy 到堆上,此时:
- 栈上 byref.__forwarding → 指向堆上的副本
- 堆上 byref.__forwarding → 指向自身
-
意义:无论通过栈上还是堆上的 byref 访问变量,
byref->__forwarding->value总是访问到堆上的同一份数据,保证了数据一致性
追问 2:Block copy 时 capture 的对象怎么处理?
Block 从栈 copy 到堆时,Descriptor 中的 copy 函数被调用,内部对每个捕获的变量调用 _Block_object_assign:
-
对象类型(
BLOCK_FIELD_IS_OBJECT):根据所有权修饰符执行 retain 或 weak 注册 -
Block 类型(
BLOCK_FIELD_IS_BLOCK):对嵌套 Block 执行_Block_copy -
****** __block 变量**** (
**BLOCK_FIELD_IS_BYREF**):将** __**Block_byref**结构体 copy 到堆上
Block 释放时对称地调用 dispose 函数 → _Block_object_dispose → release/weak 取消注册。
题目 4:RunLoop 的原理
标准答案
RunLoop 本质是一个 do-while 循环,保持线程存活。核心流程(简化):
1. 通知 Observer: kCFRunLoopEntry(进入)
2. 通知 Observer: kCFRunLoopBeforeTimers(即将处理 Timer)
3. 通知 Observer: kCFRunLoopBeforeSources(即将处理 Source0)
4. 处理 Source0(非端口事件,如触摸事件)
5. 如果有 Source1 就绪 → 跳到步骤 9
6. 通知 Observer: kCFRunLoopBeforeWaiting(即将休眠)
7. 调用 mach_msg 休眠,等待唤醒
8. 通知 Observer: kCFRunLoopAfterWaiting(已唤醒)
9. 处理事件(Timer / Source1 / GCD 主队列 block)
10. 判断是否退出,否则回到步骤 2
11. 通知 Observer: kCFRunLoopExit(退出)
**Mode 机制: ** 每次 RunLoop 只能在一个 Mode 下运行。Source/Timer/Observer 注册到特定 Mode。NSRunLoopCommonModes 不是真正的 Mode,而是将 item 同时注册到所有标记为 Common 的 Mode 中(默认包含 Default + UITracking)。
追问 1:mach_msg 是什么?休眠为什么不消耗 CPU?
mach_msg 是 Mach 内核的消息收发系统调用。RunLoop 调用 mach_msg(msg, MACH_RCV_MSG, ...) 进入内核态,线程被内核挂起(从可运行队列移除),CPU 调度器不再分配时间片给这个线程,所以不消耗 CPU。当有消息到达端口(Source1 / Timer / dispatch_main_queue 等发送 mach_msg),内核将线程重新加入可运行队列,线程恢复执行。
这与 while(1){} 的空循环完全不同——空循环是用户态的忙等,线程仍在运行消耗 CPU。
追问 2:autorelease 对象什么时候释放?
主线程 RunLoop 注册了两个 Observer:
-
kCFRunLoopEntry(优先级最高):调用
_objc_autoreleasePoolPush()创建新池 -
kCFRunLoopBeforeWaiting + kCFRunLoopExit(优先级最低):先
_objc_autoreleasePoolPop()释放旧池中的对象,再_objc_autoreleasePoolPush()创建新池
所以 autorelease 对象在当前 RunLoop 迭代即将休眠时被释放。一般理解为"当前事件处理完毕后"释放。手动 @autoreleasepool {} 则在出花括号时立即释放。
题目 5:Category 的底层原理
标准答案
Category 编译后生成 category_t 结构体,包含实例方法列表、类方法列表、协议列表、属性列表。
**运行时加载过程: ** _objc_init → map_images → _read_images → 遍历 __objc_catlist 段中的所有 category → attachCategories → 将 category 的方法列表插入到类的 class_rw_ext_t.methods 数组的最前面。
** "覆盖"原理: ** 方法没有被真正覆盖,宿主类的方法仍然存在。只是 lookUpImpOrForward 遍历方法列表数组时从前往后找,先找到 category 的方法就返回了。多个 category 有同名方法时,后编译的排在前面(取决于 Build Phases → Compile Sources 的顺序)。
**不能添加成员变量的原因: ** 类的 instanceSize 存储在编译期生成的 class_ro_t 中(只读),运行时添加成员变量会改变实例大小,导致已存在的实例内存布局错误和子类偏移量失效。
追问 1:class_rw_ext_t 是什么?
iOS 14 的优化。原来每个类都要创建完整的 class_rw_t(包含可动态修改的方法/属性/协议数组),但实测约 90% 的类运行时不会被修改。优化后 class_rw_t 的 ro_or_rw_ext 默认直接指向 class_ro_t(只读,不需要额外分配),只有当需要动态修改时(如加载 category、class_addMethod)才创建 class_rw_ext_t。全系统节省约 14MB 内存。
题目 6:KVO 的底层原理
标准答案
当对象被 addObserver:forKeyPath: 时:
-
Runtime 动态创建子类
NSKVONotifying_ClassName(继承自原类) -
将对象的 isa 指向这个动态子类(isa swizzling)
-
动态子类重写被观察属性的 setter,内部实现:
- [self willChangeValueForKey:@"xxx"](记录旧值)
- [super setXxx:xxx](调用原类的 setter)
- [self didChangeValueForKey:@"xxx"](计算新值,遍历所有 observer 回调 observeValueForKeyPath:)
-
动态子类还重写了
-class(返回原类伪装)、-dealloc(恢复 isa)、-_isKVOA(返回 YES)
追问 1:为什么用 isa swizzling 而不是 Method Swizzling?
**Method Swizzling 影响所有实例。 ** method_exchangeImplementations 修改的是类的方法列表中的 IMP,该类的所有实例调用该方法时都会走新 IMP。但 KVO 的需求是只对被观察的特定实例生效,不应影响同类的其他实例。
**isa swizzling 只影响单个实例。 ** 只修改了特定对象的 isa 指针,指向动态子类。其他同类实例的 isa 仍指向原类,不受任何影响。
追问 2:动态子类重写了哪些方法?
| 方法 | 作用 |
|------|------|
| -setXxx:(被观察属性的 setter) | 在赋值前后插入 will/didChangeValueForKey: |
| -class | 返回原类([obj class] 看不出是 KVO 子类) |
| -dealloc | 在释放时将 isa 恢复为原类 |
| -_isKVOA | 标识这是一个 KVO 动态子类,返回 YES |
注意:setter 的 IMP 根据属性类型不同有不同实现:_NSSetIntValueAndNotify、_NSSetObjectValueAndNotify、_NSSetBoolValueAndNotify、_NSSetFloatValueAndNotify 等。
题目 7:AutoreleasePool 的底层
标准答案
@autoreleasepool {} 编译后变为 objc_autoreleasePoolPush() + objc_autoreleasePoolPop(token)。
底层由 AutoreleasePoolPage 双向链表实现。每个 page 大小 4096 字节(虚拟内存页大小),页头约 56 字节用于存储 magic、next、thread、parent、child 等元数据,剩余空间约 4040 字节存放 autorelease 对象的指针(约 505 个)。
push 时在当前 page 的 next 位置插入一个 POOL_BOUNDARY(nil),next++,返回 POOL_BOUNDARY 的地址作为 token。
autorelease 时在 next 位置存入对象指针,next++。page 满了就创建新 page(child)。
pop(token) 时从 next-1 开始从后往前逐个取出对象指针调用 objc_release,直到遇到 token(POOL_BOUNDARY)为止。
追问 1:page 大小是多少?
4096 字节 = PAGE_MIN_SIZE = 一个虚拟内存页。这不是巧合——与虚拟内存页对齐,可以最大化内存使用效率,减少内存碎片。
追问 2:怎么找到 POOL_BOUNDARY?
push 返回的 token 就是 POOL_BOUNDARY 在 page 中的地址。pop 时传入 token,通过地址计算得知它在哪个 page(pageForPointer(token) = 将地址向下对齐到 4096 边界),然后从当前 page 的 next-1 开始释放直到 token 位置。
追问 3:子页的释放策略?
pop 完成后,如果当前 page 有 child page:保留一个空的 child page(作为缓存,避免下次 push 时又要 malloc),但释放 child 的 child 及其后续所有 page。即最多保留一个空的预备 page。
题目 8:GCD 死锁
标准答案
经典死锁场景:主线程中 dispatch_sync(dispatch_get_main_queue(), block)。
**原因分析: **
-
dispatch_sync将 block 提交到主队列,并阻塞当前线程(主线程)等待 block 完成 -
主队列是串行队列,block 排在队列中等待执行
-
但 block 的执行需要当前正在主线程运行的代码(包含 sync 调用)先完成
-
sync 等 block 完成 → block 等 sync 所在代码完成 → 互相等待 → 死锁
**本质规则: ** 在串行队列正在执行的任务中,对同一个串行队列 dispatch_sync 就会死锁。不仅限于主队列。
追问 1:dispatch_sync 底层如何检测死锁?
GCD 内部通过比较当前线程的 dispatch_queue 标识与目标队列是否相同来检测直接死锁。如果检测到当前线程正在执行目标串行队列的任务,又对它 sync → 直接 DISPATCH_CLIENT_CRASH(0, "dispatch_sync called on queue already owned by current thread")。
但 GCD 无法检测间接死锁(如队列 A sync 到队列 B,队列 B 又 sync 到队列 A),这种情况会静默死锁(线程卡住),不会 crash。
追问 2:GCD 线程池大小是多少?
GCD 使用全局线程池,线程数量由 libdispatch 内核动态管理。上限通常是 64 个工作线程(DISPATCH_WORKQ_MAX_PTHREAD_COUNT),但实际创建数量取决于系统负载和 CPU 核心数。大量 dispatch_async 到并发队列不会无限创建线程——超过上限的任务会排队等待。但仍可能出现线程爆炸(thread explosion):如果 64 个线程都阻塞在 sync/lock/IO 上,GCD 可能被迫创建更多线程来执行其他 non-blocked 任务。
题目 9:启动优化
标准答案
冷启动分 pre-main(dyld 加载)和 post-main(业务初始化)两个阶段。
**pre-main 优化: **
-
减少动态库数量(合并或转为静态库,Apple 建议自定义 dylib ≤ 6 个)
-
减少 OC 类数量(合并小类、删除无用类,每个类都要在
_objc_init中注册) -
减少
+load方法(移到+initialize懒加载) -
二进制重排:通过 Clang 插桩获取启动函数调用顺序,生成 order file,让链接器将启动函数排列在连续内存页中,减少 Page Fault
**post-main 优化: **
-
didFinishLaunching中区分必要/非必要初始化,非必要延迟执行 -
首页数据预加载 + 骨架屏
-
非核心 SDK 延迟初始化或移到子线程
追问 1:Page Fault 一次多少毫秒?
一次 Page Fault 约 0.3~1ms(包含:磁盘 IO 读取文件页 → iOS 代码签名验证 → 页表更新 → TLB 刷新)。启动时如果有 300 次 Page Fault → 额外耗时 90300ms。二进制重排后可减少到 30-50 次 → 节省 50200ms。
追问 2:Clang 插桩的原理?
编译时加 -fsanitize-coverage=func,trace-pc-guard,编译器在每个函数入口插入 __sanitizer_cov_trace_pc_guard(&guard) 调用。运行时在该回调中通过 __builtin_return_address(0) 获取调用者的 PC 地址,记录到线程安全的队列中。启动完成后将所有 PC 通过 dladdr 转换为符号名,去重后输出 order file。
追问 3:dyld3 闭包是什么?
dyld3(iOS 13+)将首次启动时的解析结果(所有 dylib 的依赖关系、符号绑定地址、初始化器调用顺序等)缓存到磁盘的 Launch Closure 文件中。后续启动直接读取闭包,跳过大部分解析工作。系统 App 的闭包在系统更新时预生成,三方 App 在首次启动或更新后首次启动时生成。
题目 10:卡顿优化
标准答案
卡顿 = 主线程无法在 **16.67ms(60fps)/ 8.33ms(120fps ProMotion) ** 内完成一帧的处理。
**CPU 端优化: **
-
预计算布局、缓存 Cell 高度
-
子线程预解码图片
-
减少复杂的 Auto Layout(用 Frame Layout 替代)
-
文本排版预计算(
boundingRectWithSize:在子线程执行) -
对象创建/销毁移到子线程
**GPU 端优化: **
-
避免离屏渲染(圆角用
UIBezierPath预绘制、阴影设shadowPath、避免 mask) -
减少图层混合(设
opaque = YES、避免大量半透明重叠) -
图片尺寸匹配 UIImageView 大小(避免 GPU 缩放)
-
减少视图层级
追问 1:如何在子线程获取主线程调用栈?
-
保存主线程的
thread_t(mach thread port) -
在子线程中调用
thread_suspend(mainThread)暂停主线程 -
调用
thread_get_state(mainThread, ARM_THREAD_STATE64, ...)获取主线程的寄存器状态(包括 PC、FP、LR) -
从 FP(帧指针寄存器 x29)开始沿栈帧链回溯:每个栈帧的
FP+8处是返回地址(LR),*FP是上一帧的 FP -
用
dladdr()将每个返回地址转换为函数名 -
调用
thread_resume(mainThread)恢复主线程
追问 2:为什么离屏渲染慢?
正常渲染 GPU 按"画家算法"从后往前逐层绘制到帧缓冲区(On-Screen Buffer),一次遍历完成。离屏渲染需要:
-
创建额外的离屏缓冲区(Offscreen Buffer)
-
上下文切换:GPU 从 On-Screen Buffer 切换到 Offscreen Buffer 渲染,完成后再切回(上下文切换 = 保存/恢复渲染状态、帧缓冲区绑定等,非常昂贵)
-
等待所有子层渲染完成才能应用 mask/clip/group opacity 等操作
追问 3:VSync 机制是什么?
VSync(垂直同步信号)由显示器硬件以固定频率发出(60Hz = 每 16.67ms 一次)。iOS 使用双缓冲机制:GPU 渲染到后缓冲区(Back Buffer),VSync 到来时交换前后缓冲区(Buffer Swap)。如果 VSync 到来时渲染未完成,该帧无法交换 → 掉帧。CADisplayLink 就是基于 VSync 信号的回调。
题目 11:循环引用
标准答案
**常见场景与解决: **
| 场景 | 原因 | 解决方案 |
|------|------|---------|
| delegate | delegate 属性 strong 引用 | 用 weak 修饰 delegate |
| Block 捕获 self | Block strong capture self | __weak typeof(self) weakSelf |
| NSTimer | target 被 Timer 强引用 | NSProxy / block API / 适时 invalidate |
| WKWebView messageHandler | addScriptMessageHandler 强引用 | WeakScriptMessageDelegate 中间对象 |
| 通知中心 block 观察者 | block 捕获 self | __weak |
| CADisplayLink | 同 NSTimer | 同 NSTimer |
追问 1:MLeaksFinder 原理?
Hook UINavigationController 的 popViewControllerAnimated: 等方法。VC 被 pop 后,调用 [vc willDealloc] → 内部创建 __weak 引用 + dispatch_after(2秒) 检查 weak 引用是否还有值。如果 2 秒后 weak 引用非 nil → 对象未释放 → 可能有循环引用 → 弹窗警告。同时递归检查 VC 的所有子视图和子控制器。可选配合 FBRetainCycleDetector 用 DFS 遍历对象引用图找到环。
追问 2:NSProxy vs NSObject 的消息转发差异?
**NSObject: ** 收到未知消息时先走完整的方法查找流程(缓存 → 方法列表 → 父类链 → 动态解析),全部失败后才进入消息转发。如果 NSObject 本身有同名方法(如 class、respondsToSelector:),就不会走转发。
**NSProxy: ** 作为抽象代理类,几乎没有自己的方法实现。收到消息后直接走 methodSignatureForSelector: + forwardInvocation: 消息转发,不经过方法查找。所以 NSProxy 能完美代理所有消息(包括 class、isKindOfClass: 等),而 NSObject 子类做代理会"截获"这些方法导致转发失败。这就是为什么 NSTimer 防循环引用推荐用 NSProxy 而非 NSObject。
题目 12:线程安全与锁
标准答案
iOS 中 10 种线程同步方案(性能从高到低):
| 锁 | 类型 | 性能 | 特点 |
|----|------|------|------|
| os_unfair_lock | 互斥锁 | ★★★★★ | iOS 10+,替代 OSSpinLock |
| dispatch_semaphore | 信号量 | ★★★★☆ | 灵活,可控制并发数 |
| pthread_mutex | 互斥锁 | ★★★★ | POSIX 标准,支持递归/条件 |
| NSLock | 互斥锁 | ★★★☆ | OC 封装的 pthread_mutex |
| NSCondition | 条件锁 | ★★★ | 配合条件变量使用 |
| pthread_rwlock | 读写锁 | ★★★ | 多读单写 |
| NSRecursiveLock | 递归锁 | ★★☆ | 同线程可重入 |
| NSConditionLock | 条件锁 | ★★ | 带条件值的锁 |
| @synchronized | 递归锁 | ★ | 最方便但最慢 |
| OSSpinLock | 自旋锁 | ★★★★★ | 已废弃,有优先级反转问题 |
追问 1:OSSpinLock 优先级反转的完整过程?
假设三个线程:A(高优先级)、B(中)、C(低)。
-
C 获取 OSSpinLock,进入临界区
-
A 就绪,抢占 C 的 CPU 时间(高优先级优先调度)
-
A 尝试获取锁 → 自旋等待(while 循环忙等)
-
A 持续占用 CPU 自旋,因为 A 优先级最高,调度器不会给 C 分配时间
-
C 无法执行 → 无法释放锁 → A 永远拿不到锁
-
B 就绪后也抢占 C → C 更无法运行
-
实际效果:高优先级线程 A 被低优先级线程 C 阻塞 → 优先级反转
追问 2:os_unfair_lock 如何解决?
-
不自旋:等待线程调用
__ulock_wait系统调用进入内核态休眠,不消耗 CPU -
**Priority Inheritance(优先级继承) **:内核知道锁的持有者是谁(
__ulock_wait传入 owner thread),如果高优先级线程在等待低优先级线程的锁 → 内核临时将低优先级线程的优先级提升到等待线程的优先级 → 低优先级线程获得 CPU 时间 → 释放锁 → 恢复原始优先级 -
Unfair 策略:释放锁后不保证 FIFO 唤醒,刚释放锁的线程可能立刻再获取 → 减少上下文切换,提升吞吐量
题目 13:事件传递与响应链
标准答案
**事件传递(Hit-Testing)——从上到下: **
UIApplication → UIWindow → hitTest:withEvent: 递归:
-
检查
userInteractionEnabled && !hidden && alpha > 0.01 -
pointInside:withEvent:判断触摸点是否在范围内 -
从后往前遍历子视图,对每个子视图递归调用
hitTest: -
子视图返回非 nil → 返回该子视图(最深层的合适视图)
-
没有子视图命中 → 返回自身
**事件响应(Responder Chain)——从下到上: **
hitView → superView → ... → ViewController → UIWindow → UIApplication → AppDelegate
某个 Responder 处理了事件(实现 touchesBegan: 等)则停止传递,都没处理则丢弃。
追问 1:hitTest 为什么从后往前遍历子视图?
subviews 数组中,后添加的子视图在数组末尾,视觉上显示在最上层。从后往前遍历 = 先检查最上层的视图,符合用户期望——触摸时先响应最上面的视图。如果从前往后遍历,底层视图可能会"抢走"本该由上层视图接收的事件。
追问 2:手势识别器和 touch 事件的竞争?
UIWindow 将事件同时传递给 hitView 和相关的 UIGestureRecognizer。
-
hitView 开始收到
touchesBegan:等事件 -
手势识别器同时在分析手势
-
如果手势识别成功(
.recognized/.began):
- 调用手势的 target-action
- 调用 hitView 的 touchesCancelled:(cancelsTouchesInView 默认 YES)
- hitView 不再收到后续 touch 事件
- 如果手势识别失败(
.failed):
- hitView 正常接收所有 touch 事件
delaysTouchesBegan = YES 时,手势识别器在识别期间会延迟 hitView 的 touchesBegan:,识别失败后才补发。
题目 14:HTTPS 原理
标准答案
HTTPS = HTTP + TLS。核心是 TLS 握手建立安全通道:
-
Client Hello:客户端发送 TLS 版本、支持的加密套件列表、随机数 A
-
Server Hello:服务端选定加密套件,发送随机数 B + 服务器证书(含公钥)
-
证书验证:客户端验证证书链(服务器证书 → 中间 CA → 根 CA),检查域名、有效期、吊销状态
-
密钥交换:客户端生成随机数 C(Pre-Master Secret),用服务器公钥加密发送
-
生成会话密钥:双方用 A + B + C 通过 PRF 函数生成相同的对称加密密钥
-
加密通信:后续所有数据使用对称加密(如 AES-256-GCM)
**为什么用混合加密? ** 非对称加密(RSA/ECDHE)安全但慢(比对称慢 100-1000 倍),只用于安全传输密钥。对称加密(AES)快,用于实际数据传输。
追问 1:TLS 1.2 vs 1.3 的区别?
| 特性 | TLS 1.2 | TLS 1.3 |
|------|---------|---------|
| 握手次数 | 2-RTT | 1-RTT(甚至 0-RTT 恢复) |
| 密钥交换 | RSA / DHE / ECDHE | 仅 ECDHE / X25519(移除 RSA 密钥交换) |
| 加密套件 | 大量可选(含不安全的) | 精简到 5 个(都是 AEAD) |
| 前向保密 | 可选 | 强制(移除了非 PFS 的算法) |
| 0-RTT | 不支持 | 支持(Session Resumption) |
**前向保密(PFS) **:即使服务器的长期私钥泄漏,过去的会话也无法被解密(因为每次会话的 ECDHE 密钥是临时的)。
追问 2:证书固定(SSL Pinning)的两种方式?
-
**证书固定(Certificate Pinning) **:App 内置服务器证书的完整副本,TLS 握手时比较服务器返回的证书是否与内置证书完全一致。最严格,但证书更新时需要发版。
-
**公钥固定(Public Key Pinning) **:App 内置服务器证书的公钥(或公钥的 hash),只比较公钥是否一致。证书可以更新(只要公钥不变),更灵活。
AFNetworking 对应:AFSSLPinningModeCertificate(方式1)和 AFSSLPinningModePublicKey(方式2)。
题目 15:Swift 内存管理
标准答案
Swift 同样使用 ARC,但有一些区别:
-
strong(默认):强引用,引用计数 +1
-
weak:弱引用,可选类型,对象释放后自动变 nil
-
unowned:无主引用,非可选类型,对象释放后访问会 crash
闭包循环引用:使用捕获列表 [weak self] 或 [unowned self]
Swift 引用计数存储:对象头部有 16 字节(8 字节 metadata pointer + 8 字节 refCounts),refCounts 中用位域存储 Strong RC 和 Unowned RC。
追问 1:weak 和 unowned 的底层区别?
**weak 的底层: **
-
当对象第一次被 weak 引用时,Runtime 创建
HeapObjectSideTableEntry -
对象的 refCounts 字段从 inline 模式(直接存储 RC)切换为 side table 模式(存储指向 Side Table Entry 的指针)
-
weak 引用实际指向 Side Table Entry,而非对象本身
-
对象 deinit 后,Side Table Entry 仍然存在(因为 weak RC > 0),但标记对象已 deinit
-
weak 引用访问时检查标记 → 返回 nil
-
最后一个 weak 引用消失 → Side Table Entry 释放 → 对象内存释放
**unowned 的底层: **
-
不需要 Side Table(如果没有 weak 引用)
-
直接操作 inline 的 Unowned RC 字段(原子操作)
-
对象 deinit 后:Strong RC = 0,但 Unowned RC > 0 → 对象内存不释放(保持分配状态)
-
访问 unowned 引用时检查 Strong RC → 为 0 →
fatalError("Attempted to read an unowned reference but object was already deallocated") -
最后一个 unowned 引用消失 → Unowned RC = 0 → 对象内存真正释放
**性能差异: ** weak 需要创建 Side Table + 维护额外的 weak RC + 每次访问检查并拆包 Optional → 比 unowned 慢 2-3 倍。
追问 2:Side Table Entry 的作用?
HeapObjectSideTableEntry 是 Swift Runtime 为有 weak 引用的对象创建的辅助数据结构,包含:
-
指向原对象的指针
-
Strong Reference Count
-
Unowned Reference Count
-
Weak Reference Count
-
对象状态标志(是否已 deinit)
关键设计:weak 引用指向 Side Table Entry 而非对象本身。这样对象 deinit 后释放主存储(如果 Unowned RC 也为 0),但 Side Table Entry 仍存活,weak 引用可以安全地检查状态并返回 nil,而不是访问已释放的内存。只有当所有 weak 引用都消失后(Weak RC = 0),Side Table Entry 才被释放。
本文档深入到源码级和汇编级,覆盖 iOS 面试中 99% 以上的深度知识点。每道题的答案按照「先回答核心原理 → 再深入底层细节 → 最后回答追问」的结构,适合面试时由浅入深地展开。理解"为什么这样设计"比记住"怎么实现"更重要。