iOS 深度解析

0 阅读50分钟

目录

  1. iOS 启动流程
  2. 启动优化
  3. 网络优化
  4. RunLoop
  5. Runtime
  6. 卡顿监控
  7. AFNetworking
  8. SDWebImage

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 中调用的 NSLogobjc_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(多窗口架构):

  1. application:didFinishLaunchingWithOptions: — App 级别的初始化入口。
  2. scene:willConnectToSession:options: — Scene 连接。
  3. sceneWillEnterForeground: — 即将进入前台。
  4. sceneDidBecomeActive: — 已激活,用户可交互。

1.3.3 首帧渲染(First Frame Render)

首帧渲染标志着用户可以看到 App 的实际界面。系统在第一次 CATransaction commit 时将渲染树提交给 Render Server(一个独立进程 backboardd),完成 GPU 合成并上屏。

Apple 的 App Launch InstrumentCA::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 以内。
  • MetricKitMXAppLaunchMetric 提供生产环境的启动耗时数据(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 或开源工具(如 fuiperiphery)找出未使用的类和方法。
  • 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。

做法

  1. 使用 Clang 的 SanitizerCoverage-fsanitize-coverage=func,trace-pc-guard)编译代码,在每个函数入口插入回调,记录启动路径上所有被调用的函数及其顺序。
  2. 生成 Order File.order 文件),按启动调用顺序列出函数符号。
  3. 在 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 小 310 倍,解析速度快 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=3600Expires 头。在有效期内直接使用本地缓存,不发起网络请求。
  • 协商缓存ETag / If-None-MatchLast-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:

  • kCFRunLoopDefaultModeNSDefaultRunLoopMode:默认 Mode,App 空闲时运行在此 Mode。
  • UITrackingRunLoopMode:ScrollView 滑动时切换到此 Mode。这就是为什么 NSTimer 在 Default Mode 下注册时,滑动 ScrollView 期间 Timer 不触发——因为 RunLoop 此时运行在 Tracking Mode 下。
  • kCFRunLoopCommonModesNSRunLoopCommonModes:这不是一个真正的 Mode,而是一个"模式集合"的标记。被标记为 Common 的 Source/Timer/Observer 会被同步到所有被标记为 Common 的 Mode 中。默认情况下 Default Mode 和 Tracking Mode 都是 Common Mode。将 Timer 添加到 Common Modes 可以让它在滑动时也能触发。

4.3 RunLoop 的运行机制(核心循环)

RunLoop 的核心运行逻辑(简化版):

  1. 通知 Observer:即将进入 RunLoop(kCFRunLoopEntry)。
  2. 通知 Observer:即将处理 Timer(kCFRunLoopBeforeTimers)。
  3. 通知 Observer:即将处理 Source0(kCFRunLoopBeforeSources)。
  4. 处理所有待处理的 Source0 事件。
  5. 如果有 Source1(Mach Port 消息)待处理,跳转到步骤 9 直接处理。
  6. 通知 Observer:即将进入休眠(kCFRunLoopBeforeWaiting)。
  7. 休眠,等待唤醒。线程通过 mach_msg() 系统调用陷入内核态,让出 CPU。可以被以下事件唤醒:
    • Mach Port 消息到达(Source1 事件、Timer 触发、CFRunLoopWakeUp() 调用)。
    • 超时(RunLoop 有一个超时参数)。
    • 被外部手动唤醒。
  8. 通知 Observer:刚从休眠中被唤醒(kCFRunLoopAfterWaiting)。
  9. 处理唤醒事件:
    • 如果是 Timer 到期:处理 Timer 回调。
    • 如果是 dispatch_main_queue 的 block:执行 block(GCD 派发到主队列的任务通过 RunLoop 的 Source1 唤醒主线程执行)。
    • 如果是 Source1 事件:处理 Source1 回调。
  10. 判断是否需要退出(Mode 中没有任何 Source/Timer、被外部停止、超时等)。
  11. 如果不退出,跳转到步骤 2 继续循环。
  12. 通知 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 事件响应

