当前位置:首页>iOSAPP>iOS客户端开发面试大全

iOS客户端开发面试大全

  • 2026-10-11 05:27:14
iOS客户端开发面试大全

iOS客户端开发面试大全

适用范围:初级、中级、高级iOS客户端开发岗位版本基线:Swift 6并发模型、UIKit、SwiftUI、Objective-C混编工程使用方法:先独立回答,再对照“标准答案”,最后完成“学习任务”。不要直接背结论。

目录

  1. 1. 面试能力评分卡
  2. 2. Swift语言核心
  3. 3. Objective-C与Runtime
  4. 4. ARC与内存管理
  5. 5. 并发与线程安全
  6. 6. UIKit与系统运行机制
  7. 7. SwiftUI与Observation
  8. 8. 网络、缓存与安全
  9. 9. 数据存储与迁移
  10. 10. 架构、模块化与工程化
  11. 11. 性能、稳定性与诊断
  12. 12. 算法与现场编码
  13. 13. 项目深挖与系统设计题
  14. 14. 8周学习路线
  15. 15. 面试评价标准与自测表
  16. 16. 官方学习资料

1.面试能力评分卡

1.1岗位使命

在业务持续迭代的前提下,交付稳定、流畅、可维护、可观测的iOS客户端,并能对复杂问题完成定位、设计、实现、验证和复盘。

1.2入职12个月的典型成果

  1. 1. 能独立负责一个中等复杂度业务模块,从需求评审推进到灰度和线上复盘。
  2. 2. 能定位并修复崩溃、卡顿、内存增长、网络异常等线上问题,形成可复用的诊断方法。
  3. 3. 能在不破坏既有业务的前提下改善模块边界、测试能力和迭代效率。
  4. 4. 能使用数据说明优化结果,例如崩溃率、启动P95、卡顿率、包体积或构建耗时。
  5. 5. 能在方案评审中明确风险、边界、替代方案和回滚策略。

1.3评分维度

维度
权重
初级合格
中级合格
高级合格
语言与Runtime
20%
会正确使用
理解常见机制
能解释边界、成本和兼容性
UI与系统机制
15%
能完成页面
能处理复杂状态和性能
能制定通用治理方案
并发与内存
15%
避免常见错误
能定位竞态和泄漏
能设计隔离模型并推动迁移
架构与工程化
15%
遵守已有架构
能拆分模块、补测试
能控制跨团队依赖和演进成本
网络与数据
10%
完成请求和存储
处理缓存、重试、迁移
设计高可靠数据链路
性能与稳定性
15%
会使用基础工具
能建立证据链
能建设指标、归因和防回归机制
项目贡献与沟通
10%
说清参与内容
说清个人决策和结果
能处理模糊需求与复杂权衡

1.4通用答题框架

任何原理题都按以下顺序回答:

  1. 1. 一句话结论:先正面回答,不绕圈。
  2. 2. 核心机制:解释系统内部怎样工作。
  3. 3. 适用边界:什么条件下成立,什么条件下不成立。
  4. 4. 工程实践:项目中如何选择、验证、监控。
  5. 5. 风险与替代:错误使用的后果以及替代方案。

项目题使用STAR-E结构:Situation背景、Task目标、Action个人行动、Result量化结果、Evidence证据。面试官真正要区分的是“参与者”和“关键决策者”。

2.Swift语言核心

Q1:struct和class有什么区别?应该如何选择?

标准答案

struct是值类型,赋值、传参和返回时表现为值语义;class是引用类型,多个变量可以指向同一个实例。类支持继承、引用身份判断、deinit和弱引用;结构体不支持类继承,更适合表达独立值。选择时先看领域语义:如果对象应当被当作一个不可共享的值,优先结构体;如果需要稳定身份、共享可变状态、对象生命周期或框架要求,使用类。

机制拆解

  1. 1. 值语义意味着修改副本不应影响原值,但不等于每次赋值都立即复制全部内存。
  2. 2. 标准库中的Array、Dictionary、String通常使用写时复制,共享底层缓冲区,修改前再检查是否唯一持有。
  3. 3. 引用语义意味着变量保存的是对实例的引用,多个调用方可能观察到同一次修改。
  4. 4. “结构体在栈、类在堆”不是可靠答案。编译器会根据逃逸分析和优化决定实际存储位置,语义与物理存储是两个问题。
  5. 5. 类的动态派发、引用计数和共享状态通常带来更多管理成本,但不能脱离场景简单判断性能高低。

工程实践

网络DTO、坐标、配置快照、不可变View State适合值类型;ViewModel、缓存管理器、会话、播放器等具有身份和生命周期的对象通常适合引用类型。大型结构体若被高频复制,需要确认是否包含写时复制存储,不能凭类型名称判断成本。

常见误区

  • • 错误:结构体一定线程安全。可变结构体仍可能被不安全地跨线程共享。
  • • 错误:结构体一定更快。复制成本、泛型特化和内存布局都会影响结果。
  • • 错误:使用类只是为了让多个页面方便改同一份数据。共享可变状态会增加隐式耦合,应先明确所有权。

面试追问

  1. 1. 一个结构体中包含类属性,复制结构体后类属性是否也会深拷贝?不会,类引用仍指向同一实例。
  2. 2. let修饰类实例和结构体分别限制什么?结构体的存储属性不能再变;类变量不能重新指向其他实例,但实例的可变属性仍可能修改。
  3. 3. 如何自己实现写时复制?使用引用存储承载数据,修改前通过isKnownUniquelyReferenced检查唯一性,不唯一时复制存储。

学习任务

实现一个带写时复制的Buffer值类型,验证复制后第一次修改才产生独立存储;再用单元测试证明原值不受影响。

Q2:什么是写时复制?为什么需要它?

标准答案

写时复制把“值语义”和“避免无意义深拷贝”结合起来。多个值在只读阶段共享底层引用存储,某个值准备修改时检查存储是否唯一持有;若不是唯一持有,则复制后再修改。

关键机制

final class Storage<Element> {    var elements: [Element]    init(_ elements: [Element]) { self.elements = elements }}struct ValueBuffer<Element> {    private var storage: Storage<Element>    init(_ elements: [Element]) {        storage = Storage(elements)    }    mutating func append(_ element: Element) {        if !isKnownUniquelyReferenced(&storage) {            storage = Storage(storage.elements)        }        storage.elements.append(element)    }}

isKnownUniquelyReferenced只对Swift类引用提供唯一性判断。多线程同时访问同一变量时,不能依赖它建立线程安全;唯一引用检查与写入之间仍可能发生竞态。

学习重点

  1. 1. 值语义是对外行为契约,写时复制是内部优化策略。
  2. 2. 只读共享减少复制,第一次写入承担复制成本。
  3. 3. 嵌套引用属性可能破坏预期的深层值语义,需要明确复制边界。
  4. 4. 性能分析应观察真实数据量、复制频率和生命周期,而不是只看一次微基准。

Q3:weak和unowned有什么区别?

标准答案

二者都不增加目标对象的强引用计数。weak引用在对象释放后自动变成nil,因此通常是可选类型;unowned假设目标对象在每次访问时仍然存在,不自动置空,访问已经释放的对象会触发运行时错误。选择依据是生命周期关系,而不是为了少写可选解包。

选择规则

  • • 被引用对象可能先释放:使用weak。
  • • 被引用对象确定与当前对象同寿命或更长:可以使用unowned。
  • • 无法严格证明生命周期:使用weak更稳妥。
  • • unowned(unsafe)取消安全检查,几乎不应在普通业务代码中使用。

典型场景

Delegate通常声明为weak,因为代理对象可能先于被代理对象释放。父对象强持有子对象、子对象的整个有效期都依附于父对象时,子到父可以考虑unowned,但工程中仍需评估异步回调和对象拆卸顺序。

面试陷阱

[weak self]不是闭包的固定模板。如果闭包执行期间必须保证self存活,弱引用可能让核心逻辑静默跳过;如果闭包只短暂执行且不被self或其下游长期持有,强捕获也未必形成环。正确做法是先画持有关系,再决定捕获策略。

Q4:闭包为什么会产生循环引用?如何系统排查?

标准答案

逃逸闭包会持有所捕获的引用。如果对象强持有闭包,闭包又强捕获对象,就形成强引用环。解除方法可以是弱捕获、在适当时机清空闭包、改变所有权或把闭包需要的数据按值捕获。

排查步骤

  1. 1. 找到本应释放但仍存活的对象。
  2. 2. 使用Memory Graph查看到该对象的强引用路径。
  3. 3. 确认对象是否持有Block、Task、Timer、观察者或订阅对象。
  4. 4. 检查闭包捕获的是self、成员对象还是局部引用。
  5. 5. 修复后重复完整进入和退出路径,确认deinit和内存曲线。

