iOS 深度解析
目录
1. iOS 启动流程
1.1 启动的宏观阶段划分
iOS App 的启动可分为两个大阶段:pre-main 阶段(main 函数执行之前)和 post-main 阶段(main 函数执行之后到首帧渲染完成)。
- 冷启动(Cold Launch):App 完全不在内存中,需要从磁盘加载所有资源,经历完整的 pre-main 和 post-main 流程。
- 热启动(Warm Launch):App 进程虽然被终止,但部分数据仍然在系统内核的页缓存中(page cache),此时 dyld 加载速度会更快。
- 恢复启动(Resume):App 只是从后台切回前台,不涉及进程创建,严格意义上不算"启动"。
1.2 Pre-main 阶段详解
1.2.1 内核阶段(Kernel)
当用户点击 App 图标时,系统通过 launchd 进程(PID=1)fork 出一个新的进程。内核为新进程完成以下工作:
- 创建进程:分配 PID,创建虚拟内存空间(每个进程都有独立的 4GB/16EB 虚拟地址空间)。
- ASLR(Address Space Layout Randomization):生成一个随机偏移值(slide),将 Mach-O 的加载基地址随机化,防止固定地址攻击。ASLR 是在内核层面实现的,每次启动 slide 不同。
- 加载可执行文件:将 Mach-O 的头部和 Load Commands 映射到虚拟内存中(注意是映射,不是全部读入物理内存,利用的是 mmap 和按需缺页机制)。
1.2.2 dyld 阶段(Dynamic Linker)
dyld(dynamic link editor)是 Apple 的动态链接器,它是第一个在用户态运行的代码。Apple 在 iOS 13/macOS 11 之后将 dyld 升级到了 dyld3 和后来的 dyld4,引入了启动闭包(Launch Closure)机制。
dyld 的核心工作流程:
a) 加载动态库(Load Dylibs)
dyld 根据 Mach-O 的 LC_LOAD_DYLIB 等 Load Commands,递归地加载所有依赖的动态库。每个动态库自身也可能依赖其他动态库,形成一棵依赖树。系统共享库(如 UIKit、Foundation)通过 dyld shared cache(共享缓存)提前合并优化,存放在 /System/Library/Caches/com.apple.dyld/ 下,加载速度极快。
动态库的加载过程:
- 解析 Mach-O Header,验证魔数(Magic Number)、CPU 架构、文件类型。
- 读取 Load Commands,确定各 Segment(
__TEXT、__DATA、__LINKEDIT)的内存映射方式。 - 调用
mmap()将文件内容映射到虚拟内存。 - 由于使用了 Copy-on-Write(COW)技术,只读段可以被多个进程共享物理内存。
b) Rebase(基址重定位)
由于 ASLR 的存在,Mach-O 中所有写死的内部指针地址都需要加上 slide 偏移量。这个过程就是 Rebase。
Rebase 主要操作 __DATA 段中的指针。现代的 chained fixups(链式修正)格式将 rebase 信息直接编码在指针值中,减少了 __LINKEDIT 的大小,也加速了处理。
Rebase 的性能瓶颈不在于计算(加法操作极快),而在于 Page Fault:当访问尚未加载到物理内存的虚拟页时,会触发缺页中断,内核需要从磁盘读取对应的页并进行解密验证(如果开启了代码签名验证)。
c) Bind(符号绑定)
Bind 处理的是对外部动态库符号的引用。App 中调用的 NSLog、objc_msgSend 等函数,在编译时并不知道它们的真实地址,需要在运行时通过符号名查找。
-
Lazy Binding(懒绑定):大部分外部函数调用使用懒绑定,第一次调用时才通过
dyld_stub_binder查找真实地址并回填到__DATA.__la_symbol_ptr(Lazy Symbol Pointer)中,后续调用直接跳转,不再走 dyld。 -
Non-Lazy Binding(非懒绑定):部分符号(如 Objective-C 类引用、全局变量指针)需要在启动时立即绑定,存放在
__DATA.__nl_symbol_ptr(Non-Lazy Symbol Pointer)中。 -
Weak Binding(弱绑定):
__attribute__((weak))修饰的符号需要搜索所有已加载的镜像来确定是否有强定义覆盖,开销较大。
d) dyld3/dyld4 的 Launch Closure
dyld3 引入了 Launch Closure(启动闭包)机制——将首次启动时的解析结果(依赖关系、rebase/bind 信息、初始化顺序等)序列化保存到磁盘。后续启动时直接读取闭包文件,跳过大量解析工作。
dyld4 进一步引入了 PrebuiltLoaderSet,对 App 的启动路径做了更激进的预计算。
1.2.3 Objective-C Runtime 初始化
dyld 在完成所有动态库的加载和绑定后,会调用注册的初始化函数。ObjC Runtime 的初始化是其中最重要的一步:
-
map_images:当新的 Mach-O 镜像被映射到内存时调用。Runtime 解析__DATA.__objc_classlist、__DATA.__objc_catlist(Category 列表)、__DATA.__objc_protolist(Protocol 列表)等 section,将类、分类、协议注册到全局表中。 - 类的实现(Realize):将类从磁盘格式转换为运行时格式,设置 superclass 指针、method list、ivar layout 等。这个过程是懒加载的——只有第一次使用类时才会 realize。
- Category 的附加:将 Category 中的方法、属性、协议"织入"到对应的类中。方法会被插入到方法列表的前面,这就是 Category 能"覆盖"原类方法的原因。
-
load_images:调用所有类和 Category 的+load方法。调用顺序:先按编译顺序调用父类的+load,再调用子类的,最后调用 Category 的。+load在所有类完成注册后、任何+initialize之前执行。
1.2.4 C++ 静态初始化器
所有标记了 __attribute__((constructor)) 的函数以及 C++ 全局对象的构造函数会在此阶段被调用。它们通过 __DATA.__mod_init_func section 记录。
1.2.5 执行 main 函数
完成以上所有步骤后,dyld 调用 App 可执行文件的入口点,即 main() 函数。
1.3 Post-main 阶段详解
1.3.1 UIApplicationMain
main() 函数通常只做一件事:调用 UIApplicationMain()。这个函数完成:
- 创建
UIApplication单例对象。 - 创建 App Delegate 对象。
- 启动主线程的 RunLoop(
CFRunLoopGetMain())。 - 加载
Info.plist,如果指定了 Main Storyboard,则加载并实例化初始 ViewController。
1.3.2 Application Lifecycle Callbacks
按照 iOS 13+ 的 Scene-based Life Cycle(多窗口架构):
-
application:didFinishLaunchingWithOptions:— App 级别的初始化入口。 -
scene:willConnectToSession:options:— Scene 连接。 -
sceneWillEnterForeground:— 即将进入前台。 -
sceneDidBecomeActive:— 已激活,用户可交互。
1.3.3 首帧渲染(First Frame Render)
首帧渲染标志着用户可以看到 App 的实际界面。系统在第一次 CATransaction commit 时将渲染树提交给 Render Server(一个独立进程 backboardd),完成 GPU 合成并上屏。
Apple 的 App Launch Instrument 以 CA::Transaction::commit() 中第一帧绘制完成作为启动结束的标志。
1.4 Mach-O 文件格式补充
Mach-O 是 macOS/iOS 的可执行文件格式,理解它对理解启动流程至关重要:
| 区域 | 内容 |
|---|---|
| Header | 魔数、CPU 类型、文件类型(MH_EXECUTE/MH_DYLIB)、Load Commands 数量 |
| Load Commands | 描述文件布局的元数据:段的位置和大小、动态库依赖、入口点、代码签名位置等 |
| __TEXT | 只读、可执行:机器码(__text)、ObjC 方法名(__objc_methname)、字符串常量(__cstring)等 |
| __DATA | 可读写:全局变量、ObjC 类数据、符号指针表等 |
| __DATA_CONST | 启动后只读:ObjC 类列表、协议列表等(rebase/bind 后被 mprotect 设为只读) |
| __LINKEDIT | 动态链接器使用的元数据:符号表、字符串表、rebase/bind 操作码、代码签名等 |
2. 启动优化
2.1 度量体系
2.1.1 Apple 官方指标
- TTID(Time to Initial Display):App 进程创建到第一帧渲染完成的时间。Apple 建议冷启动控制在 400ms 以内。
-
MetricKit:
MXAppLaunchMetric提供生产环境的启动耗时数据(p50/p90/p99)。 -
DYLD_PRINT_STATISTICS:设置此环境变量可在控制台输出 pre-main 阶段各步骤的耗时。
2.1.2 自建度量
在 +load 或进程创建时记录起始时间戳,在首帧 viewDidAppear: 或 CADisplayLink 回调中记录结束时间戳,差值即为端到端启动时间。注意要使用 mach_absolute_time() 或 clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) 获取高精度时间,避免使用 NSDate(会受 NTP 校时影响)。
2.2 Pre-main 阶段优化
2.2.1 减少动态库数量
每个自定义动态库都会增加 dyld 的加载、rebase、bind 开销。Apple 建议自定义动态库不超过 6 个。
优化手段:
- 将多个小型动态 framework 合并为一个。
- 能用静态库的场景优先使用静态库(静态库在编译链接阶段就合并到了主二进制中,不增加 dyld 的运行时负担)。
- 使用
xcframework统一管理多架构,避免重复链接。
2.2.2 减少 ObjC 元数据
-
减少类和 Category 的数量:每个 ObjC 类都需要在
map_images阶段注册到 Runtime 的全局类表中,每个 Category 都需要被合并到宿主类。大量无用的类会拖慢这个过程。 -
清理无用代码:使用 LinkMap 文件分析各模块大小,结合 AppCode 的 Inspect Code 或开源工具(如
fui、periphery)找出未使用的类和方法。 -
Swift 优势:Swift 的结构体和枚举不经过 ObjC Runtime,不产生
map_images的注册开销。能用 Swift 值类型代替 ObjC 类的场景应优先考虑。
2.2.3 消灭 +load 方法
+load 方法在启动的极早期串行执行(持有 Runtime 的全局锁),任何耗时操作都会直接阻塞启动。
替代方案:
-
+initialize:懒加载,在类第一次收到消息时调用,且只调用一次(线程安全由 Runtime 保证)。将初始化逻辑从+load迁移到+initialize可以将开销延后到实际使用时。 -
__attribute__((constructor))也应减少:与+load类似,在main()之前执行。
2.2.4 二进制重排(Binary Reordering)
原理:App 启动时并非所有代码都会被立即执行。由于虚拟内存的分页机制(iOS 上每页 16KB),启动时执行的函数如果分散在不同的页中,会导致大量 Page Fault。每次 Page Fault 需要从磁盘读取一页并进行代码签名验证(对于加密的 App),耗时约 0.10.3ms。如果启动路径上有 2000 次 Page Fault,累计开销可达 200600ms。
做法:
- 使用 Clang 的 SanitizerCoverage(
-fsanitize-coverage=func,trace-pc-guard)编译代码,在每个函数入口插入回调,记录启动路径上所有被调用的函数及其顺序。 - 生成 Order File(
.order文件),按启动调用顺序列出函数符号。 - 在 Xcode 的 Build Settings 中设置
Order File路径,链接器会按指定顺序排布函数,使启动路径上的函数尽量集中在连续的页中,减少 Page Fault。
效果:对于大型 App,Page Fault 次数可减少 30%70%,带来 100300ms 的启动提升。
2.2.5 dyld3/dyld4 闭包缓存
现代 iOS 系统已默认使用 dyld3 闭包。开发者能做的是确保不破坏闭包缓存的有效性——每次 App 更新后首次启动闭包需要重新生成,这属于不可避免的开销。
2.3 Post-main 阶段优化
2.3.1 任务分级与延迟加载
将 didFinishLaunchingWithOptions: 中的初始化任务按优先级分为三类:
| 优先级 | 任务类型 | 执行时机 |
|---|---|---|
| P0 | 崩溃监控、AB 实验框架 |
didFinishLaunching 最前面,同步执行 |
| P1 | 网络库初始化、用户登录态恢复 |
didFinishLaunching 中异步执行 |
| P2 | 分享 SDK、推送注册、非首屏功能 | 首帧渲染后延迟执行(通过 RunLoop idle 或延时 dispatch) |
关键原则:首帧渲染前只做必须做的事。
2.3.2 首页渲染优化
- 缓存上次的首页截图:在启动时展示缓存截图(skeleton screen 或快照),让用户感知到"已打开",待真实数据加载完成后替换。
- 减少首页视图层级:使用 Instruments 的 View Debugger 分析视图层级深度,减少不必要的嵌套。
- 避免首帧同步网络请求:使用本地缓存数据渲染首帧,网络数据到达后差量更新。
2.3.3 子线程预加载
将不需要在主线程执行的初始化任务放到并发队列中并行执行:
- 数据库初始化和预热。
- 预加载常用的图片资源到内存缓存。
- 预建立 HTTP/2 连接(TCP + TLS 握手)。
注意:UIKit 操作必须在主线程,CoreData 的 NSManagedObjectContext 要注意线程隔离。
2.3.4 启动任务调度框架
大型 App 通常会搭建启动任务调度框架,支持:
- 声明式地定义任务、依赖关系和线程要求。
- 自动拓扑排序确定执行顺序。
- 并行执行无依赖关系的任务。
- 监控每个任务的耗时,自动上报异常。
2.4 持续劣化防护
- CI 卡口:在 CI 流水线中集成启动耗时测试(使用 XCTest + MetricKit 或自定义打点),设置阈值,超标则阻断合入。
-
LinkMap 体积监控:监控二进制体积增长(尤其是
__DATA段的增长),它与 rebase/bind 耗时正相关。 -
+load 扫描:通过静态分析工具在编译期扫描新增的
+load方法。
3. 网络优化
3.1 网络请求的全链路分析
一次 HTTPS 请求的完整链路:
DNS 解析 → TCP 三次握手 → TLS 握手 → 请求发送 → 服务器处理 → 响应接收 → 数据解析
每个环节都有优化空间。
3.2 DNS 优化
3.2.1 传统 DNS 的问题
- 解析延迟:首次解析需要递归查询根域名服务器 → 顶级域名服务器 → 权威域名服务器,耗时 50~200ms,极端情况下可达数秒。
- DNS 劫持:运营商 LocalDNS 可能返回篡改的 IP 地址,将用户引导到广告页或错误服务器。
- 调度不精准:运营商 DNS 的出口 IP 与用户的实际 IP 可能不在同一地区,导致 CDN 调度到非最优节点。
-
DNS 缓存不可控:系统 DNS 缓存(
res_9_getaddrinfo)的 TTL 由服务端控制,App 无法主动管理。
3.2.2 HTTPDNS
HTTPDNS 通过 HTTP/HTTPS 协议直接向 DNS 服务商(如阿里云 HTTPDNS、腾讯云 HTTPDNS)发送域名解析请求,绕过运营商 LocalDNS。
核心优势:
- 防劫持:使用 HTTPS 通道加密传输,运营商无法篡改。
- 精准调度:可以携带客户端真实 IP(EDNS Client Subnet),CDN 能调度到最优节点。
- 可控缓存:App 自主管理 DNS 缓存和预解析策略。
实现要点:
- 预解析:App 启动时对常用域名发起预解析,将结果缓存在本地。
- 缓存策略:本地维护 IP 缓存池,设置合理的 TTL。TTL 过期后异步刷新,期间仍使用旧 IP("乐观缓存"策略),避免解析等待。
- 降级机制:HTTPDNS 服务异常时自动降级到系统 DNS。
-
SNI 问题:使用 HTTPDNS 后,HTTPS 请求的 Host 头是 IP 地址,需要手动设置 SNI(Server Name Indication)字段为原始域名,否则 TLS 握手会因证书不匹配而失败。在
NSURLSession中需要实现URLSession:didReceiveChallenge:completionHandler:代理方法处理证书验证。
3.2.3 DNS-over-HTTPS (DoH) / DNS-over-TLS (DoT)
iOS 14+ 原生支持 DoH/DoT(通过 NEDNSSettingsManager),但这是系统级别的配置,App 级别的定制灵活性不如 HTTPDNS。
3.3 连接优化
3.3.1 连接复用
- HTTP/1.1 Keep-Alive:在同一个 TCP 连接上串行发送多个请求,避免每次请求都建立新连接。但存在 队头阻塞(Head-of-Line Blocking) 问题——前一个请求未完成时后续请求必须等待。
- HTTP/2 多路复用(Multiplexing):在单个 TCP 连接上并行发送多个请求/响应,通过帧(Frame)和流(Stream)的概念实现真正的并发。一个连接可以同时承载上百个请求。但 TCP 层的队头阻塞依然存在——一个丢包会阻塞整个连接上的所有流。
-
HTTP/3 (QUIC):基于 UDP,在传输层消除了队头阻塞。每个流独立进行丢包重传,互不影响。同时集成了 TLS 1.3,握手延迟更低(0-RTT/1-RTT)。iOS 15+ 的
NSURLSession默认支持 HTTP/3。
3.3.2 预连接(Pre-connect)
在用户可能发起请求之前,提前完成 TCP + TLS 握手,使后续请求可以直接发送数据。
实现方式:使用 NSURLSession 的连接预热 API,或自行管理连接池。
3.3.3 连接迁移(Connection Migration)
传统 TCP 连接以四元组(源 IP、源端口、目的 IP、目的端口)标识,当用户从 WiFi 切换到蜂窝时,源 IP 变化导致连接断开。QUIC 使用 Connection ID 标识连接,网络切换时连接不中断,实现无缝迁移。
3.4 数据传输优化
3.4.1 数据压缩
-
Gzip/Brotli:在 HTTP 响应头中设置
Content-Encoding: gzip/br。Brotli 压缩率比 gzip 高 15~25%,特别适合文本类数据。NSURLSession自动处理 gzip 解压。 -
Protocol Buffers / FlatBuffers:使用二进制序列化替代 JSON。Protobuf 体积比 JSON 小 3
10 倍,解析速度快 20100 倍。适用于高频接口和大数据量场景。 - 增量更新(Delta Sync):只传输变化的部分,而非全量数据。可以使用 JSON Patch(RFC 6902)或自定义 diff 算法。
3.4.2 请求合并与批处理
将多个小请求合并为一个批量请求,减少网络往返次数(RTT)。例如将 10 个独立的埋点上报请求合并为 1 个批量请求。
3.4.3 精简数据
- 按需请求字段:使用 GraphQL 或接口的 fields 参数,只请求客户端真正需要的字段,减少无用数据传输。
- 分页加载:对列表类数据实施分页,避免一次加载全量数据。
3.5 缓存策略
3.5.1 HTTP 缓存
-
强缓存:
Cache-Control: max-age=3600和Expires头。在有效期内直接使用本地缓存,不发起网络请求。 -
协商缓存:
ETag/If-None-Match或Last-Modified/If-Modified-Since。客户端携带标识请求服务器,若资源未变则返回 304,节省传输带宽。 -
NSURLSession的缓存策略:通过NSURLRequest.cachePolicy控制,NSURLCache自动管理磁盘和内存缓存。
3.5.2 业务层缓存
- 将接口返回数据持久化到本地(SQLite、文件),优先展示缓存数据,网络数据到达后更新 UI("先展示后刷新"策略)。
- 对于不频繁变化的数据(如配置信息),使用较长的本地缓存有效期。
3.6 弱网优化
- 超时策略:针对不同网络质量动态调整超时时间。WiFi 下 15s,4G 下 20s,3G/2G 下 30s。
- 重试策略:指数退避(Exponential Backoff)+ 抖动(Jitter)。避免重试风暴压垮服务器。只对幂等请求(GET、PUT)重试,POST 请求需要业务层保证幂等性。
-
网络质量检测:通过
NWPathMonitor(Network Framework)实时监听网络状态变化,结合 RTT、丢包率估算网络质量,动态降级(如切换到低分辨率图片)。 -
多通道竞速:在 WiFi 和蜂窝同时可用时,并行发起请求,取先返回的结果。
NSURLSessionConfiguration.multipathServiceType支持 MPTCP(Multipath TCP)。
3.7 安全层优化
- TLS 1.3:将握手往返从 2-RTT(TLS 1.2)减少到 1-RTT,支持 0-RTT 恢复(PSK,Pre-Shared Key)。iOS 12.2+ 默认支持。
- 证书固定(Certificate Pinning):在 App 内预埋服务器证书的公钥哈希,防止中间人攻击。需要注意证书轮换的运维流程。
- OCSP Stapling:服务器在 TLS 握手时主动提供证书状态(是否被吊销),避免客户端额外查询 OCSP 服务器。
3.8 监控体系
-
URLSessionTaskMetrics(iOS 10+):提供每个请求的详细时间线——DNS 解析时间、连接建立时间、TLS 握手时间、请求发送时间、响应接收时间等。这是做网络性能分析的核心数据源。 - 端到端监控指标:成功率、平均耗时、P99 耗时、DNS 解析耗时、首字节时间(TTFB)、错误类型分布等。
- 网络链路追踪:在请求头中注入 Trace ID,贯穿客户端 → CDN → 网关 → 后端服务,实现全链路问题定位。
4. RunLoop
4.1 RunLoop 的本质
RunLoop 本质上是一个 事件循环(Event Loop) 机制。它让线程在没有任务时进入休眠(不消耗 CPU),在有任务时被唤醒处理事件。没有 RunLoop 的线程执行完任务就会退出;有了 RunLoop,线程可以常驻内存,随时响应事件。
RunLoop 与线程是一一对应的关系:
- 主线程的 RunLoop 在
UIApplicationMain中自动创建和启动。 - 子线程的 RunLoop 默认不创建,需要手动调用
[NSRunLoop currentRunLoop]或CFRunLoopGetCurrent()时才会懒加载创建。 - RunLoop 保存在一个全局的
CFMutableDictionaryRef中,以pthread_t作为 key。
4.2 RunLoop 的核心架构
4.2.1 三大核心对象
a) CFRunLoopSource(输入源)
-
Source0(非端口事件源):不能主动唤醒 RunLoop,需要手动调用
CFRunLoopSourceSignal()标记为待处理,再调用CFRunLoopWakeUp()唤醒 RunLoop。触摸事件、performSelector:onThread:等使用 Source0 分发。 - Source1(端口事件源):基于 Mach Port,能主动唤醒 RunLoop。系统内核通过 Mach Port 发送消息来通知事件,如硬件事件(触摸/锁屏/摇晃)首先由 IOKit 通过 Mach Port 传递给 SpringBoard,再由 SpringBoard 通过 Mach Port 分发给对应的 App 进程。App 内部的 Source1 接收到事件后,通常会封装成 Source0 在主线程 RunLoop 中处理。
b) CFRunLoopTimer(定时器源)
基于时间的触发器,与 NSTimer 是 toll-free bridged 的。Timer 的触发时间并非绝对精确——它依赖于 RunLoop 的运行状态。如果 RunLoop 正在处理一个耗时任务,Timer 的回调会被延迟到当前任务完成后才执行。Timer 有一个 tolerance(容差)属性,系统可以在 fireDate ± tolerance 范围内选择最佳触发时机以节能。
c) CFRunLoopObserver(观察者)
可以监听 RunLoop 的状态变化:
| 状态 | 含义 |
|---|---|
kCFRunLoopEntry |
即将进入 RunLoop |
kCFRunLoopBeforeTimers |
即将处理 Timer |
kCFRunLoopBeforeSources |
即将处理 Source |
kCFRunLoopBeforeWaiting |
即将进入休眠 |
kCFRunLoopAfterWaiting |
刚从休眠中唤醒 |
kCFRunLoopExit |
即将退出 RunLoop |
4.2.2 RunLoop Mode
RunLoop 在某一时刻只能运行在一个 Mode 下。每个 Mode 包含独立的 Source/Timer/Observer 集合。切换 Mode 时,当前 Mode 下的 Source/Timer/Observer 不会被处理。
常用 Mode:
-
kCFRunLoopDefaultMode(NSDefaultRunLoopMode):默认 Mode,App 空闲时运行在此 Mode。 -
UITrackingRunLoopMode:ScrollView 滑动时切换到此 Mode。这就是为什么NSTimer在 Default Mode 下注册时,滑动 ScrollView 期间 Timer 不触发——因为 RunLoop 此时运行在 Tracking Mode 下。 -
kCFRunLoopCommonModes(NSRunLoopCommonModes):这不是一个真正的 Mode,而是一个"模式集合"的标记。被标记为 Common 的 Source/Timer/Observer 会被同步到所有被标记为 Common 的 Mode 中。默认情况下 Default Mode 和 Tracking Mode 都是 Common Mode。将 Timer 添加到 Common Modes 可以让它在滑动时也能触发。
4.3 RunLoop 的运行机制(核心循环)
RunLoop 的核心运行逻辑(简化版):
- 通知 Observer:即将进入 RunLoop(
kCFRunLoopEntry)。 - 通知 Observer:即将处理 Timer(
kCFRunLoopBeforeTimers)。 - 通知 Observer:即将处理 Source0(
kCFRunLoopBeforeSources)。 - 处理所有待处理的 Source0 事件。
- 如果有 Source1(Mach Port 消息)待处理,跳转到步骤 9 直接处理。
- 通知 Observer:即将进入休眠(
kCFRunLoopBeforeWaiting)。 -
休眠,等待唤醒。线程通过
mach_msg()系统调用陷入内核态,让出 CPU。可以被以下事件唤醒:- Mach Port 消息到达(Source1 事件、Timer 触发、
CFRunLoopWakeUp()调用)。 - 超时(RunLoop 有一个超时参数)。
- 被外部手动唤醒。
- Mach Port 消息到达(Source1 事件、Timer 触发、
- 通知 Observer:刚从休眠中被唤醒(
kCFRunLoopAfterWaiting)。 - 处理唤醒事件:
- 如果是 Timer 到期:处理 Timer 回调。
- 如果是 dispatch_main_queue 的 block:执行 block(GCD 派发到主队列的任务通过 RunLoop 的 Source1 唤醒主线程执行)。
- 如果是 Source1 事件:处理 Source1 回调。
- 判断是否需要退出(Mode 中没有任何 Source/Timer、被外部停止、超时等)。
- 如果不退出,跳转到步骤 2 继续循环。
- 通知 Observer:即将退出 RunLoop(
kCFRunLoopExit)。
4.4 RunLoop 与系统功能的关系
4.4.1 AutoreleasePool
主线程 RunLoop 注册了两个 Observer 与 AutoreleasePool 配合:
- 第一个 Observer 监听
kCFRunLoopEntry(优先级最高,保证在所有回调之前):调用_objc_autoreleasePoolPush()创建自动释放池。 - 第二个 Observer 监听
kCFRunLoopBeforeWaiting(优先级最低,保证在所有回调之后):调用_objc_autoreleasePoolPop()释放旧池中的对象,再调用_objc_autoreleasePoolPush()创建新池。同时监听kCFRunLoopExit:调用_objc_autoreleasePoolPop()做最终释放。
这意味着主线程上被 autorelease 的对象会在每次 RunLoop 循环即将休眠时被释放。
4.4.2 事件响应
硬件事件(触摸)传递链:
- 硬件产生中断 → IOKit.framework 封装为 IOHIDEvent。
- 通过 Mach Port 传递给 SpringBoard 进程。
- SpringBoard 判断前台 App,通过 Mach Port 传递给 App 进程。
- App 主线程 RunLoop 的 Source1 被唤醒,回调
__IOHIDEventSystemClientQueueCallback()。 - Source1 内部触发 Source0(
__UIApplicationHandleEventQueue())。 - Source0 中进行 Hit Test、手势识别、UIResponder 事件分发。
4.4.3 UI 刷新
setNeedsLayout、setNeedsDisplay 等调用不会立即触发布局/绘制,而是标记为"需要更新"。主线程 RunLoop 注册了一个 Observer 监听 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit,在回调中遍历所有标记了需要更新的视图,执行实际的 layout、display、render 操作,最终打包提交给 Render Server。
这就是 Core Animation 的 Transaction 机制。
4.4.4 GCD 与 RunLoop
当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会唤醒主线程的 RunLoop(通过向 RunLoop 的 dispatch port 发送 Mach 消息),RunLoop 在循环中检测到 dispatch port 有消息后,会调用 _dispatch_main_queue_callback_4CF() 来执行 block。
4.4.5 performSelector:afterDelay:
performSelector:withObject:afterDelay: 实际上是创建了一个 Timer 添加到当前线程的 RunLoop 中。如果当前线程没有 RunLoop(子线程默认没有),这个方法不会执行。
4.5 RunLoop 的实际应用
- 常驻子线程:为子线程创建 RunLoop 并添加一个永不触发的 Port(防止 RunLoop 因没有 Source/Timer 而退出),使线程常驻内存,随时可以接收任务。AFNetworking 2.x 和 SDWebImage 早期版本都使用过这个技巧。
-
NSTimer 滑动不停:将 Timer 添加到
NSRunLoopCommonModes。 - 卡顿监控:通过 Observer 监听 RunLoop 状态,检测主线程 Source 处理或休眠前等待是否超时(详见卡顿监控章节)。
- 线程保活(Thread Keep-Alive):网络库中用于在子线程持续接收回调。
- 任务拆分:将大量计算任务拆分成小块,每次 RunLoop 循环处理一块,避免长时间阻塞主线程(类似协程的思想)。
5. Runtime
5.1 Runtime 的本质
Objective-C Runtime 是一个用 C/C++/汇编编写的运行时库,它实现了 ObjC 的面向对象特性和动态性。ObjC 是一门动态语言——许多决定(调用哪个方法、对象是什么类型)被推迟到运行时。
核心思想:消息发送(Messaging)。ObjC 中的方法调用 [obj method] 会被编译器转换为 objc_msgSend(obj, @selector(method)),由 Runtime 在运行时查找并执行对应的实现。
5.2 对象模型
5.2.1 对象(id / objc_object)
每个 ObjC 对象本质上是一个结构体,其第一个成员是 isa 指针,指向该对象所属的类。
从 ARM64 开始,Apple 使用了 Tagged Pointer 和 Non-pointer ISA 优化:
Tagged Pointer:对于 NSNumber、NSDate、短字符串等小对象,指针本身就直接存储了对象的值,不需要在堆上分配内存。判断方法:指针的最高位(ARM64)或最低位(x86_64)为 1 则是 Tagged Pointer。Tagged Pointer 不是真正的对象,没有 isa、没有 retain/release 开销,内存效率和访问速度极高。
Non-pointer ISA(优化的 isa):在 64 位系统上,isa 不再是单纯的类指针。64 位中只有 33~44 位用于存储类地址,其余位存储了:
-
引用计数(
extra_rc,19 位,存储引用计数减 1 的值)。当extra_rc溢出时,将一半的引用计数转存到 SideTable 的RefcountMap中,has_sidetable_rc标志位置 1。 -
是否有关联对象(
has_assoc)。 -
是否有 C++ 析构函数(
has_cxx_dtor)。 -
是否使用了弱引用(
weakly_referenced)。 -
是否正在释放(
deallocating)。
5.2.2 类(objc_class)
类也是一个对象(元类的实例),继承自 objc_object。关键成员:
- isa:指向元类(metaclass)。
- superclass:指向父类。
-
cache:方法缓存(
cache_t),使用哈希表存储最近调用的方法,加速消息发送。 -
bits / class_rw_t:
-
class_ro_t(Read-Only):编译期确定的只读数据——方法列表、属性列表、ivar 列表、协议列表、实例大小等。存储在 Mach-O 的__DATA_CONST段中。 -
class_rw_t(Read-Write):运行时创建的可读写数据,包含对class_ro_t的引用,以及运行时动态添加的方法、属性、协议列表。 -
class_rw_ext_t:iOS 14+ 优化,只有在类被运行时修改过(如添加了 Category、使用了class_addMethod)时才会创建class_rw_ext_t,约 90% 的类不需要,节省大量内存(Apple 称全系统节省约 14MB)。
-
5.2.3 元类(Metaclass)
- 实例对象的 isa → 类对象。
- 类对象的 isa → 元类对象。
- 元类对象的 isa → 根元类(NSObject 的元类)。
- 根元类的 isa → 自身。
- 根元类的 superclass → NSObject 类。
这个链条解释了为什么实例方法存储在类中,类方法存储在元类中——消息发送总是沿着 isa 链查找方法。
5.3 消息发送机制(objc_msgSend)
5.3.1 快速查找(缓存查找)
objc_msgSend 是用汇编语言编写的(ARM64),追求极致性能。
执行流程:
- 判断 receiver 是否为 nil(Tagged Pointer 的特殊处理)。
- 通过 receiver 的 isa 找到类对象。
- 在类的
cache_t(方法缓存)中查找 SEL 对应的 IMP。cache_t是一个开放寻址的哈希表,使用 SEL 的地址值做 mask 运算得到索引,查找效率接近 O(1)。 - 如果命中缓存(Cache Hit),直接跳转到 IMP 执行——整个过程几十纳秒,纯汇编实现。
5.3.2 慢速查找(方法列表查找)
缓存未命中时,进入 C/C++ 实现的 lookUpImpOrForward 函数:
- 在当前类的
class_rw_t中搜索方法列表。方法列表已按 SEL 地址排序(在类 realize 时排序),使用二分查找,时间复杂度 O(log n)。 - 如果未找到,沿 superclass 链向上逐级查找父类的方法列表(每级都先查缓存再查方法列表)。
- 如果一直到
NSObject(根类)都未找到,进入消息转发流程。 - 如果找到了,将 SEL→IMP 的映射写入当前类的
cache_t(注意是写入最初接收消息的类的缓存,不是找到方法的那个父类的缓存)。
5.3.3 方法缓存(cache_t)的实现细节
- 哈希表使用 掩码(mask) 而非取模,因为 mask 可以用位与运算(
& mask)替代除法,更快。 - 缓存容量始终是 2 的幂次,初始容量为 4(ARM64)。
- 当缓存使用率超过 3/4(75%) 时,容量翻倍并清空所有旧缓存(而非 rehash),因为 Apple 认为缓存的时间局部性很强,旧缓存大概率不再需要。
- 类在第一次收到消息时分配缓存。
5.4 消息转发机制(Message Forwarding)
当消息发送的快速查找和慢速查找都未找到方法实现时,进入消息转发的三个阶段:
5.4.1 第一阶段:动态方法解析(Dynamic Method Resolution)
Runtime 调用:
- 实例方法:
+resolveInstanceMethod: - 类方法:
+resolveClassMethod:
在这个方法中,类有机会动态地为 SEL 添加一个 IMP(通过 class_addMethod)。如果返回 YES 且添加了方法,Runtime 会重新执行消息发送流程。
应用场景:@dynamic 属性的实现、Core Data 的 NSManagedObject 动态生成属性的 getter/setter。
5.4.2 第二阶段:快速转发(Fast Forwarding / Forwarding Target)
Runtime 调用 -forwardingTargetForSelector:。
在这个方法中,可以返回另一个对象来处理这条消息(消息转发给备用接收者)。这一步效率很高,因为直接对新对象执行 objc_msgSend,不需要创建 NSInvocation。
应用场景:多重代理(将消息转发给多个对象)、组合模式的简化实现。
5.4.3 第三阶段:完整转发(Normal Forwarding)
Runtime 依次调用:
-
-methodSignatureForSelector::返回方法的类型签名(NSMethodSignature),描述参数类型和返回值类型。 -
-forwardInvocation::接收一个封装了完整调用信息的NSInvocation对象,可以修改目标、参数、甚至调用多次。
这是最灵活但最慢的阶段,NSInvocation 的创建涉及堆分配和参数拷贝。
如果以上三个阶段都未处理,最终调用 -doesNotRecognizeSelector:,抛出经典的 "unrecognized selector sent to instance" 异常。
5.5 Method Swizzling
通过 Runtime 函数交换两个方法的 IMP,实现 AOP(面向切面编程)。
核心 API:
-
method_exchangeImplementations:交换两个 Method 的 IMP。 -
class_replaceMethod:替换某个 SEL 的 IMP。 -
method_setImplementation:设置某个 Method 的 IMP。
陷阱与最佳实践:
-
必须在
+load中执行(或用dispatch_once保证只执行一次),避免竞态条件。 - 必须调用原始实现:Swizzle 后的方法中要调用"看似递归实际不是"的原始方法(因为 IMP 已经交换了)。
-
父类方法问题:如果当前类没有实现目标方法(继承自父类),直接交换会影响父类。正确做法是先
class_addMethod尝试添加,成功则只需class_replaceMethod替换父类的实现到当前类的新 SEL,失败(说明当前类已有实现)才method_exchangeImplementations。 -
_cmd问题:Swizzle 后方法内部的_cmd值是交换后的 SEL,可能导致日志、KVO 等依赖_cmd的逻辑出错。
5.6 关联对象(Associated Objects)
通过 objc_setAssociatedObject / objc_getAssociatedObject 为已存在的类动态添加"属性"(实际是绑定的键值对)。
内部存储结构:
全局维护一个 AssociationsManager(自带锁),内部是一个 AssociationsHashMap:
AssociationsHashMap: { 对象地址(disguised_ptr_t) → ObjectAssociationMap }
ObjectAssociationMap: { key(const void*) → ObjcAssociation(policy + value) }
- 关联对象不存储在对象本身的内存中,而是存储在全局的哈希表中,以对象地址为 key。
- 对象销毁时(
dealloc),Runtime 检查 isa 的has_assoc标志位,如果为 1,则调用_object_remove_associations()清除该对象的所有关联对象。 - 关联策略:
OBJC_ASSOCIATION_ASSIGN(弱引用)、OBJC_ASSOCIATION_RETAIN_NONATOMIC(强引用,非原子)、OBJC_ASSOCIATION_COPY_NONATOMIC(拷贝)等,语义与 property 属性一致。
5.7 Category 的实现原理
Category 在编译后生成 category_t 结构体,包含:方法列表、属性列表、协议列表(但没有 ivar 列表,这就是 Category 不能添加实例变量的原因——实例变量列表在编译期确定,存储在 class_ro_t 中,不可修改)。
加载过程:
- 在
map_images阶段,Runtime 遍历所有镜像的__objc_catlistsection,收集所有 Category。 - 调用
attachCategories()将 Category 的方法列表倒序插入到类的方法列表数组的前面(使用attachLists的ATTACH_EXISTING方式)。 - 因此,后编译的 Category 的方法会排在最前面,最先被找到——这就是 Category "覆盖"原类方法的真相(原方法仍然存在,只是排在后面不会被优先找到)。
多个 Category 有同名方法时:取决于编译顺序(Build Phases → Compile Sources 中的文件顺序),最后编译的 Category 的方法排在最前面。
5.8 Weak 引用的实现
全局 Weak 表:Runtime 维护一个全局的 SideTable(实际上是一个 StripedMap,包含 64 个 SideTable 以减少锁竞争),每个 SideTable 包含:
- spinlock_t:自旋锁,保护并发访问。
-
RefcountMap:存储对象的额外引用计数(
extra_rc溢出时使用)。 - weak_table_t:弱引用表,核心结构。
weak_table_t 是一个哈希表,以对象地址为 key,value 是 weak_entry_t,包含所有指向该对象的 weak 指针的地址。
weak 指针的赋值过程:
- 调用
objc_initWeak()(或objc_storeWeak())。 - 如果旧值非 nil,从旧对象的
weak_entry_t中移除该 weak 指针。 - 如果新值非 nil,将该 weak 指针注册到新对象的
weak_entry_t中。
对象销毁时清除 weak 引用:
-
dealloc→_objc_rootDealloc→rootDealloc→object_dispose→objc_destructInstance。 -
objc_destructInstance中:清除关联对象 → 清除弱引用(weak_clear_no_lock)→ 清除 SideTable 引用计数。 -
weak_clear_no_lock:遍历对象的weak_entry_t中所有 weak 指针地址,将它们全部置为 nil。
这就是 weak 指针在对象销毁后自动变为 nil 的底层机制。
5.9 KVO 的底层实现
KVO(Key-Value Observing)完全依赖 Runtime 实现:
- 当对某个对象的属性添加 KVO 观察时,Runtime 动态创建一个该对象所属类的子类(命名为
NSKVONotifying_OriginalClass)。 - 将对象的 isa 指向这个动态子类(isa swizzling)。
- 动态子类重写了被观察属性的 setter 方法,在 setter 中插入:
-
willChangeValueForKey:→ 调用原始 setter →didChangeValueForKey:。 -
didChangeValueForKey:内部触发observeValueForKeyPath:ofObject:change:context:回调。
-
- 动态子类还重写了
class方法(返回原类而非NSKVONotifying_前缀的子类,对外隐藏 KVO 的实现细节),以及dealloc(清理观察)和_isKVOA(标识 KVO 类)。
6. 卡顿监控
6.1 卡顿的定义与原理
iOS 设备的屏幕刷新率通常为 60Hz(ProMotion 设备最高 120Hz),意味着每帧的渲染时间预算为 16.67ms(60fps)或 8.33ms(120fps)。如果主线程在一帧的时间内未完成 UI 更新的所有工作(布局计算、绘制、图层合成提交),就会导致掉帧(Frame Drop),用户感知为卡顿。
渲染流水线(Render Pipeline):
App 进程(CPU) Render Server(GPU)
┌─────────────────┐ ┌──────────────────┐
│ Layout │ │ 图层树解码 │
│ Display (Draw) │ ──Commit──────→ │ 纹理上传 │
│ Prepare │ Transaction │ 合成渲染 │
│ Commit │ │ 显示 │
└─────────────────┘ └──────────────────┘
← 一帧 16.67ms → ← 一帧 16.67ms →
CPU 和 GPU 是流水线式工作的。CPU 在当前帧完成布局和绘制后提交给 GPU,GPU 在下一帧完成合成渲染。任一环节超时都会导致掉帧。
6.2 卡顿的常见原因
CPU 侧
- 复杂布局计算:Auto Layout 的约束求解是多项式时间复杂度,视图层级深、约束多时开销显著。
-
文本计算与渲染:
NSAttributedString的排版(Text Kit / Core Text)、行高计算、折行计算。 -
图片解码:
UIImage在首次渲染时才进行解码(从 PNG/JPEG 压缩格式解码为位图),大图的解码可能耗时数十毫秒。 -
对象创建与销毁:大量对象的
alloc/dealloc(尤其涉及 ARC 的 retain/release 操作和 SideTable 锁竞争)。 - 数据库/文件 I/O:主线程同步读写磁盘。
- 锁等待:主线程等待子线程持有的锁。
GPU 侧
-
离屏渲染(Offscreen Rendering):
cornerRadius + masksToBounds、shadow、mask、group opacity等会触发离屏渲染,GPU 需要额外创建帧缓冲区。 - 过度绘制(Overdraw):大量重叠的不透明图层导致 GPU 重复渲染。
- 大图纹理:超大图片上传到 GPU 的纹理缓存,占用大量显存和带宽。
-
图层爆炸:大量
CALayer导致合成开销增大。
6.3 卡顿监控方案
6.3.1 方案一:RunLoop Observer 监控
原理:主线程的所有任务都在 RunLoop 中执行。通过监听 RunLoop 的状态变化,检测两个关键时间间隔:
- kCFRunLoopBeforeSources 到 kCFRunLoopBeforeWaiting(Source 处理阶段):如果这个间隔过长,说明 Source0 事件处理耗时过久(如触摸事件处理中有耗时操作)。
- kCFRunLoopAfterWaiting 到下一次 kCFRunLoopBeforeWaiting(被唤醒后的处理阶段):如果这个间隔过长,说明被唤醒后的任务处理耗时过久。
实现思路:
- 在主线程注册一个
CFRunLoopObserver,监听所有状态变化。 - 在 Observer 回调中记录状态变化的时间戳和当前状态。
- 创建一个子线程,用信号量(
dispatch_semaphore)定期检测(如每 50ms 一次)主线程 RunLoop 是否长时间停留在某个状态。 - 如果连续多次(如 3 次)检测到主线程处于同一个状态超过阈值(如 250ms),判定为卡顿。
- 在子线程中抓取主线程的调用堆栈。
卡顿判定策略:
- 超过 1 帧(16ms):微卡顿,通常不记录。
- 超过 3 帧(50ms):轻微卡顿。
- 超过 250ms:明显卡顿,需要记录堆栈。
- 超过 3s:严重卡顿(ANR),需要立即上报。
6.3.2 方案二:子线程 Ping(心跳检测)
原理:子线程定期向主线程发送一个"心跳"任务(通过 dispatch_async 派发到主队列),如果主线程在规定时间内未能执行该任务,则认为主线程被阻塞。
实现思路:
- 子线程设置一个 flag 为 false,通过
dispatch_async(dispatch_get_main_queue(), ^{ flag = true; })发送心跳。 - 子线程等待一段时间(如 500ms 或 1s)。
- 检查 flag:如果仍为 false,说明主线程在此期间一直忙碌,判定为卡顿。
- 抓取主线程堆栈。
优缺点比较:
- RunLoop Observer 方案更精确,能定位到具体的 RunLoop 阶段,但实现复杂。
- 心跳检测方案简单可靠,但只能检测到"主线程忙",无法区分是哪种任务导致的。
6.3.3 方案三:CADisplayLink 帧率监控
利用 CADisplayLink 的回调计算实际帧率。CADisplayLink 会在每次屏幕刷新前调用回调,如果两次回调的间隔超过 16.67ms,说明发生了掉帧。
局限性:只能检测掉帧的发生和严重程度,无法直接获取卡顿原因的堆栈信息。通常作为辅助监控手段,与上述方案配合使用。
6.3.4 方案四:基于 MetricKit(iOS 14+)
MXHangDiagnostic 提供系统级别的卡顿诊断信息,包括卡顿时长和调用堆栈。MXCPUExceptionDiagnostic 报告 CPU 异常使用情况。
优点是零性能开销(系统在后台采集),缺点是数据延迟(次日推送),适合线上监控而非实时调试。
6.4 堆栈采集
卡顿检测到后,最关键的是采集主线程的调用堆栈,用于定位卡顿的根因。
6.4.1 基于 mach_thread API
使用 task_threads() 获取所有线程列表,通过 thread_get_state() 获取目标线程(主线程)的寄存器状态(包含 PC、FP、LR 等),然后沿着 Frame Pointer(FP)链回溯调用栈,结合 DWARF 调试信息或 dSYM 文件符号化。
6.4.2 基于 backtrace() / backtrace_symbols()
标准 POSIX 接口,但只能获取当前线程的堆栈,无法跨线程采集。
6.4.3 基于 PLCrashReporter
开源的崩溃报告库,提供了安全的跨线程堆栈采集能力(信号安全、锁安全),是业界常用方案。
6.5 堆栈聚合与分析
- 调用树合并:将多次采集的堆栈按调用路径合并成火焰图/调用树,识别热点函数。
-
符号化:将内存地址转换为函数名+偏移量,需要对应版本的 dSYM 文件。使用
atos命令或dwarfdump工具。 -
去噪:过滤系统框架的堆栈帧(如
CFRunLoopRunSpecific、mach_msg_trap),聚焦业务代码。
6.6 治理策略
-
文本异步计算:使用
NSAttributedString的boundingRectWithSize:在子线程预计算文本高度。 -
图片异步解码:在子线程用
CGBitmapContextCreate+CGContextDrawImage强制解码图片,主线程直接使用解码后的位图。 - 预排版/预计算:Cell 的高度、布局信息在数据到达时在子线程预计算完成,主线程直接使用。
- 按需加载:屏幕外的 Cell 不进行复杂渲染。
-
减少离屏渲染:用
UIBezierPath+CAShapeLayer替代cornerRadius + masksToBounds;用shadowPath替代自动计算的阴影。 -
异步绘制:使用
drawRect:在后台线程绘制位图,再赋值给CALayer.contents(参考 Texture/AsyncDisplayKit 框架的思想)。
7. AFNetworking
7.1 整体架构
AFNetworking 是 iOS/macOS 上最流行的网络库。目前主流版本为 AFNetworking 4.x,完全基于 NSURLSession(3.x 开始移除了 NSURLConnection 支持)。
核心架构分层:
┌────────────────────────────────────────────┐
│ AFHTTPSessionManager │ ← 最高层:便捷 HTTP 接口
│ (GET/POST/PUT/DELETE 等快捷方法) │
├────────────────────────────────────────────┤
│ AFURLSessionManager │ ← 核心层:Session 管理
│ (NSURLSession delegate 的完整实现) │
├────────────────────────────────────────────┤
│ AFURLRequestSerialization │ ← 请求序列化
│ (HTTP/JSON/PropertyList Request) │
├────────────────────────────────────────────┤
│ AFURLResponseSerialization │ ← 响应反序列化
│ (HTTP/JSON/XML/Image/PropertyList) │
├────────────────────────────────────────────┤
│ AFSecurityPolicy │ ← 安全策略(HTTPS/证书验证)
├────────────────────────────────────────────┤
│ AFNetworkReachabilityManager │ ← 网络状态监听
└────────────────────────────────────────────┘
7.2 AFURLSessionManager 深入解析
7.2.1 核心职责
AFURLSessionManager 是整个库的心脏,它:
- 持有并管理一个
NSURLSession实例。 - 实现了
NSURLSessionDelegate、NSURLSessionTaskDelegate、NSURLSessionDataDelegate、NSURLSessionDownloadDelegate四个协议的所有关键方法。 - 维护一个
mutableTaskDelegatesKeyedByTaskIdentifier字典,将每个NSURLSessionTask映射到一个AFURLSessionManagerTaskDelegate对象,实现任务级别的回调隔离。
7.2.2 线程安全设计
- 使用
NSLock(名为lock)保护mutableTaskDelegatesKeyedByTaskIdentifier字典的并发访问。 -
NSURLSession的 delegate 回调在一个专用的串行 OperationQueue(operationQueue.maxConcurrentOperationCount = 1)上执行,保证回调的串行化,避免多线程问题。 - 完成回调(success/failure block)默认 dispatch 到主队列(
completionQueue默认为dispatch_get_main_queue()),保证 UI 更新的线程安全。开发者也可以自定义completionQueue和completionGroup。
7.2.3 任务代理(AFURLSessionManagerTaskDelegate)
每个 NSURLSessionTask 对应一个 AFURLSessionManagerTaskDelegate 实例,它负责:
- 收集响应数据:在
URLSession:dataTask:didReceiveData:中将接收到的数据追加到mutableData中。 - 跟踪上传/下载进度:通过
NSProgress对象提供 KVO 兼容的进度更新。 - 任务完成时:根据
responseSerializer反序列化响应数据,在completionQueue上回调 success/failure block。
7.2.4 KVO 与通知机制
AFNetworking 大量使用了 KVO 和 NSNotification:
- 对
NSURLSessionTask的state属性进行 KVO 观察,当任务状态变为completed时自动清理。 - 任务 resume/suspend/complete 时发送全局通知(如
AFNetworkingTaskDidResumeNotification),方便外部监听(如网络活动指示器AFNetworkActivityIndicatorManager)。 - 使用 Method Swizzling 交换了
NSURLSessionTask的resume和suspend方法,在调用时发送通知。这是因为NSURLSession不对 task 的state变化发送 KVO 通知,AF 需要自己实现。
7.3 请求序列化(AFURLRequestSerialization)
7.3.1 AFHTTPRequestSerializer
基础的 HTTP 请求序列化器:
- 设置通用 HTTP Header(User-Agent、Accept-Language、Authorization 等)。
- 将参数字典编码为 URL query string(GET/HEAD/DELETE)或 HTTP body(POST/PUT/PATCH)。
- 参数编码规则:对键值对进行百分号编码(Percent Encoding),嵌套字典和数组使用方括号语法(
key[subkey]=value、key[]=value)。 -
multipartFormData:支持multipart/form-data编码,用于文件上传。内部使用AFMultipartBodyStream(自定义的NSInputStream子类)实现流式上传,避免将整个文件载入内存。
7.3.2 AFJSONRequestSerializer
继承自 AFHTTPRequestSerializer,将参数字典使用 NSJSONSerialization 编码为 JSON 格式放入 HTTP Body,设置 Content-Type 为 application/json。
7.4 响应序列化(AFURLResponseSerialization)
响应序列化器负责验证响应的合法性并将数据转换为目标格式。
7.4.1 验证机制
所有序列化器都继承自 AFHTTPResponseSerializer,它的 validateResponse:data:error: 方法检查:
- HTTP 状态码是否在
acceptableStatusCodes(默认 200~299)范围内。 - 响应的
Content-Type是否在acceptableContentTypes集合中。
如果验证失败,生成对应的 NSError(AFURLResponseSerializationErrorDomain),并将响应数据放入 error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey] 中,方便调试。
7.4.2 AFJSONResponseSerializer
使用 NSJSONSerialization 将 Data 解析为字典/数组。支持自动移除 JSON 中的 NSNull 值(removesKeysWithNullValues 属性)。
7.4.3 AFImageResponseSerializer
将 Data 解码为 UIImage。支持自动解压(inflate)——在子线程强制解码图片位图,避免在主线程首次渲染时的解码开销(与 SDWebImage 的思路一致)。
7.5 安全策略(AFSecurityPolicy)
7.5.1 三种验证模式
| 模式 | 说明 | 安全级别 |
|---|---|---|
AFSSLPinningModeNone |
使用系统默认的证书链验证 | 低 |
AFSSLPinningModeCertificate |
将服务器证书与 App 内预埋的证书进行完整比对 | 最高 |
AFSSLPinningModePublicKey |
只比对证书中的公钥(Public Key) | 高(推荐) |
7.5.2 证书验证流程
- 获取服务器返回的证书链(
SecTrustRef)。 - 设置锚点证书(Anchor Certificates)为 App 预埋的证书。
- 调用
SecTrustEvaluateWithError()进行系统级证书链验证。 - 根据 Pinning Mode:
- Certificate Mode:逐一比对证书的 DER 编码数据。
- PublicKey Mode:提取证书的公钥数据进行比对。
-
validatesDomainName:是否验证证书中的域名与请求域名匹配。
7.5.3 公钥固定的优势
比证书固定更灵活——即使服务器更换了证书(只要使用相同的密钥对),App 无需更新。
7.6 网络可达性(AFNetworkReachabilityManager)
基于 SCNetworkReachability(SystemConfiguration 框架),监听网络状态变化。
核心流程:
- 使用
SCNetworkReachabilityCreateWithAddress或SCNetworkReachabilityCreateWithName创建 reachability 引用。 - 设置回调函数,当网络状态变化时触发。
- 将 reachability 引用加入 RunLoop(
CFRunLoopGetMain())以持续监听。 - 回调中解析
SCNetworkReachabilityFlags,判断:- 是否可达(
kSCNetworkReachabilityFlagsReachable)。 - 是否通过 WWAN(
kSCNetworkReachabilityFlagsIsWWAN)。
- 是否可达(
注意:SCNetworkReachability 检测的是"是否有网络路径",不是"是否能真正连通互联网"。飞行模式能检测到,但连上 WiFi 但无法上网的情况检测不到。
7.7 与 Alamofire 的对比
Alamofire 是 AFNetworking 作者在 Swift 生态下的重写,核心思想一致但做了现代化改进:
| 对比维度 | AFNetworking | Alamofire |
|---|---|---|
| 语言 | Objective-C | Swift |
| 并发模型 | GCD + NSOperationQueue | Swift Concurrency (async/await) |
| 请求构建 | Mutable URL Request | 链式调用 + Request 协议 |
| 响应处理 | Block 回调 | Result + async/await |
| 拦截器 | 需自行封装 | 内置 RequestInterceptor 协议 |
| 重试 | 需自行实现 | 内置 RetryPolicy
|
8. SDWebImage
8.1 整体架构
SDWebImage 是 iOS 上最广泛使用的图片加载和缓存库。其核心设计哲学是将复杂的图片加载流程封装为简洁的 API(如 sd_setImageWithURL:),同时提供高度可定制的扩展点。
架构分层:
┌──────────────────────────────────────────────────┐
│ UIView+WebCache │ ← 最上层:UIKit 扩展
│ (UIImageView / UIButton 的分类方法) │
├──────────────────────────────────────────────────┤
│ SDWebImageManager │ ← 核心调度器
│ (协调缓存查找和网络下载) │
├──────────────┬───────────────────────────────────┤
│ SDImageCache │ SDWebImageDownloader │ ← 缓存 / 下载
│ (内存+磁盘) │ (网络下载管理) │
├──────────────┴───────────────────────────────────┤
│ SDWebImageDownloaderOperation │ ← 下载操作
│ (基于 NSURLSessionDataTask 的下载单元) │
├──────────────────────────────────────────────────┤
│ SDImageCoder / SDImageTransformer │ ← 编解码 / 变换
│ (PNG/JPEG/GIF/WebP/HEIF 编解码, 圆角/缩放等) │
└──────────────────────────────────────────────────┘
8.2 加载流程全景
当调用 [imageView sd_setImageWithURL:url] 时,完整的执行流程:
Step 1:取消旧任务 取消该 UIImageView 上一次尚未完成的图片加载任务(通过关联对象存储的 operation key)。这避免了 Cell 复用场景下的图片错乱问题。
Step 2:设置占位图
如果提供了 placeholder,立即在主线程设置占位图。
Step 3:查询缓存
SDWebImageManager 调用 SDImageCache 查询缓存:
-
内存缓存查询:
SDMemoryCache(基于NSCache)中以 URL 的 MD5/SHA256 哈希为 key 查找。命中则直接返回。 -
磁盘缓存查询:如果内存未命中,在串行 I/O 队列(
ioQueue)中异步查询磁盘缓存。磁盘缓存使用文件存储,文件名为 URL 的 MD5 哈希值。查询过程包括:- 检查文件是否存在(
fileExistsAtPath:)。 - 读取文件数据。
- 对图片进行解码(从 PNG/JPEG 数据解码为位图)。
- 将解码后的图片写入内存缓存(回填)。
- 检查文件是否存在(
Step 4:网络下载
如果缓存完全未命中(或设置了 SDWebImageRefreshCached 选项),启动网络下载:
-
SDWebImageDownloader创建或复用一个SDWebImageDownloaderOperation。 - 同一个 URL 的多次请求会被合并(Coalescing)——只发一次网络请求,结果回调给所有等待者。这通过
URLOperations字典(以 URL 为 key)实现。 - 下载操作基于
NSURLSessionDataTask。
Step 5:图片处理 下载完成后:
- 在子线程进行图片解码(Decode)。
- 如果设置了
SDImageTransformer(如圆角、缩放、高斯模糊),在子线程执行变换。 - 将处理后的图片同时写入内存缓存和磁盘缓存。
Step 6:回调主线程
在主线程设置 imageView.image,触发 UI 更新。支持渐变动画(SDWebImageTransition)。
8.3 缓存机制深入解析
8.3.1 内存缓存(SDMemoryCache)
继承自 NSCache,具备以下特性:
-
自动淘汰:当系统内存紧张时,
NSCache会自动释放对象。开发者可以设置countLimit(最大数量)和totalCostLimit(最大总开销,以图片像素数为 cost)。 -
线程安全:
NSCache内部使用锁保护,可以在任意线程安全访问。 -
弱引用表(mapTable):SDWebImage 额外维护了一个
NSMapTable(weakToStrongObjects),当NSCache因内存压力淘汰了某张图片时,如果该图片仍被某个 UIImageView 持有(强引用),通过 mapTable 仍然可以找到它,避免不必要的重新解码/下载。
8.3.2 磁盘缓存(SDDiskCache)
- 存储格式:原始的图片数据(未解码的 PNG/JPEG/WebP 数据),不是解码后的位图。这大幅减少了磁盘空间占用。
- 文件命名:URL 的 MD5 哈希值作为文件名,避免特殊字符问题。
-
过期策略:默认缓存保留 1 周(
maxDiskAge = 60 * 60 * 24 * 7)。 -
容量限制:可设置
maxDiskSize(最大磁盘缓存大小),超限时按最近最久未使用(LRU) 策略淘汰——根据文件的NSFileModificationDate(修改日期)排序,优先删除最旧的文件,直到缓存大小降至限制的一半。 -
清理时机:
- App 进入后台时(
UIApplicationDidEnterBackgroundNotification)触发异步清理。 - App 终止时(
UIApplicationWillTerminateNotification)触发清理。 - 开发者手动调用
clearDiskOnCompletion:。
- App 进入后台时(
8.3.3 缓存 Key 的计算
默认使用完整的 URL 字符串作为缓存 key。开发者可以通过 SDWebImageManager 的 cacheKeyFilter block 自定义 key 生成逻辑(例如去除 URL 中的 token 参数,使相同内容的不同签名 URL 共享缓存)。
如果使用了 SDImageTransformer,变换后的图片使用 originalKey + transformerKey 作为缓存 key,与原图分开缓存。
8.4 图片解码机制
8.4.1 为什么需要预解码
UIImage 的 imageWithData: 创建的图片是未解码的——它只是持有压缩的图片数据。只有在图片首次被渲染到屏幕上时(CALayer 的 display 方法中),Core Animation 才会调用解码器将其解码为位图。这个解码发生在主线程,可能导致掉帧。
SDWebImage 的策略是在子线程提前解码(Force Decode / Decompressing),将位图缓存到内存中,主线程直接使用解码后的位图,消除主线程解码开销。
8.4.2 解码实现
解码的核心步骤:
- 创建
CGBitmapContext(位图上下文),指定颜色空间、每像素字节数、Alpha 通道信息。 - 使用
CGContextDrawImage将CGImageRef绘制到上下文中——这一步触发实际的解码。 - 从上下文中获取解码后的
CGImageRef,创建新的UIImage。
内存占用计算:一张 1000×1000 的图片解码后占用 1000 × 1000 × 4 bytes = 4MB(RGBA 格式,每像素 4 字节)。因此,SDWebImage 提供了 SDImageCoderDecodeScaleDownLimitBytes 选项,对超大图片进行降采样后再解码,避免内存暴涨。
8.4.3 渐进式解码(Progressive Decoding)
对于 JPEG 等支持渐进式加载的格式,SDWebImage 可以在下载过程中边下载边解码。每接收一段数据就解码一次,UI 上展示从模糊到清晰的渐进效果。
通过 SDImageCoderProgressiveCoder 协议实现,每次调用 updateIncrementalData:finished: 更新数据并产生部分解码的图片。
8.4.4 编解码器架构(SDImageCoder)
SDWebImage 5.x 使用了协议化的编解码器架构:
-
SDImageCoder协议定义了canDecodeFromData:、decodedImageWithData:、encodedDataWithImage:等方法。 - 内置编解码器:
SDImageIOCoder(PNG/JPEG/TIFF/GIF 静图)、SDImageGIFCoder(GIF 动图)、SDImageAPNGCoder(APNG)。 - 可扩展:通过
SDImageCodersManager注册自定义编解码器,如SDImageWebPCoder(WebP 支持)、SDImageHEICCoder(HEIC 支持)。 - 解码器按注册的逆序遍历(后注册的优先),调用
canDecodeFromData:判断哪个解码器能处理当前数据格式。
8.5 下载机制深入
8.5.1 SDWebImageDownloader
- 维护一个
NSOperationQueue(downloadQueue),控制最大并发下载数(默认 6)。 - 支持 LIFO(后进先出)和 FIFO(先进先出)两种执行顺序。LIFO 适合瀑布流场景——用户快速滑动时,最新可见的 Cell 的图片优先下载。通过设置 Operation 之间的依赖关系实现 LIFO。
- 支持 HTTP Header 自定义、认证(
URLCredential)、超时配置等。
8.5.2 SDWebImageDownloaderOperation
继承自 NSOperation,内部封装了一个 NSURLSessionDataTask。
关键设计:
-
回调合并:使用
callbackBlocks数组存储所有对同一 URL 的下载回调。当下载完成时,遍历数组逐一回调。 -
后台下载:支持 App 进入后台后继续下载(通过
UIApplication.beginBackgroundTaskWithExpirationHandler:)。 -
响应数据拼接:在
URLSession:dataTask:didReceiveData:中将数据追加到NSMutableData(imageData),下载完成后一次性交给解码器。 -
取消机制:调用
cancel时取消NSURLSessionDataTask,从callbackBlocks中移除对应的回调。如果所有回调都被移除,则取消整个下载任务。
8.5.3 URL 请求去重(Coalescing)
SDWebImageDownloader 维护一个 URLOperations 字典(以 URL 为 key,以 SDWebImageDownloaderOperation 为 value)。当新请求到来时:
- 如果该 URL 已有进行中的下载操作,直接将新的回调添加到现有 Operation 的
callbackBlocks中,不创建新的网络请求。 - 如果没有,创建新的 Operation 并加入队列。
这种设计在列表场景下极为高效——同一张头像被多个 Cell 引用时,只会发起一次网络请求。
8.6 UIView+WebCache 的设计
通过 ObjC Runtime 的关联对象机制,为 UIImageView 等视图绑定当前的加载操作。
核心流程:
- 调用
sd_setImageWithURL:时,先通过sd_cancelCurrentImageLoad取消当前关联的旧操作。 - 使用
objc_setAssociatedObject将新的SDWebImageCombinedOperation关联到视图上。 - 加载完成或 Cell 复用时,通过
objc_getAssociatedObject获取并取消/检查操作状态。
这解决了经典的 Cell 复用导致图片错乱问题:当 Cell 被复用时,旧 Cell 的下载完成回调中设置的图片会被忽略(因为旧操作已被取消)。
8.7 动图支持
8.7.1 GIF / APNG
SDWebImage 使用 SDAnimatedImageView(继承自 UIImageView)播放动图。其内部实现:
- 使用
CADisplayLink驱动动画帧切换。 -
按需解码:不一次性解码所有帧(一个 GIF 可能有数百帧,全部解码会占用大量内存),而是维护一个帧缓存(
NSMutableDictionary),预解码当前帧附近的若干帧(预取缓冲区),按需释放远离当前播放位置的帧。 - 帧缓冲区大小根据可用内存动态调整。
8.7.2 WebP / HEIF
通过可插拔的编解码器支持:
-
SDImageWebPCoder:使用 libwebp 库进行 WebP 编解码。 -
SDImageHEICCoder:使用系统 ImageIO 框架进行 HEIF 编解码(iOS 11+)。
8.8 性能优化细节
-
异步 I/O:磁盘缓存的所有读写操作都在专用的串行
ioQueue上异步执行,不阻塞主线程。 -
解码降采样:对于超大图片(如 4000×3000 的相机照片),先使用
CGImageSourceCreateThumbnailAtIndex进行降采样到目标显示尺寸,再解码。这比先解码再缩放效率高得多——直接操作压缩数据,内存峰值大幅降低。 -
内存警告响应:监听
UIApplicationDidReceiveMemoryWarningNotification,立即清空内存缓存(NSCache的removeAllObjects)。 -
URL 黑名单:对于下载失败的 URL(非超时错误),加入
failedURLs集合,短期内不再重试,避免无效请求浪费资源(可通过SDWebImageRetryFailed选项关闭此行为)。 -
Prefetch(预加载):
SDWebImagePrefetcher支持批量预加载图片到缓存中,适用于已知用户即将浏览的内容(如下一页的列表数据)。
8.9 SDWebImage 5.x 的架构升级
SDWebImage 5.x 相比 4.x 做了大量架构优化:
| 特性 | 4.x | 5.x |
|---|---|---|
| 编解码 | 硬编码在内部 | 协议化(SDImageCoder) |
| 缓存 | 固定实现 | 协议化(SDImageCache Protocol) |
| 下载 | 固定实现 | 协议化(SDImageLoader Protocol) |
| 变换 | 需第三方库 | 内置 SDImageTransformer |
| 动图 | FLAnimatedImage 依赖 | 内置 SDAnimatedImage |
| 指标 | 无 | SDImageLoadIndicator |
协议化设计使得每个组件都可以被替换为自定义实现,极大提升了灵活性。
总结
上述八个知识点构成了 iOS 开发中性能优化与底层原理的核心体系:
- 启动流程和启动优化帮助我们理解 App 从点击图标到用户可见的完整链路,并从 pre-main 和 post-main 两个阶段系统性地优化启动速度。
- 网络优化覆盖了从 DNS 到数据传输、从连接管理到弱网对抗的全链路优化策略。
- RunLoop 是 iOS 事件驱动模型的基石,理解它才能理解触摸事件、Timer、UI 刷新等核心机制的运作方式。
- Runtime 是 Objective-C 动态性的根基,消息发送、方法缓存、消息转发、KVO、Category 等特性都建立在它之上。
- 卡顿监控将 RunLoop 和性能分析结合,提供了从检测到治理的完整方案。
- AFNetworking 和 SDWebImage 作为两个最经典的第三方库,它们的架构设计、线程安全策略、性能优化思路值得深入学习和借鉴。