硬件事件(触摸)传递链:

  1. 硬件产生中断 → IOKit.framework 封装为 IOHIDEvent。
  2. 通过 Mach Port 传递给 SpringBoard 进程。
  3. SpringBoard 判断前台 App,通过 Mach Port 传递给 App 进程。
  4. App 主线程 RunLoop 的 Source1 被唤醒,回调 __IOHIDEventSystemClientQueueCallback()
  5. Source1 内部触发 Source0(__UIApplicationHandleEventQueue())。
  6. Source0 中进行 Hit Test、手势识别、UIResponder 事件分发。

4.4.3 UI 刷新

setNeedsLayoutsetNeedsDisplay 等调用不会立即触发布局/绘制,而是标记为"需要更新"。主线程 RunLoop 注册了一个 Observer 监听 kCFRunLoopBeforeWaitingkCFRunLoopExit,在回调中遍历所有标记了需要更新的视图,执行实际的 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 PointerNon-pointer ISA 优化:

Tagged Pointer:对于 NSNumberNSDate、短字符串等小对象,指针本身就直接存储了对象的值,不需要在堆上分配内存。判断方法:指针的最高位(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),追求极致性能。

执行流程:

  1. 判断 receiver 是否为 nil(Tagged Pointer 的特殊处理)。
  2. 通过 receiver 的 isa 找到类对象。
  3. 在类的 cache_t(方法缓存)中查找 SEL 对应的 IMP。cache_t 是一个开放寻址的哈希表,使用 SEL 的地址值做 mask 运算得到索引,查找效率接近 O(1)。
  4. 如果命中缓存(Cache Hit),直接跳转到 IMP 执行——整个过程几十纳秒,纯汇编实现。

5.3.2 慢速查找(方法列表查找)

缓存未命中时,进入 C/C++ 实现的 lookUpImpOrForward 函数:

  1. 在当前类的 class_rw_t 中搜索方法列表。方法列表已按 SEL 地址排序(在类 realize 时排序),使用二分查找,时间复杂度 O(log n)。
  2. 如果未找到,沿 superclass 链向上逐级查找父类的方法列表(每级都先查缓存再查方法列表)。
  3. 如果一直到 NSObject(根类)都未找到,进入消息转发流程。
  4. 如果找到了,将 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 依次调用:

  1. -methodSignatureForSelector::返回方法的类型签名(NSMethodSignature),描述参数类型和返回值类型。
  2. -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 中,不可修改)。

加载过程

  1. map_images 阶段,Runtime 遍历所有镜像的 __objc_catlist section,收集所有 Category。
  2. 调用 attachCategories() 将 Category 的方法列表倒序插入到类的方法列表数组的前面(使用 attachListsATTACH_EXISTING 方式)。
  3. 因此,后编译的 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 指针的赋值过程

  1. 调用 objc_initWeak()(或 objc_storeWeak())。
  2. 如果旧值非 nil,从旧对象的 weak_entry_t 中移除该 weak 指针。
  3. 如果新值非 nil,将该 weak 指针注册到新对象的 weak_entry_t 中。

对象销毁时清除 weak 引用

  1. dealloc_objc_rootDeallocrootDeallocobject_disposeobjc_destructInstance
  2. objc_destructInstance 中:清除关联对象 → 清除弱引用(weak_clear_no_lock)→ 清除 SideTable 引用计数。
  3. weak_clear_no_lock:遍历对象的 weak_entry_t 中所有 weak 指针地址,将它们全部置为 nil。

这就是 weak 指针在对象销毁后自动变为 nil 的底层机制。

5.9 KVO 的底层实现

KVO(Key-Value Observing)完全依赖 Runtime 实现:

  1. 当对某个对象的属性添加 KVO 观察时,Runtime 动态创建一个该对象所属类的子类(命名为 NSKVONotifying_OriginalClass)。
  2. 将对象的 isa 指向这个动态子类(isa swizzling)。
  3. 动态子类重写了被观察属性的 setter 方法,在 setter 中插入:
    • willChangeValueForKey: → 调用原始 setter → didChangeValueForKey:
    • didChangeValueForKey: 内部触发 observeValueForKeyPath:ofObject:change:context: 回调。
  4. 动态子类还重写了 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 + masksToBoundsshadowmaskgroup opacity 等会触发离屏渲染,GPU 需要额外创建帧缓冲区。
  • 过度绘制(Overdraw):大量重叠的不透明图层导致 GPU 重复渲染。
  • 大图纹理:超大图片上传到 GPU 的纹理缓存,占用大量显存和带宽。
  • 图层爆炸:大量 CALayer 导致合成开销增大。