常见复杂场景

  • • self持有网络请求对象,请求对象持有completion,completion捕获self。
  • • self持有Task属性,Task闭包捕获self,且任务长期等待异步序列。
  • • Combine订阅存入self.cancellables,订阅闭包捕获self。
  • • Timer由RunLoop持有,Timer持有target或闭包,对象又持有Timer。

更好的设计

让长期任务拥有明确的start/stop生命周期;在deinit之外也主动取消,因为循环引用发生时deinit不会到来。一次性回调完成后应释放闭包。对于并发代码,优先让任务只捕获完成工作所需的不可变值。

Q5:some Protocol和any Protocol有什么区别?

标准答案

some P表示调用方看不到具体类型,但实现方在每条返回路径上提供同一个确定类型;any P是存在类型容器,可以在运行时容纳不同的符合类型。前者保留更多静态类型信息和优化机会,后者提供异构性和运行时灵活性。

深入理解

  1. 1. some View不是“返回任意View”,而是返回一个由编译器确定、对调用方隐藏的具体复合类型。
  2. 2. any P可能需要存在类型容器和动态派发,但现代编译器可能做去虚化,不能机械地说一定慢。
  3. 3. 含有关联类型或Self要求的协议在作为存在类型使用时,部分成员可能无法直接调用,因为具体类型关系被擦除。
  4. 4. 泛型func f<T: P>(_ value: T)保留调用处的具体T,适合算法和静态约束;func f(_ value: any P)适合边界层和异构集合。

学习任务

分别使用泛型、some和any实现一个数据提供者,记录每种方案能否放入异构数组、能否返回不同实现、调用方能否看到具体类型。

Q6:可选值的本质是什么?如何避免滥用强制解包?

标准答案

Optional<Wrapped>是一个枚举,包含.some(Wrapped)和.none。可选值把“存在或不存在”编码进类型系统。处理方式应根据业务语义选择:if let用于局部分支,guard let用于前置条件,默认值用于缺失可被合理替代的场景,抛错或Result用于调用方必须知道失败原因的场景。

常见错误

  • • 用!掩盖初始化和数据契约不清晰的问题。
  • • 连续可选链导致失败原因全部丢失。
  • • 把业务错误都压成nil,调用方无法区分网络失败、解析失败和数据不存在。
  • • 为了避免可选,把尚未初始化的属性声明为隐式解包可选。

工程建议

在模块边界尽早校验并构造有效领域模型,使模块内部少处理非法状态。只有由框架生命周期保证、且错误应在开发期立即暴露的连接点,才谨慎使用隐式解包可选。

Q7:throws、Result和可选值怎样选择?

标准答案

  • • 可选值:只关心“有或没有”,失败原因不重要。
  • • throws:同步或异步调用链中的失败传播,调用方通过do-catch处理。
  • • Result:需要把成功或失败作为值保存、传递、组合,常见于回调和状态模型。

判断依据

错误是否需要保留类型和上下文;是否跨越异步回调或存储边界;调用方是否必须处理;错误是否可恢复。不要把编程错误包装成普通业务错误后继续运行,例如数组越界、违反内部不变量通常应在开发阶段尽快暴露。

Q8:属性包装器是什么?它解决了什么问题?

标准答案

属性包装器把属性的存储与访问策略抽取为可复用类型。声明@Wrapper var value后,编译器会生成包装存储,并通过wrappedValue提供属性语义,通过projectedValue提供$value投影。

适用场景

持久化映射、依赖读取、输入校验、线程隔离适配和SwiftUI状态连接。属性包装器适合复用明确的属性级策略,不适合隐藏复杂I/O、异步副作用或难以观察的全局状态。

面试追问

  1. 1. 包装器初始化顺序如何影响宿主类型初始化?
  2. 2. 为什么不能在包装器初始化阶段随意访问宿主self?
  3. 3. @State只是普通属性包装器吗?语法层面是包装器,但其生命周期和存储由SwiftUI运行时协同管理,不能只看包装器源码理解全部行为。

Q9:Swift方法派发有哪些形式?

标准答案

常见形式包括静态派发、虚函数表派发、协议见证表派发和Objective-C消息派发。编译器可能通过内联、特化和去虚化改变最终机器码,因此源码层面的派发模型不等于最终一定发生一次间接调用。

关键点

  • • 值类型和可确定目标的方法常有静态派发机会。
  • • 类的可重写方法通常通过虚表表达动态多态。
  • • 泛型约束下的协议调用可通过见证表找到具体实现。
  • • @objc dynamic要求Objective-C动态消息派发,常用于KVO等依赖Runtime的能力。
  • • 协议扩展中未声明为协议要求的方法,基于存在类型调用时可能采用静态选择,这是高频陷阱。

Q10:Swift 6并发检查带来了什么变化?

标准答案

Swift 6语言模式把完整数据竞争安全检查纳入编译期约束,围绕Actor隔离、全局Actor、Sendable和跨隔离传递发现潜在数据竞争。它不是自动让所有代码并行,而是让并发边界更明确、更可验证。

迁移思路

  1. 1. 先在Swift 5语言模式打开完整并发检查,以警告形式盘点问题。
  2. 2. 按模块处理,先明确UI主Actor、共享可变状态所有者和跨模块回调边界。
  3. 3. 让不可变值满足Sendable,把共享可变状态收敛进Actor或受控同步原语。
  4. 4. Objective-C和旧SDK边界可能缺乏并发标注,应使用适配层隔离风险,谨慎使用@unchecked Sendable和@preconcurrency。
  5. 5. 用压力测试、Thread Sanitizer和线上指标补充编译器检查,编译通过不代表业务时序正确。

关键误区

  • • Sendable不是“对象可以被多个线程同时随意修改”。
  • • Actor保证隔离访问,不保证业务操作天然原子;await可能发生重入。
  • • @MainActor也不表示每个同步调用都必然产生一次主队列派发,应从隔离上下文理解。

3.Objective-C与Runtime

Q11:Objective-C消息发送过程是什么?

标准答案

[receiver method]在概念上转为消息发送。运行时根据接收者的类查找方法实现:先查方法缓存,再沿类的方法列表和父类链查找;找到后调用对应IMP。找不到时进入动态方法解析和消息转发流程。给nil发送普通对象返回值消息通常不会崩溃,但不能把它当作错误处理机制。

深入理解

  1. 1. Selector描述方法名称,IMP是实现函数地址,两者不是同一概念。
  2. 2. 方法缓存减少重复查找成本;方法交换后需要理解Runtime如何处理缓存,不能假设所有历史调用点不受影响。
  3. 3. objc_msgSend的真实调用依赖架构ABI,不能像普通C函数一样随意声明和调用不同签名。
  4. 4. 类方法从元类的方法表查找,实例方法从类对象的方法表查找。
  5. 5. 消息派发的动态性支持Category、KVO、转发和动态替换,也带来运行时才暴露错误的风险。

面试追问

为什么Objective-C可以调用编译期未直接绑定的实现?因为调用目标通过接收者和Selector在运行时解析,而不是所有调用都静态绑定到固定函数地址。

Q12:isa、类对象和元类是什么关系?

标准答案

实例对象保存实例数据,并通过isa关联其类对象;类对象保存实例方法、属性和协议等元数据;类对象本身也是对象,其isa关联元类;元类保存类方法。继承关系通过superclass链表达。

回答边界

现代Runtime中的isa可能是非指针isa,除类指针信息外还编码引用计数等状态。面试中应讲清概念关系,不应把某一系统版本的位布局当成永久ABI承诺。

Q13:Category和Extension有什么区别?

标准答案

Category可在不修改原类源码的情况下增加方法、协议声明和通过关联对象模拟的属性存储,不能直接增加实例变量。类扩展通常在编译期参与类定义,可以声明私有方法、属性和实例变量,要求编译器能看到原类实现。

风险

Category同名方法的冲突缺乏可靠覆盖顺序保证,容易污染全局行为。给系统类添加通用名称的方法风险更高,应使用业务前缀。关联对象有额外查找和所有权策略,不能完全等同于原生实例变量。

Q14:+load和+initialize有什么区别?

标准答案

+load在镜像加载阶段由Runtime调用,调用时机早,类和Category都可能执行,不依赖首次消息;+initialize通常在类首次接收消息前按需调用,并与继承关系有关。两者都不适合承载重I/O和复杂业务初始化。

工程实践

启动优化应重点排查大量+load、静态初始化和过早注册。能延迟的工作延迟到真正需要时;必须只执行一次的业务初始化应使用明确入口和线程安全的一次性机制,而不是依赖隐式Runtime时机。

Q15:KVC的查找过程和风险是什么?

标准答案

KVC通过字符串Key访问对象属性。设置值时会按约定查找setter或相关实例变量;读取时会查找访问方法或实例变量。未找到Key会进入未定义Key处理;给非对象标量设置nil会进入相应错误处理。

