适用范围:初级、中级、高级iOS客户端开发岗位版本基线:Swift 6并发模型、UIKit、SwiftUI、Objective-C混编工程使用方法:先独立回答,再对照“标准答案”,最后完成“学习任务”。不要直接背结论。
在业务持续迭代的前提下,交付稳定、流畅、可维护、可观测的iOS客户端,并能对复杂问题完成定位、设计、实现、验证和复盘。
任何原理题都按以下顺序回答:
项目题使用STAR-E结构:Situation背景、Task目标、Action个人行动、Result量化结果、Evidence证据。面试官真正要区分的是“参与者”和“关键决策者”。
struct
class
struct是值类型,赋值、传参和返回时表现为值语义;class是引用类型,多个变量可以指向同一个实例。类支持继承、引用身份判断、deinit和弱引用;结构体不支持类继承,更适合表达独立值。选择时先看领域语义:如果对象应当被当作一个不可共享的值,优先结构体;如果需要稳定身份、共享可变状态、对象生命周期或框架要求,使用类。
deinit
Array
Dictionary
String
网络DTO、坐标、配置快照、不可变View State适合值类型;ViewModel、缓存管理器、会话、播放器等具有身份和生命周期的对象通常适合引用类型。大型结构体若被高频复制,需要确认是否包含写时复制存储,不能凭类型名称判断成本。
let
isKnownUniquelyReferenced
实现一个带写时复制的Buffer值类型,验证复制后第一次修改才产生独立存储;再用单元测试证明原值不受影响。
Buffer
写时复制把“值语义”和“避免无意义深拷贝”结合起来。多个值在只读阶段共享底层引用存储,某个值准备修改时检查存储是否唯一持有;若不是唯一持有,则复制后再修改。
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类引用提供唯一性判断。多线程同时访问同一变量时,不能依赖它建立线程安全;唯一引用检查与写入之间仍可能发生竞态。
weak
unowned
二者都不增加目标对象的强引用计数。weak引用在对象释放后自动变成nil,因此通常是可选类型;unowned假设目标对象在每次访问时仍然存在,不自动置空,访问已经释放的对象会触发运行时错误。选择依据是生命周期关系,而不是为了少写可选解包。
nil
unowned(unsafe)
Delegate通常声明为weak,因为代理对象可能先于被代理对象释放。父对象强持有子对象、子对象的整个有效期都依附于父对象时,子到父可以考虑unowned,但工程中仍需评估异步回调和对象拆卸顺序。
[weak self]不是闭包的固定模板。如果闭包执行期间必须保证self存活,弱引用可能让核心逻辑静默跳过;如果闭包只短暂执行且不被self或其下游长期持有,强捕获也未必形成环。正确做法是先画持有关系,再决定捕获策略。
[weak self]
self
逃逸闭包会持有所捕获的引用。如果对象强持有闭包,闭包又强捕获对象,就形成强引用环。解除方法可以是弱捕获、在适当时机清空闭包、改变所有权或把闭包需要的数据按值捕获。
Task
self.cancellables
让长期任务拥有明确的start/stop生命周期;在deinit之外也主动取消,因为循环引用发生时deinit不会到来。一次性回调完成后应释放闭包。对于并发代码,优先让任务只捕获完成工作所需的不可变值。
start/stop
some Protocol
any Protocol
some P表示调用方看不到具体类型,但实现方在每条返回路径上提供同一个确定类型;any P是存在类型容器,可以在运行时容纳不同的符合类型。前者保留更多静态类型信息和优化机会,后者提供异构性和运行时灵活性。
some P
any P
some View
Self
func f<T: P>(_ value: T)
T
func f(_ value: any P)
分别使用泛型、some和any实现一个数据提供者,记录每种方案能否放入异构数组、能否返回不同实现、调用方能否看到具体类型。
some
any
Optional<Wrapped>是一个枚举,包含.some(Wrapped)和.none。可选值把“存在或不存在”编码进类型系统。处理方式应根据业务语义选择:if let用于局部分支,guard let用于前置条件,默认值用于缺失可被合理替代的场景,抛错或Result用于调用方必须知道失败原因的场景。
Optional<Wrapped>
.some(Wrapped)
.none
if let
guard let
Result
!
在模块边界尽早校验并构造有效领域模型,使模块内部少处理非法状态。只有由框架生命周期保证、且错误应在开发期立即暴露的连接点,才谨慎使用隐式解包可选。
throws
do-catch
错误是否需要保留类型和上下文;是否跨越异步回调或存储边界;调用方是否必须处理;错误是否可恢复。不要把编程错误包装成普通业务错误后继续运行,例如数组越界、违反内部不变量通常应在开发阶段尽快暴露。
属性包装器把属性的存储与访问策略抽取为可复用类型。声明@Wrapper var value后,编译器会生成包装存储,并通过wrappedValue提供属性语义,通过projectedValue提供$value投影。
@Wrapper var value
wrappedValue
projectedValue
$value
持久化映射、依赖读取、输入校验、线程隔离适配和SwiftUI状态连接。属性包装器适合复用明确的属性级策略,不适合隐藏复杂I/O、异步副作用或难以观察的全局状态。
@State
常见形式包括静态派发、虚函数表派发、协议见证表派发和Objective-C消息派发。编译器可能通过内联、特化和去虚化改变最终机器码,因此源码层面的派发模型不等于最终一定发生一次间接调用。
@objc dynamic
Swift 6语言模式把完整数据竞争安全检查纳入编译期约束,围绕Actor隔离、全局Actor、Sendable和跨隔离传递发现潜在数据竞争。它不是自动让所有代码并行,而是让并发边界更明确、更可验证。
Sendable
@unchecked Sendable
@preconcurrency
await
@MainActor
[receiver method]在概念上转为消息发送。运行时根据接收者的类查找方法实现:先查方法缓存,再沿类的方法列表和父类链查找;找到后调用对应IMP。找不到时进入动态方法解析和消息转发流程。给nil发送普通对象返回值消息通常不会崩溃,但不能把它当作错误处理机制。
[receiver method]
objc_msgSend
为什么Objective-C可以调用编译期未直接绑定的实现?因为调用目标通过接收者和Selector在运行时解析,而不是所有调用都静态绑定到固定函数地址。
isa
实例对象保存实例数据,并通过isa关联其类对象;类对象保存实例方法、属性和协议等元数据;类对象本身也是对象,其isa关联元类;元类保存类方法。继承关系通过superclass链表达。
superclass
现代Runtime中的isa可能是非指针isa,除类指针信息外还编码引用计数等状态。面试中应讲清概念关系,不应把某一系统版本的位布局当成永久ABI承诺。
Category可在不修改原类源码的情况下增加方法、协议声明和通过关联对象模拟的属性存储,不能直接增加实例变量。类扩展通常在编译期参与类定义,可以声明私有方法、属性和实例变量,要求编译器能看到原类实现。
Category同名方法的冲突缺乏可靠覆盖顺序保证,容易污染全局行为。给系统类添加通用名称的方法风险更高,应使用业务前缀。关联对象有额外查找和所有权策略,不能完全等同于原生实例变量。
+load
+initialize
+load在镜像加载阶段由Runtime调用,调用时机早,类和Category都可能执行,不依赖首次消息;+initialize通常在类首次接收消息前按需调用,并与继承关系有关。两者都不适合承载重I/O和复杂业务初始化。
启动优化应重点排查大量+load、静态初始化和过早注册。能延迟的工作延迟到真正需要时;必须只执行一次的业务初始化应使用明确入口和线程安全的一次性机制,而不是依赖隐式Runtime时机。
KVC通过字符串Key访问对象属性。设置值时会按约定查找setter或相关实例变量;读取时会查找访问方法或实例变量。未找到Key会进入未定义Key处理;给非对象标量设置nil会进入相应错误处理。
字符串缺少编译期安全,重命名和类型错误可能延迟到运行时;绕过普通访问器可能破坏不变量;外部不可信Key可能访问不应暴露的数据。业务代码应优先使用类型安全访问,KVC只在框架机制、兼容层或明确动态需求中使用。
经典自动KVO通常在运行时为被观察对象创建动态子类,把对象的isa切换到该子类,并重写被观察属性的setter,在赋值前后触发变更通知。观察者注册和移除必须与对象生命周期匹配。
willChange/didChange
常见回答顺序:
resolveInstanceMethod:
resolveClassMethod:
forwardingTargetForSelector:
methodSignatureForSelector:
forwardInvocation:
可用于代理聚合、兼容旧接口和消息路由,但不应拿它掩盖类型设计问题。动态转发降低静态可读性,错误往往到运行时才暴露,高频路径也有额外成本。
方法交换在全局范围改变类的方法映射,风险包括执行顺序依赖、与其他库冲突、系统实现变化、重复交换、继承链污染和难以测试回滚。它适合极少数无法通过公开扩展点完成的横切能力,且必须限定范围、保证幂等、验证原实现存在并保留调用链。
组合、子类化、Delegate、通知、依赖注入、显式拦截器和编译期代码生成通常更容易理解和验证。回答“埋点都用Swizzling”不算高级,必须说明为什么无法使用更显式的入口。
ARC不是追踪式垃圾回收。编译器根据所有权语义在合适位置插入retain、release等操作,对象没有强引用后释放。ARC能管理引用计数操作,但不能自动打破强引用环,也不能替开发者管理非对象资源、缓存策略和无限增长的数据集合。
strong
copy
takeRetainedValue
takeUnretainedValue
内存泄漏是对象或分配已不再需要,却因错误持有或未释放而永久不可回收;内存峰值是某个阶段短时间需要大量内存,之后能够下降;持续增长可能来自泄漏,也可能来自无上限缓存、历史数据累计、图片解码、数据库对象未重置或内存映射。
磁盘上的JPEG/PNG是压缩数据,显示前通常需要解码为像素缓冲区。近似内存可以按宽×高×每像素字节数估算,一张尺寸很大的图片即使文件只有几百KB,解码后也可能占几十MB。缩小UIImageView的Frame不等于减少原始解码尺寸。
关键是分析谁持有谁以及结束条件。RunLoop会持有已注册Timer,Timer可能持有target或闭包;通知中心的Block观察者需要由返回Token管理;长期Task可能通过闭包捕获对象并等待永不结束的流。只写[weak self]不能替代取消和注销。
为所有长期资源定义所有者和明确终止时机:页面消失是否停止、对象释放前是否取消、业务切换是否重建。将Timer、观察Token、Task、订阅等统一纳入生命周期容器,增加重复进入退出测试。
iOS可能因进程内存压力终止应用,通常不会留下普通崩溃调用栈。应结合系统终止信息、设备型号、页面路径、内存趋势、图片与大对象分配、后台状态和版本变化判断。重点关注峰值,而不仅是最终稳定内存。
同步和异步描述提交者是否等待任务完成;串行和并发描述队列允许任务以什么关系开始执行。异步提交到串行队列仍按顺序执行,同步提交到并发队列也不意味着调用者获得并行收益。
回答任何GCD题都先明确三件事:当前在哪个执行上下文、提交到哪个队列、提交者是否等待。不要只背“同步+主队列死锁”,因为同一串行队列同步提交都会产生类似等待环。
当主线程正在执行任务A,A同步提交任务B到主队列时,A必须等待B完成;主队列又必须等A结束后才能开始B,形成循环等待。死锁本质不是“主队列特殊”,而是当前串行执行器上的任务同步等待同一执行器后续任务。
两个串行队列相互同步提交也可能死锁;锁顺序不一致也会形成循环等待;semaphore.wait()等待一个只能在当前线程或队列执行的signal同样会死锁。
semaphore.wait()
现代Swift异步代码优先使用结构化并发表达任务关系,避免为了把异步接口改成同步接口而阻塞线程。GCD仍适合底层队列隔离、旧接口兼容和明确的执行上下文控制。
选择取决于临界区长度、竞争强度、是否允许递归、读写比例、公平性和是否处于异步上下文。互斥锁适合普通短临界区;递归锁只在确实存在同线程重入时使用;读写锁在读多写少且临界区有一定成本时才可能获益;自旋会消耗CPU,只适合极短等待且平台实现有明确保证的场景。
async let
Task {}
Task.detached
优先结构化并发,因为父子任务的生命周期、取消和错误传播更清晰。创建非结构化任务时必须回答谁持有、何时取消、错误交给谁、页面退出后是否继续。
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,结束后清理。
token
Sendable表达一个值可以安全跨并发隔离域传递。不可变值类型通常容易满足;包含共享可变引用的类型需要内部同步或重新设计。@unchecked Sendable表示开发者绕过编译器证明并自行承担线程安全责任,不是消除警告的快捷方式。
Task取消通常是协作式的。取消会设置状态,部分挂起点可能抛出取消错误,但任务代码仍需通过Task.checkCancellation()或Task.isCancelled响应,停止后续工作并释放资源。取消父任务会按结构化关系传播给子任务,非结构化任务需要单独管理。
Task.checkCancellation()
Task.isCancelled
图片加载和搜索请求要在单元格复用、查询变化或页面退出时取消;CPU密集循环要定期检查取消;取消后的错误不应统一展示成业务失败;资源清理使用明确的作用域和defer。
defer
先定义一致性语义,再选同步机制。至少明确读取、写入、淘汰、过期、成本统计和“缺失后加载”是否原子。仅用并发队列保护字典的单次读写,不代表“检查不存在→请求→写入”整体不会重复。
UIViewController
生命周期不是一张只需背诵的顺序表,而是“视图加载、进入窗口、出现、消失、释放”几个阶段。常见节点包括初始化、loadView、viewDidLoad、viewWillAppear、布局、viewDidAppear、viewWillDisappear、viewDidDisappear和释放。不同容器、转场取消、App前后台切换会影响调用,不能假设每次出现都完整经历相同路径。
loadView
viewDidLoad
viewWillAppear
viewDidAppear
viewWillDisappear
viewDidDisappear
super
viewWillDisappear/viewDidDisappear
从A push到B再pop,哪些方法调用?正常情况下A消失、B出现,pop时反向发生;但回答时应说明导航转场、容器嵌套和交互取消可能改变细节。高级回答会把业务生命周期从ViewController回调中抽象出来,避免重复刷新和错误埋点。
约束变化后,系统先更新约束,再求解布局并设置Frame,随后进入显示阶段。updateConstraints用于批量更新约束,layoutSubviews用于最终布局调整,draw(_:)用于自定义绘制。应用通常只需修改约束并标记布局,不应手动频繁调用系统生命周期方法。
updateConstraints
layoutSubviews
draw(_:)
setNeedsUpdateConstraints()
updateConstraintsIfNeeded()
setNeedsLayout()
layoutIfNeeded()
setNeedsDisplay()
约束冲突、层级过深、频繁增删整组约束和在布局回调里反复触发新布局会增加成本。优化前应通过工具和调用次数证明瓶颈,不能把所有卡顿归因于Auto Layout。
hitTest
系统从窗口开始命中测试,检查视图是否允许交互、是否隐藏、透明度是否满足条件、触点是否在有效区域,然后按子视图层级从前到后递归查找最合适的命中视图。命中后事件沿Responder Chain传递和处理。
扩大按钮点击区域、让覆盖视图透传、将事件转给特定子视图。重写时必须保持坐标转换正确,并避免让不可见区域抢占其他控件事件。手势识别器还涉及失败依赖、同时识别和触摸取消策略。
RunLoop是线程处理输入源、定时器和观察回调的事件循环。线程本身负责执行,RunLoop让线程在无事件时休眠、有事件时被唤醒。主线程RunLoop由应用框架启动;普通线程需要按需配置并运行。
Cell代表可复用的显示载体,不与某条数据永久绑定。异步请求返回时,原Cell可能已经显示另一条数据;如果回调直接设置图片或状态,就会污染新内容。
为什么仅比较indexPath不可靠?插入、删除、排序后IndexPath可能变化,数据Identity才是更稳定的判断依据。
indexPath
离屏渲染指某些合成效果需要先在额外缓冲区完成中间结果,再合成到最终目标,可能增加GPU内存和切换成本。圆角、阴影、遮罩、光栅化等是否触发以及成本大小与系统版本、图层组合和硬件有关。它不是绝对错误,应以真实帧时间和工具结果判断。
减少不必要的透明叠加和复杂遮罩;为阴影提供明确路径;静态复杂内容可评估光栅化,但缩放变化和缓存失效可能适得其反;大图优先降采样。最终目标是稳定帧预算,不是机械追求“零离屏”。
body
body是当前状态到视图描述的函数。依赖状态变化时,SwiftUI重新计算相关视图描述,并基于Identity和结构比较更新实际渲染树。body重新执行不等于所有像素都重绘,也不等于所有子视图生命周期都重建。
print
@Binding
@StateObject
@ObservedObject
ObservableObject
@Environment
@EnvironmentObject
先问“谁是Source of Truth、谁负责创建、谁负责销毁、谁只读取或编辑”,再选包装器。仅按“值类型用State、类用StateObject”回答已经不完整,尤其在Observation模型下。
Identity帮助SwiftUI判断前后两次描述中的视图是否代表同一个逻辑实体。身份稳定时可以延续状态和增量更新;身份意外变化会导致本地状态重置、动画中断、任务重启或列表跳动。
.id(UUID())
Observation通过@Observable宏为模型生成观察能力。SwiftUI在body执行期间记录实际读取的可观察属性,属性变化后更新建立了对应依赖的视图。ObservableObject通常通过objectWillChange表达对象级变化,@Published是常见发布方式。Observation能更细粒度地基于属性访问建立依赖。
@Observable
objectWillChange
@Published
Observation从iOS 17等系统版本开始可用于SwiftUI模型,需要考虑部署版本和混编策略。模型应保持明确的隔离要求;UI模型通常标记主Actor,后台工作返回后再更新UI状态。迁移不能只机械替换包装器,还要重新确认Source of Truth、环境注入和绑定。
SwiftUI页面可通过UIHostingController嵌入UIKit;UIKit视图和控制器可分别通过UIViewRepresentable和UIViewControllerRepresentable包装到SwiftUI。关键不在包装协议语法,而在生命周期、状态同步、尺寸协商、Delegate桥接和资源释放。
UIHostingController
UIViewRepresentable
UIViewControllerRepresentable
updateUIView
从业务调用到结果展示通常包含请求构造、认证、编码、DNS与连接、TLS、发送、服务端处理、响应接收、HTTP状态判断、解码、业务错误映射、缓存、状态更新和埋点。高级回答会明确每层职责,避免把Transport错误、HTTP错误、业务错误和解析错误混为一种。
缓存策略由响应头、请求策略和缓存实现共同决定。Cache-Control: max-age定义新鲜时间;no-cache通常表示使用前需要重新验证,不等于完全不存;no-store才是禁止存储;过期后可用ETag/If-None-Match或Last-Modified/If-Modified-Since进行条件请求,服务端返回304表示可复用缓存实体。
Cache-Control: max-age
no-cache
no-store
ETag/If-None-Match
Last-Modified/If-Modified-Since
URLSessionConfiguration可配置URLCache和请求缓存策略。默认会遵循协议缓存策略,但业务层手写缓存时必须明确它与HTTP缓存的关系,避免双层缓存过期规则冲突。后台Session默认缓存行为与普通Session不同,需要查看具体配置。
URLSessionConfiguration
URLCache
不能。重试前要判断请求是否幂等、失败阶段、错误类型、服务端限流和用户是否仍需要结果。GET通常更容易安全重试;创建订单、支付等非幂等操作必须使用服务端幂等键或查询最终状态,不能因超时就盲目再发一次。
Retry-After
核心是Single Flight:认证协调器维护当前有效Token和一个可共享的刷新Task。第一个过期请求创建刷新Task,后续请求等待同一个Task;刷新成功后重放原请求,失败则统一进入登出或重新认证流程。状态需要被Actor或锁完整保护。
TLS在正确验证证书和主机名的前提下提供传输加密、完整性和服务端身份验证。它不能自动防止业务越权、客户端本地泄密或服务端自身被攻破。证书或公钥固定可以降低某些错误信任或中间人风险,但会增加证书轮换、灾备和运维复杂度。
使用系统信任评估,不实现“接受所有证书”;敏感凭证存Keychain;日志、崩溃上报和埋点脱敏;鉴权和权限判断必须由服务端最终执行;本地混淆不能替代真正的密钥管理;固定策略必须支持多把Key、轮换和远程应急。
下载要考虑后台Session、临时文件、断点信息、磁盘空间、校验、原子移动和失败恢复;上传要考虑分片、重试、幂等、进度、后台执行和服务端合并。不能把整个文件一次性读入内存。
弱网切换、App被系统终止、磁盘不足、服务端Range不支持、文件已变更、用户取消、重复完成回调、校验失败。进度展示还要避免高频回调压垮主线程。
数据敏感性、规模、查询方式、事务需求、同步方式、生命周期、迁移成本和并发模型。不要因为“方便”把所有数据塞入UserDefaults或一个JSON文件。
每次独立写入都可能承担日志和持久化同步成本。把多条写入放在一个事务中,可以批量提交并减少昂贵的同步次数,同时保证原子性:全部成功或全部回滚。
索引能加速读取但增加写入成本和空间;查询是否使用索引要看执行计划;长事务会增加锁竞争和日志体积;WAL改善读写并发但仍需理解检查点和文件增长。性能优化应基于真实查询、数据量和执行计划。
Managed Object和Context有队列约束,不能把一个Context管理的对象随意跨队列传递。跨上下文通常传NSManagedObjectID,在目标Context重新获取。批量导入使用后台Context,合并到视图Context时处理冲突策略和UI更新。
NSManagedObjectID
简单的新增可选字段、重命名等可能通过轻量迁移推断;复杂结构变化需要分阶段或自定义迁移。迁移是上线风险,不应只验证空数据库,需要准备来自多个历史版本和真实规模的数据副本。
内存缓存低延迟但易受内存压力影响,磁盘缓存容量大但有I/O和序列化成本。读取通常先查内存,再查磁盘,最后请求远端;写入需要定义同步或异步、原子性和失败策略。两层必须共享Key、版本、过期和一致性规则。
Key规范、最大容量、单项大小、TTL、LRU或其他淘汰、成本统计、并发访问、请求合并、文件名安全、原子写、校验、加密、版本升级、清理时机、命中率和降级策略。
问题不在MVC三个字,而在团队把网络、解析、业务规则、导航、状态、埋点和视图配置都放进Controller。Controller天然处在多个系统交界处,如果没有明确协作者和依赖方向,就会持续吸收职责。
按变化原因拆分:View负责展示和事件,Use Case/Service负责业务动作,Repository负责数据来源,Coordinator负责导航,Presenter/ViewModel负责展示状态转换。拆分的目标是降低耦合和测试成本,不是把一个大类机械拆成十个无语义小类。
MVVM把展示状态和用户意图处理从View中分离,便于测试状态转换和复用业务交互。它可能带来双向绑定难追踪、ViewModel膨胀、导航归属不清、异步状态组合复杂等问题。
ViewModel接收输入事件,调用明确依赖,输出有限状态;不直接创建全局依赖,不保存UIKit视图,不成为所有业务Service的容器。复杂业务规则应下沉到Use Case或领域层,导航交给Coordinator或Router。
模块边界应围绕稳定业务能力、团队所有权和变化方向划分,而不是简单按View、Model、Utils横切。好的模块具有高内聚、窄接口、单向依赖和可独立测试构建的特征。
先画依赖图,判断循环是业务概念纠缠、接口放错层、共享模型泄漏还是为了方便互调。解决方法包括提取更稳定的协议到下层、通过依赖注入倒置方向、把跨模块流程交给上层协调器、使用事件发布解耦或重新合并本就不可分的模块。
事件总线能打断编译依赖,但会隐藏调用链和数据契约;协议不是越多越好,若协议只为绕过工具限制而存在,会增加跳转成本。应以依赖方向和业务所有权为准。
依赖注入让对象从外部获得协作者,显式表达依赖,便于替换、测试和控制生命周期。构造器注入适合必需依赖,属性或方法注入适合可选或阶段性依赖。Service Locator虽然调用方便,但会重新隐藏依赖。
网络层可以替换为Stub;时钟可以固定;UUID和随机源可以确定;缓存和数据库可以使用隔离实例;页面测试不必启动完整App环境。依赖注入的目标是可控边界,不是引入庞大容器框架。
单例适合进程内确实唯一、无业务可变状态或生命周期与进程一致的基础设施入口。问题在于全局可访问、隐藏依赖、共享可变状态、测试污染和初始化顺序。它不是绝对禁止,但调用方最好依赖协议或显式注入,测试可替换实现。
SPM与Swift工具链和Xcode集成紧密,适合源码包和跨平台Swift模块;CocoaPods生态成熟、对复杂Objective-C工程和自定义集成能力强;二进制组件可保护源码和降低部分编译时间,但增加架构切片、符号、调试、兼容和发布管理成本。
现有工程、语言构成、依赖生态、构建性能、资源处理、脚本能力、版本治理、调试体验和团队迁移成本。大型项目不应为了“现代化”一次性迁移全部依赖,应先量化收益并按低风险模块试点。
可测试性来自确定性和边界清晰:业务逻辑不依赖真实时间、随机数、网络和全局状态;副作用通过协议隔离;异步完成有可等待的信号;输入输出可构造;错误和取消路径可观察。
移动端版本无法像服务端一样立即全部回滚,因此要把风险控制前置。使用Feature Flag隔离新功能,服务端保持兼容,按用户或版本灰度,配置有默认值、签名/校验、过期策略和紧急关闭能力。
上线前定义成功指标和停止条件;灰度期间看崩溃、卡顿、接口错误、业务转化和设备分布;异常时关闭开关或服务端降级;事后复盘并补自动化防回归。开关本身也要清理,长期遗留会制造组合爆炸。
先定义指标。系统常用到首帧时间衡量启动,但用户可交互时间可能更晚。分析时区分进程启动前后的系统工作、动态加载和初始化、应用入口、首屏数据准备、布局绘制和首个可交互状态。
先稳定复现并记录帧时间,再确认瓶颈在主线程、CPU、GPU、I/O还是锁等待。Time Profiler看调用栈和耗时,Core Animation或相关工具看帧与合成,Allocations看滚动期间分配,网络和图片链路看解码与缓存。
主线程图片解码、重复布局、同步磁盘读取、富文本计算、过度对象创建、阴影遮罩、主线程锁等待、Cell配置触发网络和重复订阅。优化后必须用相同数据、设备、滚动路径复测,而不是凭手感。
不能只看用户描述“闪退”。需要终止原因、异常类型、线程栈、内存和前后台状态。符号化必须匹配对应构建的dSYM和UUID,不能拿当前代码猜历史地址。
只加nil判断掩盖越界或状态机错误;只修当前堆栈不查相同模式;把无法复现当作无法修复;使用错误dSYM得到误导栈。
平均值会被大量正常样本稀释,无法代表低端设备、弱网和极端数据下的体验。P95表示95%的样本不超过该值,P99观察更长尾。还要同时看样本量、分布、版本、设备和置信波动。
为启动、页面加载、接口、卡顿和内存定义口径;比较同条件版本;发布前设回归阈值;线上用MetricKit、Organizer或自建指标观察真实设备。局部微优化只有能改善用户指标才有优先级。
先提出假设,选择能验证假设的工具;固定复现步骤;设置时间范围和线程过滤;从高占比调用栈进入源码;做最小修改;相同条件复测。一次Trace只能说明该次样本,不应直接推广到全部线上用户。
现场编码不是只看最终代码是否运行,还会评估:
LRU淘汰最久未使用的数据。要让查询、插入、更新和淘汰接近O(1),通常使用哈希表加双向链表:哈希表通过Key定位节点,链表维护最近使用顺序;访问或更新后把节点移动到头部,超容量时删除尾节点并同步移除哈希表记录。
空缓存读取;插入到容量;访问旧元素后再淘汰;覆盖已有Key;容量为1;连续删除;并发读写;高成本对象触发多项淘汰。
Debounce在事件停止一段时间后执行最后一次,适合搜索输入;Throttle限制固定时间窗口内最多执行一次,适合滚动、拖动和高频埋点。Throttle还需明确执行窗口首个、末个还是两者。
需要一个明确时钟、可取消的待执行任务、线程安全状态和生命周期所有者。新输入到来时Debounce取消旧任务;页面退出时取消所有待执行任务;测试应使用可控虚拟时钟,避免真实等待导致不稳定。
完整图片加载器至少包含URL规范化、内存缓存、磁盘缓存、请求合并、网络下载、数据校验、目标尺寸降采样、解码、取消、优先级、列表Identity校验和指标。
处理HTTP缓存与自建缓存关系;缓存Key包含处理版本;限制并发解码;磁盘清理不阻塞主线程;记录命中率、下载耗时、解码耗时、失败类型和内存成本。
用一个受Actor或锁保护的[Key: Task<Value, Error>]保存进行中任务。调用时先查是否已有任务,有则等待;没有则创建并登记。任务完成或失败后,仅在字典中仍是同一任务时清理,防止旧任务晚到误删新任务。
[Key: Task<Value, Error>]
调用者取消的语义、底层任务是否共享、错误是否短暂缓存、超时是否由每个调用者独立控制、认证刷新是否也使用相同机制。重点不是写出字典,而是定义共享任务的所有权。
直接在延迟一秒后重新设置RootVC;只加一次刷新;没有区分“未创建”和“创建但不可见”;依赖用户截图猜测。
先定义现象和状态;能设计最小证据;知道如何关联启动阶段;有降级与防回归策略。
固定图片集和滚动路径,分别记录帧、主线程、CPU、GPU和内存。Time Profiler确认是否主线程解码或布局;Allocations看UIImage、Data和位图增长;Memory Graph排查Cell、任务和控制器泄漏;检查缓存是否按原图尺寸与数量无限增长。修复可能包括目标尺寸降采样、后台解码、限制缓存成本、取消不可见请求、请求合并和减少布局计算。最后使用同设备同数据复测峰值、稳定内存和卡顿率。
这不是简单的“切主线程”问题,而是结果时序问题。每次查询生成递增版本或稳定Request ID,状态层只接受当前查询对应结果;新查询取消旧任务,但仍保留Identity校验,因为取消是协作式且旧响应可能已在返回途中。Debounce减少请求,状态机区分idle/loading/success/empty/error,并确保错误也不能覆盖更新查询的加载状态。
先确认数据口径和发布相关性,按版本、堆栈、设备、系统和用户路径聚类;验证dSYM和符号化;判断是否集中在新版本、新开关或服务端变化。如果有Feature Flag先关闭高风险路径,暂停扩大灰度;若是服务端兼容问题先降级响应。保留证据后在对应Commit复现,同时评估影响用户和回滚能力。沟通中持续给出事实、风险和下一检查点,不发布无证据结论。
先建立模块清单和边界,不做全仓一次性切换。以警告模式扫描,优先确定UI主Actor、网络回调、数据库队列、缓存共享状态和Objective-C边界。选择低依赖模块试点,把数据模型改为可安全传递的不可变值,用Actor收敛共享状态,通过适配层包装未标注SDK。每阶段记录警告数量、构建变化、运行回归和线上指标。@unchecked Sendable建立审计清单和删除期限,不能作为清零工具。
输出真实依赖图并标注调用原因。若A和B共同依赖稳定数据契约,把契约下沉到独立基础模块;若A需要触发B流程,由更上层Coordinator组合;若只是回调,用由调用方定义的窄协议倒置;若业务生命周期本就不可分,重新合并可能比制造大量协议合理。方案要比较编译依赖、运行时可观测性、测试和团队所有权。
先把目标从“重写”翻译为可验证结果,例如崩溃、开发效率、交互变化或性能。盘点现有问题、测试覆盖、依赖、灰度和回滚能力,比较原地改造、绞杀式替换和完整重写。核心页面通常优先建立行为基线与Feature Flag,按子流程替换。若一周不足以控制数据迁移和兼容风险,要给出可交付的缩小范围和明确风险,而不是只说做不到。
优化前定义指标、设备、数据、构建配置和复现路径;采集多次基线和分布;使用工具定位主导因素;只修改与假设相关部分;同条件复测,并观察CPU、内存或能耗是否出现回归;灰度看线上P50/P95/P99和样本量。最终结论包括改善幅度、适用范围、代价和防回归测试。
some/any
weak self
中级岗位的核心题至少达到L3,项目题达到L4;高级岗位的并发、架构、性能和稳定性题应达到L4-L5。
第一轮基础面:Swift、Runtime、ARC、UIKit,45分钟;第二轮专项面:并发、网络、存储、性能,60分钟;第三轮项目面:选择两个真实项目连续追问,60分钟;第四轮系统设计:图片加载、聊天消息或Feed流,60分钟;第五轮综合面:架构权衡、事故处理、协作和复盘,45分钟。
面试准备的目标不是把每道题背成唯一答案,而是建立稳定的问题解决链路:定义问题、解释机制、识别边界、提出方案、验证结果、复盘风险。能够用真实项目证据完成这条链路,才是从“会写iOS代码”走向“能负责iOS系统”的分水岭。