6.3 卡顿监控方案

6.3.1 方案一:RunLoop Observer 监控

原理:主线程的所有任务都在 RunLoop 中执行。通过监听 RunLoop 的状态变化,检测两个关键时间间隔:

  • kCFRunLoopBeforeSources 到 kCFRunLoopBeforeWaiting(Source 处理阶段):如果这个间隔过长,说明 Source0 事件处理耗时过久(如触摸事件处理中有耗时操作)。
  • kCFRunLoopAfterWaiting 到下一次 kCFRunLoopBeforeWaiting(被唤醒后的处理阶段):如果这个间隔过长,说明被唤醒后的任务处理耗时过久。

实现思路

  1. 在主线程注册一个 CFRunLoopObserver,监听所有状态变化。
  2. 在 Observer 回调中记录状态变化的时间戳和当前状态。
  3. 创建一个子线程,用信号量(dispatch_semaphore)定期检测(如每 50ms 一次)主线程 RunLoop 是否长时间停留在某个状态。
  4. 如果连续多次(如 3 次)检测到主线程处于同一个状态超过阈值(如 250ms),判定为卡顿。
  5. 在子线程中抓取主线程的调用堆栈。

卡顿判定策略

  • 超过 1 帧(16ms):微卡顿,通常不记录。
  • 超过 3 帧(50ms):轻微卡顿。
  • 超过 250ms:明显卡顿,需要记录堆栈。
  • 超过 3s:严重卡顿(ANR),需要立即上报。

6.3.2 方案二:子线程 Ping(心跳检测)

原理:子线程定期向主线程发送一个"心跳"任务(通过 dispatch_async 派发到主队列),如果主线程在规定时间内未能执行该任务,则认为主线程被阻塞。

实现思路

  1. 子线程设置一个 flag 为 false,通过 dispatch_async(dispatch_get_main_queue(), ^{ flag = true; }) 发送心跳。
  2. 子线程等待一段时间(如 500ms 或 1s)。
  3. 检查 flag:如果仍为 false,说明主线程在此期间一直忙碌,判定为卡顿。
  4. 抓取主线程堆栈。

优缺点比较

  • 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 工具。
  • 去噪:过滤系统框架的堆栈帧(如 CFRunLoopRunSpecificmach_msg_trap),聚焦业务代码。

6.6 治理策略

  • 文本异步计算:使用 NSAttributedStringboundingRectWithSize: 在子线程预计算文本高度。
  • 图片异步解码:在子线程用 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 实例。
  • 实现了 NSURLSessionDelegateNSURLSessionTaskDelegateNSURLSessionDataDelegateNSURLSessionDownloadDelegate 四个协议的所有关键方法。
  • 维护一个 mutableTaskDelegatesKeyedByTaskIdentifier 字典,将每个 NSURLSessionTask 映射到一个 AFURLSessionManagerTaskDelegate 对象,实现任务级别的回调隔离。

7.2.2 线程安全设计

  • 使用 NSLock(名为 lock)保护 mutableTaskDelegatesKeyedByTaskIdentifier 字典的并发访问。
  • NSURLSession 的 delegate 回调在一个专用的串行 OperationQueueoperationQueue.maxConcurrentOperationCount = 1)上执行,保证回调的串行化,避免多线程问题。
  • 完成回调(success/failure block)默认 dispatch 到主队列(completionQueue 默认为 dispatch_get_main_queue()),保证 UI 更新的线程安全。开发者也可以自定义 completionQueuecompletionGroup

7.2.3 任务代理(AFURLSessionManagerTaskDelegate)