风险

字符串缺少编译期安全,重命名和类型错误可能延迟到运行时;绕过普通访问器可能破坏不变量;外部不可信Key可能访问不应暴露的数据。业务代码应优先使用类型安全访问,KVC只在框架机制、兼容层或明确动态需求中使用。

Q16:经典KVO的原理是什么?

标准答案

经典自动KVO通常在运行时为被观察对象创建动态子类,把对象的isa切换到该子类,并重写被观察属性的setter,在赋值前后触发变更通知。观察者注册和移除必须与对象生命周期匹配。

深入追问

  1. 1. 直接修改实例变量是否一定触发自动KVO?通常不会,因为绕过了自动生成的setter路径。
  2. 2. 为什么调试时对象的class看起来仍是原类?动态子类可能重写class隐藏实现细节。
  3. 3. 自动观察与手动willChange/didChange怎样协调?重复通知或不配对会产生错误行为。

Q17:消息转发的完整流程是什么?

标准答案

常见回答顺序:

  1. 1. 动态方法解析:resolveInstanceMethod:或resolveClassMethod:,有机会动态添加实现。
  2. 2. 快速转发:forwardingTargetForSelector:,把消息交给另一个对象。
  3. 3. 完整转发:提供methodSignatureForSelector:,再在forwardInvocation:中修改或转发调用。
  4. 4. 仍无法处理则进入无法识别Selector错误。

应用与边界

可用于代理聚合、兼容旧接口和消息路由,但不应拿它掩盖类型设计问题。动态转发降低静态可读性,错误往往到运行时才暴露,高频路径也有额外成本。

Q18:Method Swizzling为什么危险?

标准答案

方法交换在全局范围改变类的方法映射,风险包括执行顺序依赖、与其他库冲突、系统实现变化、重复交换、继承链污染和难以测试回滚。它适合极少数无法通过公开扩展点完成的横切能力,且必须限定范围、保证幂等、验证原实现存在并保留调用链。

优先替代

组合、子类化、Delegate、通知、依赖注入、显式拦截器和编译期代码生成通常更容易理解和验证。回答“埋点都用Swizzling”不算高级,必须说明为什么无法使用更显式的入口。

4.ARC与内存管理

Q19:ARC是怎样工作的?它是不是垃圾回收?

标准答案

ARC不是追踪式垃圾回收。编译器根据所有权语义在合适位置插入retain、release等操作,对象没有强引用后释放。ARC能管理引用计数操作,但不能自动打破强引用环,也不能替开发者管理非对象资源、缓存策略和无限增长的数据集合。

所有权语义

  • • strong拥有对象并延长其生命周期。
  • • weak不拥有对象,对象释放后自动置nil。
  • • unowned不拥有对象,但要求访问时对象仍存活。
  • • Objective-C中的copy常用于Block和可变对象边界,保证保存预期副本语义。
  • • Core Foundation与Objective-C桥接时要明确所有权转移,例如takeRetainedValue和takeUnretainedValue不能混用。

Q20:内存泄漏、内存峰值和内存持续增长有什么区别?

标准答案

内存泄漏是对象或分配已不再需要,却因错误持有或未释放而永久不可回收;内存峰值是某个阶段短时间需要大量内存,之后能够下降;持续增长可能来自泄漏,也可能来自无上限缓存、历史数据累计、图片解码、数据库对象未重置或内存映射。

诊断证据链

  1. 1. 固定复现路径和初始状态,记录每轮操作后的内存。
  2. 2. 重复进入退出页面,判断对象数量和内存是否回落。
  3. 3. Memory Graph检查对象强引用路径。
  4. 4. Allocations观察分配类别、存活代数和调用栈。
  5. 5. VM Tracker区分堆、映射文件、图片或其他虚拟内存区域。
  6. 6. 修复后用相同路径和设备复测,比较峰值与稳定值。

Q21:为什么图片会造成很高的内存占用?

标准答案

磁盘上的JPEG/PNG是压缩数据,显示前通常需要解码为像素缓冲区。近似内存可以按宽×高×每像素字节数估算,一张尺寸很大的图片即使文件只有几百KB,解码后也可能占几十MB。缩小UIImageView的Frame不等于减少原始解码尺寸。

优化方法

  1. 1. 按目标显示尺寸和屏幕Scale降采样,不先完整解码再缩放。
  2. 2. 后台解码,主线程只提交已准备好的显示结果。
  3. 3. 内存缓存按成本而不是只按数量限制。
  4. 4. 列表复用时取消不可见请求并校验资源身份。
  5. 5. 收到内存压力时释放可重建缓存。
  6. 6. 避免同时保留原始Data、完整UIImage和多个中间位图。

Q22:Timer、通知和异步任务怎样造成生命周期问题?

标准答案

关键是分析谁持有谁以及结束条件。RunLoop会持有已注册Timer,Timer可能持有target或闭包;通知中心的Block观察者需要由返回Token管理;长期Task可能通过闭包捕获对象并等待永不结束的流。只写[weak self]不能替代取消和注销。

正确治理

为所有长期资源定义所有者和明确终止时机:页面消失是否停止、对象释放前是否取消、业务切换是否重建。将Timer、观察Token、Task、订阅等统一纳入生命周期容器,增加重复进入退出测试。

Q23:如何定位OOM或Jetsam?

标准答案

iOS可能因进程内存压力终止应用,通常不会留下普通崩溃调用栈。应结合系统终止信息、设备型号、页面路径、内存趋势、图片与大对象分配、后台状态和版本变化判断。重点关注峰值,而不仅是最终稳定内存。

分析顺序

  1. 1. 确认是否是内存终止,而不是Watchdog或普通Crash。
  2. 2. 按设备内存等级和系统版本聚类。
  3. 3. 查找进入特定页面、加载资源或切后台前后的峰值。
  4. 4. 在真机复现,模拟器不会以相同方式执行Jetsam策略。
  5. 5. 使用Allocations、VM Tracker、Memory Graph和线上指标交叉验证。

5.并发与线程安全

Q24:同步、异步、串行、并发有什么区别?

标准答案

同步和异步描述提交者是否等待任务完成;串行和并发描述队列允许任务以什么关系开始执行。异步提交到串行队列仍按顺序执行,同步提交到并发队列也不意味着调用者获得并行收益。

判断方式

回答任何GCD题都先明确三件事:当前在哪个执行上下文、提交到哪个队列、提交者是否等待。不要只背“同步+主队列死锁”,因为同一串行队列同步提交都会产生类似等待环。

Q25:主队列同步提交为什么会死锁?

标准答案

当主线程正在执行任务A,A同步提交任务B到主队列时,A必须等待B完成;主队列又必须等A结束后才能开始B,形成循环等待。死锁本质不是“主队列特殊”,而是当前串行执行器上的任务同步等待同一执行器后续任务。

延伸

两个串行队列相互同步提交也可能死锁;锁顺序不一致也会形成循环等待;semaphore.wait()等待一个只能在当前线程或队列执行的signal同样会死锁。

Q26:GCD中的Barrier、Group和Semaphore分别适合什么?

标准答案

  • • Barrier:在自建并发队列上协调“并发读、独占写”。全局队列不能被业务代码独占,不能依赖barrier实现预期隔离。
  • • Group:跟踪一组异步任务的完成,适合汇总结果和统一通知。
  • • Semaphore:计数同步原语,可限制并发量或等待资源,但阻塞线程,滥用容易死锁和优先级反转。

高级回答

现代Swift异步代码优先使用结构化并发表达任务关系,避免为了把异步接口改成同步接口而阻塞线程。GCD仍适合底层队列隔离、旧接口兼容和明确的执行上下文控制。

Q27:锁有哪些类型?如何选择?

标准答案

选择取决于临界区长度、竞争强度、是否允许递归、读写比例、公平性和是否处于异步上下文。互斥锁适合普通短临界区;递归锁只在确实存在同线程重入时使用;读写锁在读多写少且临界区有一定成本时才可能获益;自旋会消耗CPU,只适合极短等待且平台实现有明确保证的场景。

常见风险

  1. 1. 锁内执行网络、磁盘或用户回调,导致临界区不可控。
  2. 2. 多把锁获取顺序不一致造成死锁。
  3. 3. 高优先级线程等待低优先级线程造成优先级反转。
  4. 4. 在持锁状态下await,把跨悬挂周期的不变量交给外部代码。
  5. 5. 用“线程安全容器”误以为一系列复合操作也自动原子。

Q28:async let、Task和TaskGroup如何选择?

标准答案

  • • async let:当前作用域内数量固定、结构已知的并行子任务。
  • • TaskGroup:任务数量动态,需逐个收集结果或动态添加任务。
  • • Task {}:创建继承当前优先级和Actor上下文的非结构化任务,应保存和管理生命周期。
  • • Task.detached:不继承当前Actor上下文和任务局部值,适用范围更窄,不能当作通用后台线程API。

核心原则

优先结构化并发,因为父子任务的生命周期、取消和错误传播更清晰。创建非结构化任务时必须回答谁持有、何时取消、错误交给谁、页面退出后是否继续。

Q29:Actor怎样保证隔离?为什么仍可能出现业务竞态?

标准答案

Actor把其隔离状态的访问串行化到对应执行器,并通过编译器限制跨隔离访问。但Actor方法在await处可以悬挂,Actor此时可能执行其他任务;恢复后之前读取的状态可能已经变化,这称为Actor可重入带来的业务时序问题。

示例

actor TokenStore {    private var token: String?    func validToken() async throws -> String {        if let token { return token }        let fetched = try await fetchToken()        token = fetched        return fetched    }}

多个调用者可能都在token为空时进入,分别开始刷新。Actor保护了属性访问,却没有自动合并跨await的业务操作。更稳妥的设计是保存一个进行中的刷新Task,其他调用者等待同一个Task,结束后清理。

Q30:Sendable表示什么?@unchecked Sendable有什么风险?

标准答案

Sendable表达一个值可以安全跨并发隔离域传递。不可变值类型通常容易满足;包含共享可变引用的类型需要内部同步或重新设计。@unchecked Sendable表示开发者绕过编译器证明并自行承担线程安全责任,不是消除警告的快捷方式。

审查清单

  1. 1. 类型是否含可变引用属性?
  2. 2. 可变状态是否由锁、Actor或专用队列完整保护?
  3. 3. 是否把非线程安全SDK对象暴露到隔离域外?
  4. 4. 复合操作是否仍有竞态?
  5. 5. 是否有压力测试和Thread Sanitizer验证?

Q31:Swift Task取消是怎样工作的?

标准答案

Task取消通常是协作式的。取消会设置状态,部分挂起点可能抛出取消错误,但任务代码仍需通过Task.checkCancellation()或Task.isCancelled响应,停止后续工作并释放资源。取消父任务会按结构化关系传播给子任务,非结构化任务需要单独管理。

工程实践

图片加载和搜索请求要在单元格复用、查询变化或页面退出时取消;CPU密集循环要定期检查取消;取消后的错误不应统一展示成业务失败;资源清理使用明确的作用域和defer。

Q32:如何设计线程安全缓存?

标准答案

先定义一致性语义,再选同步机制。至少明确读取、写入、淘汰、过期、成本统计和“缺失后加载”是否原子。仅用并发队列保护字典的单次读写,不代表“检查不存在→请求→写入”整体不会重复。

推荐设计

  1. 1. 用Actor或锁保护元数据和缓存容器。
  2. 2. 为进行中的同Key请求保存Task,实现请求合并。
  3. 3. 把网络I/O放在隔离状态之外执行,避免长期占用锁。
  4. 4. 恢复后重新验证状态,处理取消和失败清理。
  5. 5. 内存缓存设置总成本和驱逐策略,磁盘缓存采用原子写入和版本化格式。
  6. 6. 暴露命中率、容量、淘汰次数和加载耗时,才能验证收益。

6.UIKit与系统运行机制

Q33:UIViewController生命周期怎样理解?

标准答案

生命周期不是一张只需背诵的顺序表,而是“视图加载、进入窗口、出现、消失、释放”几个阶段。常见节点包括初始化、loadView、viewDidLoad、viewWillAppear、布局、viewDidAppear、viewWillDisappear、viewDidDisappear和释放。不同容器、转场取消、App前后台切换会影响调用,不能假设每次出现都完整经历相同路径。

各阶段职责

  • • loadView:创建根View。纯代码页面可重写,但不要调用super后又无意义覆盖。
  • • viewDidLoad:视图已加载,适合一次性的视图绑定和静态配置,不代表页面已显示。
  • • viewWillAppear:适合每次显示前刷新与外观同步,避免重复创建昂贵资源。
  • • viewDidAppear:页面已完成出现,适合依赖可见性的动作和曝光,但要处理重复触发。
  • • viewWillDisappear/viewDidDisappear:停止仅在可见期需要的工作,但交互式转场取消时要恢复状态。
  • • deinit:确认对象释放,不应把必须执行的业务保存只放在这里。

高频追问

从A push到B再pop,哪些方法调用?正常情况下A消失、B出现,pop时反向发生;但回答时应说明导航转场、容器嵌套和交互取消可能改变细节。高级回答会把业务生命周期从ViewController回调中抽象出来,避免重复刷新和错误埋点。

Q34:Auto Layout一次布局过程是什么?

标准答案

约束变化后,系统先更新约束,再求解布局并设置Frame,随后进入显示阶段。updateConstraints用于批量更新约束,layoutSubviews用于最终布局调整,draw(_:)用于自定义绘制。应用通常只需修改约束并标记布局,不应手动频繁调用系统生命周期方法。

API区别

  • • setNeedsUpdateConstraints():标记未来需要更新约束。
  • • updateConstraintsIfNeeded():在需要时立即处理约束更新。
  • • setNeedsLayout():标记未来布局。
  • • layoutIfNeeded():在当前布局根上立即执行尚未完成的布局,动画前后常用。
  • • setNeedsDisplay():标记重绘,不等同于重新布局。

性能问题

约束冲突、层级过深、频繁增删整组约束和在布局回调里反复触发新布局会增加成本。优化前应通过工具和调用次数证明瓶颈,不能把所有卡顿归因于Auto Layout。

Q35:事件传递和hitTest如何工作?

标准答案

系统从窗口开始命中测试,检查视图是否允许交互、是否隐藏、透明度是否满足条件、触点是否在有效区域,然后按子视图层级从前到后递归查找最合适的命中视图。命中后事件沿Responder Chain传递和处理。

常见应用

扩大按钮点击区域、让覆盖视图透传、将事件转给特定子视图。重写时必须保持坐标转换正确,并避免让不可见区域抢占其他控件事件。手势识别器还涉及失败依赖、同时识别和触摸取消策略。

Q36:RunLoop是什么?它与主线程、Timer和Autorelease Pool有什么关系?

标准答案

RunLoop是线程处理输入源、定时器和观察回调的事件循环。线程本身负责执行,RunLoop让线程在无事件时休眠、有事件时被唤醒。主线程RunLoop由应用框架启动;普通线程需要按需配置并运行。

关键关系

  1. 1. Timer注册到RunLoop的特定Mode,滚动时Mode变化可能影响Timer触发。
  2. 2. Common Modes是若干Mode的集合标记,不是一个独立运行模式。
  3. 3. 主线程事件循环边界会创建和释放Autorelease Pool,后台长循环仍可能需要手动建立局部池控制峰值。
  4. 4. RunLoop Observer可观察阶段,但业务代码不应过度依赖私有实现细节。
  5. 5. 主线程卡顿的直接表现是RunLoop长时间无法返回处理下一批事件,根因可能是CPU、锁、I/O或过量布局绘制。

Q37:列表复用为什么会出现错乱?

标准答案

Cell代表可复用的显示载体,不与某条数据永久绑定。异步请求返回时,原Cell可能已经显示另一条数据;如果回调直接设置图片或状态,就会污染新内容。

正确方案

  1. 1. 配置Cell时立即重置所有可变UI状态,不能依赖上次状态刚好一致。
  2. 2. 为当前模型保存稳定Identity或资源Key。
  3. 3. 在复用时取消旧任务,至少让旧回调失效。
  4. 4. 回调更新前再次比较Identity。
  5. 5. 图片加载器做内存/磁盘缓存、请求合并和降采样。
  6. 6. UI更新在主Actor,解码和I/O不占主线程。

追问

为什么仅比较indexPath不可靠?插入、删除、排序后IndexPath可能变化,数据Identity才是更稳定的判断依据。

Q38:离屏渲染是什么?是不是一定要消除?

标准答案

离屏渲染指某些合成效果需要先在额外缓冲区完成中间结果,再合成到最终目标,可能增加GPU内存和切换成本。圆角、阴影、遮罩、光栅化等是否触发以及成本大小与系统版本、图层组合和硬件有关。它不是绝对错误,应以真实帧时间和工具结果判断。

优化思路

减少不必要的透明叠加和复杂遮罩;为阴影提供明确路径;静态复杂内容可评估光栅化,但缩放变化和缓存失效可能适得其反;大图优先降采样。最终目标是稳定帧预算,不是机械追求“零离屏”。

7.SwiftUI与Observation

Q39:SwiftUI的body为什么会重复执行?这等于全部重绘吗?

标准答案

body是当前状态到视图描述的函数。依赖状态变化时,SwiftUI重新计算相关视图描述,并基于Identity和结构比较更新实际渲染树。body重新执行不等于所有像素都重绘,也不等于所有子视图生命周期都重建。