每个 NSURLSessionTask 对应一个 AFURLSessionManagerTaskDelegate 实例,它负责:

  • 收集响应数据:在 URLSession:dataTask:didReceiveData: 中将接收到的数据追加到 mutableData 中。
  • 跟踪上传/下载进度:通过 NSProgress 对象提供 KVO 兼容的进度更新。
  • 任务完成时:根据 responseSerializer 反序列化响应数据,在 completionQueue 上回调 success/failure block。

7.2.4 KVO 与通知机制

AFNetworking 大量使用了 KVO 和 NSNotification:

  • NSURLSessionTaskstate 属性进行 KVO 观察,当任务状态变为 completed 时自动清理。
  • 任务 resume/suspend/complete 时发送全局通知(如 AFNetworkingTaskDidResumeNotification),方便外部监听(如网络活动指示器 AFNetworkActivityIndicatorManager)。
  • 使用 Method Swizzling 交换了 NSURLSessionTaskresumesuspend 方法,在调用时发送通知。这是因为 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]=valuekey[]=value)。
  • multipartFormData:支持 multipart/form-data 编码,用于文件上传。内部使用 AFMultipartBodyStream(自定义的 NSInputStream 子类)实现流式上传,避免将整个文件载入内存。

7.3.2 AFJSONRequestSerializer

继承自 AFHTTPRequestSerializer,将参数字典使用 NSJSONSerialization 编码为 JSON 格式放入 HTTP Body,设置 Content-Typeapplication/json

7.4 响应序列化(AFURLResponseSerialization)

响应序列化器负责验证响应的合法性并将数据转换为目标格式。

7.4.1 验证机制

所有序列化器都继承自 AFHTTPResponseSerializer,它的 validateResponse:data:error: 方法检查:

  • HTTP 状态码是否在 acceptableStatusCodes(默认 200~299)范围内。
  • 响应的 Content-Type 是否在 acceptableContentTypes 集合中。

如果验证失败,生成对应的 NSErrorAFURLResponseSerializationErrorDomain),并将响应数据放入 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 证书验证流程

  1. 获取服务器返回的证书链(SecTrustRef)。
  2. 设置锚点证书(Anchor Certificates)为 App 预埋的证书。
  3. 调用 SecTrustEvaluateWithError() 进行系统级证书链验证。
  4. 根据 Pinning Mode:
    • Certificate Mode:逐一比对证书的 DER 编码数据。
    • PublicKey Mode:提取证书的公钥数据进行比对。
  5. validatesDomainName:是否验证证书中的域名与请求域名匹配。

7.5.3 公钥固定的优势

比证书固定更灵活——即使服务器更换了证书(只要使用相同的密钥对),App 无需更新。

7.6 网络可达性(AFNetworkReachabilityManager)

基于 SCNetworkReachability(SystemConfiguration 框架),监听网络状态变化。

核心流程:

  1. 使用 SCNetworkReachabilityCreateWithAddressSCNetworkReachabilityCreateWithName 创建 reachability 引用。
  2. 设置回调函数,当网络状态变化时触发。
  3. 将 reachability 引用加入 RunLoop(CFRunLoopGetMain())以持续监听。
  4. 回调中解析 SCNetworkReachabilityFlags,判断:
    • 是否可达(kSCNetworkReachabilityFlagsReachable)。
    • 是否通过 WWAN(kSCNetworkReachabilityFlagsIsWWAN)。

注意:SCNetworkReachability 检测的是"是否有网络路径",不是"是否能真正连通互联网"。飞行模式能检测到,但连上 WiFi 但无法上网的情况检测不到。

7.7 与 Alamofire 的对比

Alamofire 是 AFNetworking 作者在 Swift 生态下的重写,核心思想一致但做了现代化改进:

对比维度AFNetworkingAlamofire
语言Objective-CSwift
并发模型GCD + NSOperationQueueSwift 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 哈希值。查询过程包括:
    1. 检查文件是否存在(fileExistsAtPath:)。
    2. 读取文件数据。
    3. 对图片进行解码(从 PNG/JPEG 数据解码为位图)。
    4. 将解码后的图片写入内存缓存(回填)。