设计原则

  1. 1. body应保持轻量、确定,避免网络请求、写数据库和不可控副作用。
  2. 2. 昂贵派生数据放入模型或缓存,并确保失效规则正确。
  3. 3. 把状态放在最小合理拥有者,减少无关视图建立依赖。
  4. 4. 视图拆分应基于状态依赖和职责,不是单纯减少文件行数。
  5. 5. 使用Instruments验证更新范围,不凭print次数直接判断渲染性能。

Q40:@State、@Binding、@StateObject和@ObservedObject如何选择?

标准答案

  • • @State:由当前View拥有并由SwiftUI管理生命周期的本地状态。现代Observation模型也可由@State持有。
  • • @Binding:不拥有数据,只提供对上游状态的读写通道。
  • • @StateObject:当前View创建并拥有一个ObservableObject引用实例,避免View值重建时对象被重新创建。
  • • @ObservedObject:观察外部传入的ObservableObject,生命周期由外部管理。
  • • @Environment或@EnvironmentObject:从环境获取跨层共享依赖,应避免把所有业务对象都变成隐式全局依赖。

核心判断

先问“谁是Source of Truth、谁负责创建、谁负责销毁、谁只读取或编辑”,再选包装器。仅按“值类型用State、类用StateObject”回答已经不完整,尤其在Observation模型下。

Q41:View Identity为什么重要?

标准答案

Identity帮助SwiftUI判断前后两次描述中的视图是否代表同一个逻辑实体。身份稳定时可以延续状态和增量更新;身份意外变化会导致本地状态重置、动画中断、任务重启或列表跳动。

常见错误

  • • 每次计算都生成新UUID作为列表ID。
  • • 使用可能重复或变化的展示文本作为ID。
  • • 用条件分支切换完全不同的结构,却期望内部状态延续。
  • • 为了强制刷新滥用.id(UUID()),把状态建模问题变成全量重建。

Q42:Observation与ObservableObject有什么区别?

标准答案

Observation通过@Observable宏为模型生成观察能力。SwiftUI在body执行期间记录实际读取的可观察属性,属性变化后更新建立了对应依赖的视图。ObservableObject通常通过objectWillChange表达对象级变化,@Published是常见发布方式。Observation能更细粒度地基于属性访问建立依赖。

实践重点

Observation从iOS 17等系统版本开始可用于SwiftUI模型,需要考虑部署版本和混编策略。模型应保持明确的隔离要求;UI模型通常标记主Actor,后台工作返回后再更新UI状态。迁移不能只机械替换包装器,还要重新确认Source of Truth、环境注入和绑定。

Q43:SwiftUI和UIKit如何混合?

标准答案

SwiftUI页面可通过UIHostingController嵌入UIKit;UIKit视图和控制器可分别通过UIViewRepresentable和UIViewControllerRepresentable包装到SwiftUI。关键不在包装协议语法,而在生命周期、状态同步、尺寸协商、Delegate桥接和资源释放。

常见风险

  1. 1. updateUIView中重复添加子视图或重复注册观察。
  2. 2. Coordinator强持有宿主造成循环引用。
  3. 3. UIKit回调写状态后触发再次更新,形成反馈循环。
  4. 4. 未实现正确尺寸策略导致Auto Layout和SwiftUI布局互相冲突。
  5. 5. 页面退出后UIKit对象的Timer、播放器或观察者仍运行。

8.网络、缓存与安全

Q44:一个完整网络请求链路包含什么?

标准答案

从业务调用到结果展示通常包含请求构造、认证、编码、DNS与连接、TLS、发送、服务端处理、响应接收、HTTP状态判断、解码、业务错误映射、缓存、状态更新和埋点。高级回答会明确每层职责,避免把Transport错误、HTTP错误、业务错误和解析错误混为一种。

推荐分层

  1. 1. Endpoint描述路径、方法、参数和响应类型。
  2. 2. RequestBuilder负责URLRequest构造和公共Header。
  3. 3. Transport封装URLSession,只处理传输。
  4. 4. Authenticator负责凭证附加和刷新协调。
  5. 5. Decoder与ErrorMapper负责类型转换。
  6. 6. Repository协调远端、本地缓存和领域模型。
  7. 7. UI层只消费可展示状态,不解析原始字典。

Q45:HTTP缓存怎样工作?

标准答案

缓存策略由响应头、请求策略和缓存实现共同决定。Cache-Control: max-age定义新鲜时间;no-cache通常表示使用前需要重新验证,不等于完全不存;no-store才是禁止存储;过期后可用ETag/If-None-Match或Last-Modified/If-Modified-Since进行条件请求,服务端返回304表示可复用缓存实体。

iOS实践

URLSessionConfiguration可配置URLCache和请求缓存策略。默认会遵循协议缓存策略,但业务层手写缓存时必须明确它与HTTP缓存的关系,避免双层缓存过期规则冲突。后台Session默认缓存行为与普通Session不同,需要查看具体配置。

Q46:请求失败可以直接重试吗?

标准答案

不能。重试前要判断请求是否幂等、失败阶段、错误类型、服务端限流和用户是否仍需要结果。GET通常更容易安全重试;创建订单、支付等非幂等操作必须使用服务端幂等键或查询最终状态,不能因超时就盲目再发一次。

重试策略

  1. 1. 只重试瞬时错误,例如部分网络中断和特定5xx。
  2. 2. 使用指数退避和随机抖动,避免大量客户端同时重试。
  3. 3. 尊重服务端Retry-After。
  4. 4. 设置总次数、总时间和取消条件。
  5. 5. 前后台切换和网络恢复后重新评估,而不是无限续命。
  6. 6. 记录重试原因、次数、最终结果和额外延迟。

Q47:多个请求同时遇到Token过期,如何只刷新一次?

标准答案

核心是Single Flight:认证协调器维护当前有效Token和一个可共享的刷新Task。第一个过期请求创建刷新Task,后续请求等待同一个Task;刷新成功后重放原请求,失败则统一进入登出或重新认证流程。状态需要被Actor或锁完整保护。

必须处理的边界

  • • 重放次数限制,避免刷新接口自身过期造成死循环。
  • • 原请求取消后不应继续无意义重放。
  • • 刷新失败只触发一次全局登录失效处理。
  • • 新Token写Keychain与内存状态的顺序要一致。
  • • 旧响应晚到不能覆盖新Token。
  • • 非幂等请求重放前要有服务端幂等保证。

Q48:HTTPS提供什么安全能力?证书固定是否总是必要?

标准答案

TLS在正确验证证书和主机名的前提下提供传输加密、完整性和服务端身份验证。它不能自动防止业务越权、客户端本地泄密或服务端自身被攻破。证书或公钥固定可以降低某些错误信任或中间人风险,但会增加证书轮换、灾备和运维复杂度。

安全实践

使用系统信任评估,不实现“接受所有证书”;敏感凭证存Keychain;日志、崩溃上报和埋点脱敏;鉴权和权限判断必须由服务端最终执行;本地混淆不能替代真正的密钥管理;固定策略必须支持多把Key、轮换和远程应急。

Q49:大文件上传下载如何设计?

标准答案

下载要考虑后台Session、临时文件、断点信息、磁盘空间、校验、原子移动和失败恢复;上传要考虑分片、重试、幂等、进度、后台执行和服务端合并。不能把整个文件一次性读入内存。

验证点

弱网切换、App被系统终止、磁盘不足、服务端Range不支持、文件已变更、用户取消、重复完成回调、校验失败。进度展示还要避免高频回调压垮主线程。

9.数据存储与迁移

Q50:UserDefaults、Keychain、文件、SQLite和Core Data如何选择?

标准答案

  • • UserDefaults:少量用户偏好和简单配置,不存大对象和敏感凭证。
  • • Keychain:密码、Token、加密密钥等小型敏感数据。
  • • 文件:图片、音视频、文档和可独立读写的大块数据。
  • • SQLite:需要明确关系查询、事务和细粒度控制的结构化数据。
  • • Core Data:对象图、变更跟踪、关系、查询、上下文和持久化管理,不只是SQLite封装。

选择维度

数据敏感性、规模、查询方式、事务需求、同步方式、生命周期、迁移成本和并发模型。不要因为“方便”把所有数据塞入UserDefaults或一个JSON文件。

Q51:SQLite事务为什么能提高批量写入性能?

标准答案

每次独立写入都可能承担日志和持久化同步成本。把多条写入放在一个事务中,可以批量提交并减少昂贵的同步次数,同时保证原子性:全部成功或全部回滚。

进一步知识

索引能加速读取但增加写入成本和空间;查询是否使用索引要看执行计划;长事务会增加锁竞争和日志体积;WAL改善读写并发但仍需理解检查点和文件增长。性能优化应基于真实查询、数据量和执行计划。

Q52:Core Data的核心组件是什么?

标准答案

  • • Managed Object Model:实体、属性、关系和约束。
  • • Managed Object Context:对象变化的工作区、身份映射和保存边界。
  • • Persistent Store Coordinator/Container:协调模型、上下文和持久化Store。
  • • Persistent Store:SQLite、内存等具体存储。

并发原则

Managed Object和Context有队列约束,不能把一个Context管理的对象随意跨队列传递。跨上下文通常传NSManagedObjectID,在目标Context重新获取。批量导入使用后台Context,合并到视图Context时处理冲突策略和UI更新。

Q53:数据迁移如何设计?

标准答案

简单的新增可选字段、重命名等可能通过轻量迁移推断;复杂结构变化需要分阶段或自定义迁移。迁移是上线风险,不应只验证空数据库,需要准备来自多个历史版本和真实规模的数据副本。

发布清单

  1. 1. 迁移前校验磁盘空间、旧版本和数据完整性。
  2. 2. 大迁移估算时间,避免启动阶段被Watchdog终止。
  3. 3. 保留备份或可恢复策略,写入临时Store后原子切换。
  4. 4. 记录迁移阶段、耗时和失败原因,不记录敏感业务内容。
  5. 5. 覆盖中途中断、磁盘不足、旧版本回退和重复启动。
  6. 6. 灰度观察成功率和耗时后扩大范围。

Q54:内存缓存和磁盘缓存怎样协同?

标准答案

内存缓存低延迟但易受内存压力影响,磁盘缓存容量大但有I/O和序列化成本。读取通常先查内存,再查磁盘,最后请求远端;写入需要定义同步或异步、原子性和失败策略。两层必须共享Key、版本、过期和一致性规则。

完整设计项

Key规范、最大容量、单项大小、TTL、LRU或其他淘汰、成本统计、并发访问、请求合并、文件名安全、原子写、校验、加密、版本升级、清理时机、命中率和降级策略。

10.架构、模块化与工程化

Q55:MVC为什么容易出现Massive View Controller?

标准答案

问题不在MVC三个字,而在团队把网络、解析、业务规则、导航、状态、埋点和视图配置都放进Controller。Controller天然处在多个系统交界处,如果没有明确协作者和依赖方向,就会持续吸收职责。

改进方式

按变化原因拆分:View负责展示和事件,Use Case/Service负责业务动作,Repository负责数据来源,Coordinator负责导航,Presenter/ViewModel负责展示状态转换。拆分的目标是降低耦合和测试成本,不是把一个大类机械拆成十个无语义小类。

Q56:MVVM解决什么问题,又带来什么问题?

标准答案

MVVM把展示状态和用户意图处理从View中分离,便于测试状态转换和复用业务交互。它可能带来双向绑定难追踪、ViewModel膨胀、导航归属不清、异步状态组合复杂等问题。

健康边界

ViewModel接收输入事件,调用明确依赖,输出有限状态;不直接创建全局依赖,不保存UIKit视图,不成为所有业务Service的容器。复杂业务规则应下沉到Use Case或领域层,导航交给Coordinator或Router。

Q57:怎样划分模块边界?

标准答案

模块边界应围绕稳定业务能力、团队所有权和变化方向划分,而不是简单按View、Model、Utils横切。好的模块具有高内聚、窄接口、单向依赖和可独立测试构建的特征。

评估问题

  1. 1. 这个模块拥有什么业务能力和数据?
  2. 2. 对外最小接口是什么?
  3. 3. 谁可以依赖它,它又依赖谁?
  4. 4. 是否泄漏第三方框架或内部模型?
  5. 5. 是否能替换实现而不修改调用方?
  6. 6. 一次业务变化需要改多少模块?
  7. 7. 是否出现公共模块反向依赖业务模块?

Q58:如何处理循环依赖?

标准答案

先画依赖图,判断循环是业务概念纠缠、接口放错层、共享模型泄漏还是为了方便互调。解决方法包括提取更稳定的协议到下层、通过依赖注入倒置方向、把跨模块流程交给上层协调器、使用事件发布解耦或重新合并本就不可分的模块。

风险提醒

事件总线能打断编译依赖,但会隐藏调用链和数据契约;协议不是越多越好,若协议只为绕过工具限制而存在,会增加跳转成本。应以依赖方向和业务所有权为准。

Q59:依赖注入有什么价值?

标准答案

依赖注入让对象从外部获得协作者,显式表达依赖,便于替换、测试和控制生命周期。构造器注入适合必需依赖,属性或方法注入适合可选或阶段性依赖。Service Locator虽然调用方便,但会重新隐藏依赖。

典型收益

网络层可以替换为Stub;时钟可以固定;UUID和随机源可以确定;缓存和数据库可以使用隔离实例;页面测试不必启动完整App环境。依赖注入的目标是可控边界,不是引入庞大容器框架。

Q60:单例有哪些问题?是否完全不能使用?

标准答案

单例适合进程内确实唯一、无业务可变状态或生命周期与进程一致的基础设施入口。问题在于全局可访问、隐藏依赖、共享可变状态、测试污染和初始化顺序。它不是绝对禁止,但调用方最好依赖协议或显式注入,测试可替换实现。

Q61:CocoaPods、Swift Package Manager和二进制组件如何选择?

标准答案

SPM与Swift工具链和Xcode集成紧密,适合源码包和跨平台Swift模块;CocoaPods生态成熟、对复杂Objective-C工程和自定义集成能力强;二进制组件可保护源码和降低部分编译时间,但增加架构切片、符号、调试、兼容和发布管理成本。

选择维度

现有工程、语言构成、依赖生态、构建性能、资源处理、脚本能力、版本治理、调试体验和团队迁移成本。大型项目不应为了“现代化”一次性迁移全部依赖,应先量化收益并按低风险模块试点。

Q62:如何设计可测试代码?

标准答案

可测试性来自确定性和边界清晰:业务逻辑不依赖真实时间、随机数、网络和全局状态;副作用通过协议隔离;异步完成有可等待的信号;输入输出可构造;错误和取消路径可观察。

测试分层

  • • 单元测试:纯逻辑、状态转换、错误映射、边界条件。
  • • 集成测试:网络适配、数据库、序列化和模块协作。
  • • UI测试:关键用户路径和系统集成,不承担所有组合测试。
  • • 性能测试:建立基线和允许波动范围,关注趋势而非单次数字。
  • • 契约测试:验证客户端模型与服务端Schema或Mock契约一致。

Q63:如何做灰度发布和快速回滚?

标准答案

移动端版本无法像服务端一样立即全部回滚,因此要把风险控制前置。使用Feature Flag隔离新功能,服务端保持兼容,按用户或版本灰度,配置有默认值、签名/校验、过期策略和紧急关闭能力。

完整闭环

上线前定义成功指标和停止条件;灰度期间看崩溃、卡顿、接口错误、业务转化和设备分布;异常时关闭开关或服务端降级;事后复盘并补自动化防回归。开关本身也要清理,长期遗留会制造组合爆炸。

11.性能、稳定性与诊断

Q64:如何分析App启动时间?

标准答案

先定义指标。系统常用到首帧时间衡量启动,但用户可交互时间可能更晚。分析时区分进程启动前后的系统工作、动态加载和初始化、应用入口、首屏数据准备、布局绘制和首个可交互状态。

优化流程

  1. 1. 使用真机和Release配置建立冷启动、温启动基线。
  2. 2. 用Instruments和系统指标定位主线程长任务。
  3. 3. 排查大量动态库、+load、静态初始化和同步I/O。
  4. 4. 首屏只做必要工作,非关键初始化延后并分批。
  5. 5. 简化初始视图层级和昂贵绘制。
  6. 6. 用P50/P95/P99、设备和系统版本分层验证,防止均值掩盖长尾。

Q65:列表卡顿怎样建立证据链?

标准答案

先稳定复现并记录帧时间,再确认瓶颈在主线程、CPU、GPU、I/O还是锁等待。Time Profiler看调用栈和耗时,Core Animation或相关工具看帧与合成,Allocations看滚动期间分配,网络和图片链路看解码与缓存。

高频根因

主线程图片解码、重复布局、同步磁盘读取、富文本计算、过度对象创建、阴影遮罩、主线程锁等待、Cell配置触发网络和重复订阅。优化后必须用相同数据、设备、滚动路径复测,而不是凭手感。

Q66:Crash、Watchdog、OOM和卡死怎样区分?

标准答案

  • • Crash:非法内存、未捕获异常、断言等导致进程异常退出,通常有崩溃报告。
  • • Watchdog:启动、切换状态或主线程长时间无响应,被系统终止。
  • • OOM/Jetsam:内存压力下被系统杀死,未必有普通崩溃栈。
  • • 卡死/死锁:进程仍存在但无法推进,可能最终触发Watchdog,也可能用户主动结束。