Step 4:网络下载 如果缓存完全未命中(或设置了 SDWebImageRefreshCached 选项),启动网络下载:

  • SDWebImageDownloader 创建或复用一个 SDWebImageDownloaderOperation
  • 同一个 URL 的多次请求会被合并(Coalescing)——只发一次网络请求,结果回调给所有等待者。这通过 URLOperations 字典(以 URL 为 key)实现。
  • 下载操作基于 NSURLSessionDataTask

Step 5:图片处理 下载完成后:

  1. 在子线程进行图片解码(Decode)。
  2. 如果设置了 SDImageTransformer(如圆角、缩放、高斯模糊),在子线程执行变换。
  3. 将处理后的图片同时写入内存缓存和磁盘缓存。

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:

8.3.3 缓存 Key 的计算

默认使用完整的 URL 字符串作为缓存 key。开发者可以通过 SDWebImageManagercacheKeyFilter block 自定义 key 生成逻辑(例如去除 URL 中的 token 参数,使相同内容的不同签名 URL 共享缓存)。

如果使用了 SDImageTransformer,变换后的图片使用 originalKey + transformerKey 作为缓存 key,与原图分开缓存。

8.4 图片解码机制

8.4.1 为什么需要预解码

UIImageimageWithData: 创建的图片是未解码的——它只是持有压缩的图片数据。只有在图片首次被渲染到屏幕上时(CALayerdisplay 方法中),Core Animation 才会调用解码器将其解码为位图。这个解码发生在主线程,可能导致掉帧。

SDWebImage 的策略是在子线程提前解码(Force Decode / Decompressing),将位图缓存到内存中,主线程直接使用解码后的位图,消除主线程解码开销。

8.4.2 解码实现

解码的核心步骤:

  1. 创建 CGBitmapContext(位图上下文),指定颜色空间、每像素字节数、Alpha 通道信息。
  2. 使用 CGContextDrawImageCGImageRef 绘制到上下文中——这一步触发实际的解码。
  3. 从上下文中获取解码后的 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

  • 维护一个 NSOperationQueuedownloadQueue),控制最大并发下载数(默认 6)。
  • 支持 LIFO(后进先出)和 FIFO(先进先出)两种执行顺序。LIFO 适合瀑布流场景——用户快速滑动时,最新可见的 Cell 的图片优先下载。通过设置 Operation 之间的依赖关系实现 LIFO。
  • 支持 HTTP Header 自定义、认证(URLCredential)、超时配置等。

8.5.2 SDWebImageDownloaderOperation

继承自 NSOperation,内部封装了一个 NSURLSessionDataTask

关键设计:

  • 回调合并:使用 callbackBlocks 数组存储所有对同一 URL 的下载回调。当下载完成时,遍历数组逐一回调。
  • 后台下载:支持 App 进入后台后继续下载(通过 UIApplication.beginBackgroundTaskWithExpirationHandler:)。
  • 响应数据拼接:在 URLSession:dataTask:didReceiveData: 中将数据追加到 NSMutableDataimageData),下载完成后一次性交给解码器。
  • 取消机制:调用 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 等视图绑定当前的加载操作。

核心流程:

  1. 调用 sd_setImageWithURL: 时,先通过 sd_cancelCurrentImageLoad 取消当前关联的旧操作。
  2. 使用 objc_setAssociatedObject 将新的 SDWebImageCombinedOperation 关联到视图上。
  3. 加载完成或 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,立即清空内存缓存(NSCacheremoveAllObjects)。
  • URL 黑名单:对于下载失败的 URL(非超时错误),加入 failedURLs 集合,短期内不再重试,避免无效请求浪费资源(可通过 SDWebImageRetryFailed 选项关闭此行为)。
  • Prefetch(预加载)SDWebImagePrefetcher 支持批量预加载图片到缓存中,适用于已知用户即将浏览的内容(如下一页的列表数据)。

8.9 SDWebImage 5.x 的架构升级

SDWebImage 5.x 相比 4.x 做了大量架构优化:

特性4.x5.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 和性能分析结合,提供了从检测到治理的完整方案。
  • AFNetworkingSDWebImage 作为两个最经典的第三方库,它们的架构设计、线程安全策略、性能优化思路值得深入学习和借鉴。