分析原则

不能只看用户描述“闪退”。需要终止原因、异常类型、线程栈、内存和前后台状态。符号化必须匹配对应构建的dSYM和UUID,不能拿当前代码猜历史地址。

Q67:如何分析线上崩溃?

标准答案

  1. 1. 验证符号化和版本信息。
  2. 2. 按异常类型、堆栈签名、版本、设备和系统聚类。
  3. 3. 确认第一条业务相关栈,而不是只看最顶层系统函数。
  4. 4. 结合Breadcrumb、页面、网络和线程状态重建路径,但日志必须脱敏。
  5. 5. 在对应Commit和构建配置复现。
  6. 6. 修根因并补防御、测试、监控和灰度策略。

常见误区

只加nil判断掩盖越界或状态机错误;只修当前堆栈不查相同模式;把无法复现当作无法修复;使用错误dSYM得到误导栈。

Q68:性能指标为什么要看P95/P99?

标准答案

平均值会被大量正常样本稀释,无法代表低端设备、弱网和极端数据下的体验。P95表示95%的样本不超过该值,P99观察更长尾。还要同时看样本量、分布、版本、设备和置信波动。

实践

为启动、页面加载、接口、卡顿和内存定义口径;比较同条件版本;发布前设回归阈值;线上用MetricKit、Organizer或自建指标观察真实设备。局部微优化只有能改善用户指标才有优先级。

Q69:如何使用Instruments,而不是只会说工具名?

标准答案

  • • Time Profiler:CPU采样、热点调用栈、主线程长任务。
  • • Allocations:对象分配、存活、代际和调用栈。
  • • Leaks/Memory Graph:泄漏线索和引用关系。
  • • VM Tracker:虚拟内存区域和Footprint组成。
  • • Core Animation及响应性工具:帧、卡顿和渲染行为。
  • • Network:连接和请求行为。

标准操作

先提出假设,选择能验证假设的工具;固定复现步骤;设置时间范围和线程过滤;从高占比调用栈进入源码;做最小修改;相同条件复测。一次Trace只能说明该次样本,不应直接推广到全部线上用户。

12.算法与现场编码

12.1面试官真正评估什么

现场编码不是只看最终代码是否运行,还会评估:

  1. 1. 是否先澄清输入规模、合法性、重复值和输出要求。
  2. 2. 是否能从朴素解法推导到更优解法。
  3. 3. 是否正确说明时间和空间复杂度。
  4. 4. 是否覆盖空输入、单元素、极值、重复和失败路径。
  5. 5. 命名、函数边界和可测试性是否清楚。
  6. 6. 并发题是否明确原子性、顺序、取消和生命周期。
  7. 7. 写错后能否通过例子快速定位,而不是盲目重写。

Q70:如何设计LRU缓存?

标准答案

LRU淘汰最久未使用的数据。要让查询、插入、更新和淘汰接近O(1),通常使用哈希表加双向链表:哈希表通过Key定位节点,链表维护最近使用顺序;访问或更新后把节点移动到头部,超容量时删除尾节点并同步移除哈希表记录。

设计细节

  • • 容量为0如何处理。
  • • 更新已有Key时是否改变成本。
  • • 容量按条目还是内存成本计算。
  • • 节点的前后引用如何避免错误生命周期。
  • • 删除头尾和唯一节点时如何维护指针。
  • • 是否线程安全,锁保护的是完整复合操作而非单次字典访问。
  • • 淘汰回调不能在持锁状态执行未知外部逻辑。

测试用例

空缓存读取;插入到容量;访问旧元素后再淘汰;覆盖已有Key;容量为1;连续删除;并发读写;高成本对象触发多项淘汰。

Q71:Debounce和Throttle有什么区别?

标准答案

Debounce在事件停止一段时间后执行最后一次,适合搜索输入;Throttle限制固定时间窗口内最多执行一次,适合滚动、拖动和高频埋点。Throttle还需明确执行窗口首个、末个还是两者。

实现难点

需要一个明确时钟、可取消的待执行任务、线程安全状态和生命周期所有者。新输入到来时Debounce取消旧任务;页面退出时取消所有待执行任务;测试应使用可控虚拟时钟,避免真实等待导致不稳定。

Q72:如何设计图片加载器?

标准答案

完整图片加载器至少包含URL规范化、内存缓存、磁盘缓存、请求合并、网络下载、数据校验、目标尺寸降采样、解码、取消、优先级、列表Identity校验和指标。

请求流程

  1. 1. 根据URL、目标像素尺寸、处理参数生成Cache Key。
  2. 2. 查内存缓存,命中则立即返回。
  3. 3. 查磁盘缓存,后台读取并解码,成功后回填内存。
  4. 4. 若远端请求已在进行,当前调用者订阅同一任务。
  5. 5. 下载后校验响应并按目标尺寸降采样。
  6. 6. 原子写入磁盘、按成本写入内存,再分发结果。
  7. 7. 单个调用者取消不一定取消共享底层请求,只有无人订阅时才评估取消。

面试加分点

处理HTTP缓存与自建缓存关系;缓存Key包含处理版本;限制并发解码;磁盘清理不阻塞主线程;记录命中率、下载耗时、解码耗时、失败类型和内存成本。

Q73:如何实现线程安全的请求去重?

标准答案

用一个受Actor或锁保护的[Key: Task<Value, Error>]保存进行中任务。调用时先查是否已有任务,有则等待;没有则创建并登记。任务完成或失败后,仅在字典中仍是同一任务时清理,防止旧任务晚到误删新任务。

边界

调用者取消的语义、底层任务是否共享、错误是否短暂缓存、超时是否由每个调用者独立控制、认证刷新是否也使用相同机制。重点不是写出字典,而是定义共享任务的所有权。

12.2常见算法清单

类型
必练题
核心知识
数组与字符串
两数之和、最长无重复子串、合并区间
哈希、双指针、滑动窗口
链表
反转、找环、合并有序链表
指针变化、快慢指针
栈与队列
有效括号、最小栈、滑动窗口最大值
单调结构、状态维护
树
前中后序、层序、最近公共祖先
递归边界、显式栈
排序与查找
二分边界、Top K、归并
循环不变量、堆
动态规划
爬楼梯、背包、最长递增子序列
状态定义、转移、空间压缩
iOS实战
LRU、图片加载器、并发限制器
数据结构、生命周期、线程安全

13.项目深挖与系统设计题

场景1:首页偶现白屏,无法稳定复现,怎样排查?

高质量答案

  1. 1. 先定义白屏:窗口未建立、根控制器为空、页面无数据、渲染卡住还是透明遮罩覆盖。
  2. 2. 按版本、设备、系统、冷暖启动、登录态、网络和实验组聚类。
  3. 3. 在启动和首页状态机加入阶段性、可脱敏的埋点:窗口创建、根页面设置、首帧、配置完成、首屏数据、错误和超时。
  4. 4. 收集主线程卡顿、Watchdog、接口和配置结果,建立统一Trace ID关联一次启动。
  5. 5. 根据证据构造故障注入:弱网、配置超时、Token失效、数据库迁移失败、主线程阻塞、资源缺失。
  6. 6. 修复根因后增加超时降级、默认页面和状态机测试,并按灰度指标验证。

Red Flags

直接在延迟一秒后重新设置RootVC;只加一次刷新;没有区分“未创建”和“创建但不可见”;依赖用户截图猜测。

Green Signals

先定义现象和状态;能设计最小证据;知道如何关联启动阶段;有降级与防回归策略。

场景2:图片列表卡顿且内存上涨,怎样定位?

高质量答案

固定图片集和滚动路径,分别记录帧、主线程、CPU、GPU和内存。Time Profiler确认是否主线程解码或布局;Allocations看UIImage、Data和位图增长;Memory Graph排查Cell、任务和控制器泄漏;检查缓存是否按原图尺寸与数量无限增长。修复可能包括目标尺寸降采样、后台解码、限制缓存成本、取消不可见请求、请求合并和减少布局计算。最后使用同设备同数据复测峰值、稳定内存和卡顿率。

场景3:旧网络响应覆盖新搜索结果,怎样解决?

高质量答案

这不是简单的“切主线程”问题,而是结果时序问题。每次查询生成递增版本或稳定Request ID,状态层只接受当前查询对应结果;新查询取消旧任务,但仍保留Identity校验,因为取消是协作式且旧响应可能已在返回途中。Debounce减少请求,状态机区分idle/loading/success/empty/error,并确保错误也不能覆盖更新查询的加载状态。

场景4:线上崩溃率突然升高,前30分钟做什么?

高质量答案

先确认数据口径和发布相关性,按版本、堆栈、设备、系统和用户路径聚类;验证dSYM和符号化;判断是否集中在新版本、新开关或服务端变化。如果有Feature Flag先关闭高风险路径,暂停扩大灰度;若是服务端兼容问题先降级响应。保留证据后在对应Commit复现,同时评估影响用户和回滚能力。沟通中持续给出事实、风险和下一检查点,不发布无证据结论。

场景5:大型Objective-C工程如何迁移Swift 6并发检查?

高质量答案

先建立模块清单和边界,不做全仓一次性切换。以警告模式扫描,优先确定UI主Actor、网络回调、数据库队列、缓存共享状态和Objective-C边界。选择低依赖模块试点,把数据模型改为可安全传递的不可变值,用Actor收敛共享状态,通过适配层包装未标注SDK。每阶段记录警告数量、构建变化、运行回归和线上指标。@unchecked Sendable建立审计清单和删除期限,不能作为清零工具。

场景6:模块化后出现循环依赖,怎样处理?

高质量答案

输出真实依赖图并标注调用原因。若A和B共同依赖稳定数据契约,把契约下沉到独立基础模块;若A需要触发B流程,由更上层Coordinator组合;若只是回调,用由调用方定义的窄协议倒置;若业务生命周期本就不可分,重新合并可能比制造大量协议合理。方案要比较编译依赖、运行时可观测性、测试和团队所有权。

场景7:产品要求一周重写核心页面,怎样决策?

高质量答案

先把目标从“重写”翻译为可验证结果,例如崩溃、开发效率、交互变化或性能。盘点现有问题、测试覆盖、依赖、灰度和回滚能力,比较原地改造、绞杀式替换和完整重写。核心页面通常优先建立行为基线与Feature Flag,按子流程替换。若一周不足以控制数据迁移和兼容风险,要给出可交付的缩小范围和明确风险,而不是只说做不到。

场景8:如何证明一次性能优化真的有效?

高质量答案

优化前定义指标、设备、数据、构建配置和复现路径;采集多次基线和分布;使用工具定位主导因素;只修改与假设相关部分;同条件复测,并观察CPU、内存或能耗是否出现回归;灰度看线上P50/P95/P99和样本量。最终结论包括改善幅度、适用范围、代价和防回归测试。

14.8周学习路线

第1周:Swift值语义与类型系统

  • • 学习:值/引用语义、写时复制、Optional、错误处理、泛型、协议、some/any。
  • • 实践:实现COW Buffer、泛型数据源和类型擦除容器。
  • • 输出:独立回答Q1-Q9,每题录音3分钟,不看资料复述。
  • • 验收:能讲机制、边界和项目选择,不使用“就是更快”等空结论。

第2周:Objective-C与Runtime

  • • 学习:对象模型、消息发送、Category、KVC/KVO、转发、Block、Autorelease Pool。
  • • 实践:用Runtime打印类和元类关系;实现安全的消息转发演示。
  • • 输出:画出实例、类、元类、父类和方法表关系图。
  • • 验收:能解释动态能力的代价,并给出不用Swizzling的替代方案。

第3周:ARC、内存与并发基础

  • • 学习:所有权、引用环、图片内存、GCD、锁、死锁。
  • • 实践:构造并修复Block、Timer和Task三种循环引用;用Memory Graph验证。
  • • 输出:形成一份“对象不释放”排查清单。
  • • 验收:先画持有图再给方案,能区分泄漏、缓存和峰值。

第4周:Swift Concurrency

  • • 学习:结构化并发、TaskGroup、取消、Actor、重入、Sendable、主Actor。
  • • 实践:实现请求合并的Token刷新器和线程安全缓存。
  • • 输出:对一个旧回调API设计async适配层。
  • • 验收:能解释生命周期、错误传播和取消,不把Actor等同于锁的语法糖。

第5周:UIKit、SwiftUI与渲染

  • • 学习:生命周期、RunLoop、布局、事件、复用、SwiftUI状态与Identity。
  • • 实践:做一个图片列表,加入降采样、取消、缓存和性能测量。
  • • 输出:对同一页面分别设计UIKit与SwiftUI状态流。
  • • 验收:能区分body重算、布局和真正绘制。

第6周:网络、存储与安全

  • • 学习:HTTP缓存、重试、幂等、Token刷新、TLS、Keychain、SQLite、Core Data迁移。
  • • 实践:设计支持缓存、取消、重试和错误映射的网络层接口。
  • • 输出:画出一次请求从View到远端和缓存再回UI的时序图。
  • • 验收:能处理弱网、重复请求、旧响应和迁移失败。

第7周:架构、工程化与测试

  • • 学习:MVC/MVVM、依赖注入、模块边界、路由、包管理、CI、灰度。
  • • 实践:从一个Massive View Controller提取状态、Use Case和Repository,仅做设计演练。
  • • 输出:模块依赖图、接口表和测试矩阵。
  • • 验收:每个抽象都能说明解决了什么真实成本。

第8周:性能、稳定性和模拟面试

  • • 学习:启动、卡顿、OOM、Crash、Watchdog、指标分位数、Instruments。
  • • 实践:完成3次60分钟模拟面试和2次工具诊断。
  • • 输出:准备3个项目STAR-E案例:复杂问题、性能优化、架构权衡。
  • • 验收:项目回答包含量化指标、个人行动、失败尝试和证据。

每日学习节奏

  1. 1. 20分钟无资料回忆昨日知识。
  2. 2. 40分钟阅读官方资料和机制文章。
  3. 3. 60分钟代码或工具实验。
  4. 4. 20分钟口述答案并记录卡顿点。
  5. 5. 10分钟建立错题卡:错误结论、正确机制、验证方式。

15.面试评价标准与自测表

15.1Green Signals

  • • 先澄清问题和边界,再给方案。
  • • 能从源码语义、系统机制讲到工程实践。
  • • 项目贡献使用“我决定、我实现、我验证”,并能区分团队成果。
  • • 给出真实指标、工具、Trace、测试或设计文档作为证据。
  • • 主动说明失败方案、权衡、风险和回滚。
  • • 面对不确定内容明确说验证方法,不编造系统实现。

15.2Red Flags

  • • 只背API名称,无法解释为什么。
  • • 所有性能问题都回答“放子线程”或“加缓存”。
  • • 所有架构问题都回答“上MVVM/组件化”,没有成本分析。
  • • 说项目提升明显,却没有指标、基线和个人行动。
  • • 使用weak self、单例、锁或Swizzling作为固定模板。
  • • 把未复现、未测量的猜测说成根因。

15.3决策矩阵

维度
No Hire
Hire
Strong Hire
语言基础
概念混乱且无法用例子验证
理解语义并正确使用
能解释编译器、Runtime边界与迁移风险
并发内存
忽略竞态、取消和生命周期
能定位常见死锁泄漏
能设计隔离模型和系统治理
架构能力
只会套名词
能按职责拆分并测试
能控制依赖、演进和团队协作成本
性能稳定性
凭感觉优化
会使用工具建立证据
能建设线上指标、归因和防回归
项目真实性
只描述团队结果
说清个人行动和结果
能复盘失败、权衡和可复制方法
沟通判断
回避不确定性
结论清晰且边界明确
能在信息不全时建立验证计划

15.4自测等级

  • • L1记忆:能说出定义。
  • • L2理解:能解释机制和反例。
  • • L3应用:能写代码或用工具验证。
  • • L4分析:能定位真实问题并比较方案。
  • • L5设计:能建立跨模块方案、指标和演进计划。

中级岗位的核心题至少达到L3,项目题达到L4;高级岗位的并发、架构、性能和稳定性题应达到L4-L5。

15.5模拟面试安排

第一轮基础面:Swift、Runtime、ARC、UIKit,45分钟;第二轮专项面:并发、网络、存储、性能,60分钟;第三轮项目面:选择两个真实项目连续追问,60分钟;第四轮系统设计:图片加载、聊天消息或Feed流,60分钟;第五轮综合面:架构权衡、事故处理、协作和复盘,45分钟。

16.官方学习资料

Swift与并发

  • • The Swift Programming Language
  • • Automatic Reference Counting
  • • Swift 6 Concurrency Migration Guide
  • • Enable Data-Race Safety Checking
  • • Apple Actor Documentation

UI与状态

  • • SwiftUI Documentation
  • • Managing Model Data in Your App
  • • Observation

网络、存储与安全

  • • URL Loading System
  • • Accessing Cached Data
  • • Keychain Services
  • • Core Data
  • • Migrating Your Data Model Automatically

性能与稳定性

  • • Profiling Apps Using Instruments
  • • Gathering Information About Memory Use
  • • Reducing Your App's Launch Time
  • • MetricKit

结语

面试准备的目标不是把每道题背成唯一答案,而是建立稳定的问题解决链路:定义问题、解释机制、识别边界、提出方案、验证结果、复盘风险。能够用真实项目证据完成这条链路,才是从“会写iOS代码”走向“能负责iOS系统”的分水岭。

最新文章

随机文章