普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月2日iOS

iOS 深度解析

作者 忆江南
2026年3月2日 18:07

目录

  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 生态下的重写,核心思想一致但做了现代化改进:

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

移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据

2026年3月2日 16:09

近期,由小红书联合多伦多大学等高校的研究人员发布了 《SWE-Bench Mobile》(2602.09540) 论文,内容主要是评估 LLM 智能体在处理真实生产级移动端应用开发任务时的能力,并提出了首个针对该领域的基准测试——SWE-Bench Mobile

这个论文对比之前那些简单的需求场景,明显更具备说服力,最重要的是,用真实的数据给目前的 AI 狂热浇一浇冷水

目前的编程基准测试大多集中在孤立的算法问题,而 SWE-Bench 则是关注 GitHub 上的 Bug 修复,然而真实的工业级移动端开发汪汪更为复杂:

  • 多模态输入:开发者需要根据产品需求文档(PRD)和 Figma 设计稿等来写代码
  • 复杂的工程环境:中大厂的移动端代码库通常规模巨大( 5GB 以上),且涉及 Swift 与 Objective-C 混编、特定系统 API 及复杂的 UI 交互,还有编译环境影响
  • 任务类型多样化:不限于 Bug 修复,更多是功能开发和 UI 增强

所以研究团队从目前小红书自己的真实产品流水线中提取了 50 个具有代表性的开发任务,构建了该基准测试:

  • 数据集组成

    • 50 个真实任务:源自实际的产品需求
    • 449 个人工验证的测试用例:平均每个任务 9.1 个测试点,用于评估功能正确性
    • 多模态支持:70% 的任务附带 Figma 设计链接,92% 附带参考图
  • 代码库规模:基于约 5GB 大小的真实 iOS 生产代码库(Swift/Objective-C)

  • 任务复杂度:平均每个任务涉及修改 4.2 个文件,远超之前的基准测试

整个基准的规则是:

  • 70% 任务包含 Figma
  • 92% 包含参考图片
  • 平均 PRD 长度 450 字

每个任务包含:

  • 一个统一 diff 补丁(patch)输出
  • 综合测试套件(平均 9.1 个测试案例)
  • 任务难度分级:从简单 UI 调整到复杂跨模块改造

对于任务两个关键指标:

  • 任务成功率:所有测试通过的任务比例
  • 测试通过率:所有测试案例通过的比率

而对于 LLM,论文评估了 22 种 不同的“智能体-模型”配置,涵盖了四个主流框架:

  • 商业智能体:Cursor、Codex (由 DeepSeek/OpenAI 等模型驱动)、Claude Code
  • 开源智能体:OpenCode

评估维度包括:任务完成率、任务复杂度影响、成本效果对比、多次运行稳定性、Prompt 设计影响等。

而根据论文可以得出结论:当前 AI 在生产级的软件工程力存在巨大局限性:

  • 成功率极低表现最好配置的成功率仅为 12% ,大多数任务以“实现不完整”告终,但测试通过率最高可到 28%,说明部分任务可以部分正确生成,但没能完全部署成功
  • 智能体架构十分重要 :同一个底层模型,在 Cursor 框架下的成功率为 12%,但在 OpenCode 下仅为 2%,智能体的工具调用、上下文管理等设计与模型本身同等重要
  • 商业模型占优:商业闭源智能体在处理大型代码库时的稳定性和正确性显著优于开源方案
  • 复杂度陷阱任务涉及 1-2 个文件时成功率为 18%,但当涉及 7 个以上文件时,成功率骤降至 2% ,显示出模型在跨文件长程推理方面的短板
  • “防御性编程”提示词更有效:研究发现,使用基于“防御性编程”(原则的简洁提示词,比复杂的提示词能让成功率提升 7.4%

对于失败,论文还针对失败类型归类:

  • 缺失关键功能标志位或 Feature Flag 是主要的失败原因
  • 其次是 数据模型缺失
  • 再者是 incomplete patch(文件覆盖不足)等问题

这些失败的类似,在一定程度上反映了智能体对真实工程流程、跨文件依赖、与视觉设计的理解严重不足,也就是这些问题是“工程级问题”,而不是“语言问题”:

所以哪怕换成 Android / Flutter,这类跨文件工程理解问题仍然存在。

基于这些数据,论文认为当前 LLM Agent 尽管在单一代码生成上有突破,但在端到端工程上下文(包含设计、代码库理解、工程流程)仍远未达到企业生产标准

另外,论文也有一个有趣的结论数据,主要统计了各 Agent + Model 的每任务成本(美元)和平均耗时(分钟),例如:

  • Cursor + Opus 4.5 : $3.50 / 15 min
  • Codex + GLM 4.6 : $1.30 / 13.3 min
  • OpenCode + GLM 4.6 : $0.13 / 32.5 min
  • OpenCode + Opus 4.5 : $9.33 / 8.2 min

对此可以看出来:

  • Codex + GLM 4.6 是性价比最高
  • OpenCode 极便宜但成功率低
  • OpenCode + Opus 4.5 是最贵但效果很差(2%)

最后,下图是论文的最终结果对比,例如在 Success 和 Pass 上:

  • Cursor + Opus 4.5 → 12% / 28.1%
  • Codex + GLM 4.6 → 12% / 19.6%
  • OpenCode + GLM 4.6 → 8%

这么看,OpenCode 的实际数据表现是真的一般。

这个在同一个模型,在不同 agent 上的成功率也有所体现,OpenCode 再一次被鞭尸:

所以,可以看出来,目前的 AI 智能体离独立完成中大型移动开发还有很大距离,主要瓶颈在于多模态理解、大规模代码导航和跨文件逻辑一致性等。

另外,SWE-Bench Mobile 采用了托管基准挑战(Hosted Benchmark)模式 ,不公开测试集答案,以防止数据泄露到未来的模型训练中。

最后,论文只针对原生 iOS 开发进行测试,没有测试 Android 原生、Flutter、RN 等其他情况,按照一般直觉,这些框架的 AI 表现应该会好于 iOS 原生,当然这也只是我的个人直觉,真实数据还是得有企业做过 Benchmark 才知道。

不过至少从目前看,在移动端开发领域写代码上,至少比前端安全性高一些?你怎么看?

05-主题|事件响应者链@iOS-应用场景与进阶实践

2026年3月2日 14:16

本文在 01 总纲02 hitTest03 响应者链04 UIResponder 基础上,总结工程中的应用场景进阶用法:UIControl 的 target=nil 与响应者链、手势识别器与响应者的优先级、扩大点击区域与事件穿透、以及 SwiftUI 与 UIKit 的对比。文末附参考文献。


一、UIControl 与 target=nil 的响应者链

1.1 机制

UIControl(如 UIButton、UISlider)使用 addTarget(_:action:for:) 时,若将 target 设为 nil,系统不会在添加时绑定具体对象,而是在事件触发时第一响应者开始,沿 next 查找第一个能响应该 action 的响应者并调用,即 action 沿响应者链寻找 target [1]。编辑菜单(复制/粘贴/剪切)也使用同一机制在链上查找实现 copy(_:)paste(_:)cut(_:) 等的对象。

1.2 Action 方法签名

Action 方法通常为以下形式之一 [2]

  • @IBAction func doSomething()
  • @IBAction func doSomething(sender: UIButton)
  • @IBAction func doSomething(sender: UIButton, forEvent event: UIEvent)

1.3 使用注意

  • Cell 内按钮:按钮在 UITableViewCell/UICollectionViewCell 内时,链的路径是 Cell → 其他 view,不一定会经过 TableView 的 ViewController。若希望由 VC 处理,用 nil target 可能找不到 VC,此时更稳妥的做法是显式指定 target(如 VC)或通过 delegate/callback 把事件交给 VC [3]。Delegate、Block、闭包、函数封装、遍历传递等「传递方式」的对比与选型见 06-响应者链传递方式与编程模式详解
  • 非相邻 VC:通过 present 的 VC 与当前 VC 不一定在一条「相邻」的 next 链上,nil target 不一定能跨 present 边界找到目标,建议用显式 target 或业务层路由。

二、UIGestureRecognizer 与响应者链

2.1 优先级关系

手势识别器在触摸到达视图的 touchesBegan 等之前参与识别。若手势识别成功,可消费触摸,视图的 touches 方法可能不再被调用;若手势识别失败,触摸会交给视图并沿响应者链继续传递 [4]

控件(如 UIButton)可通过 gestureRecognizerShouldBegin(_:) 等让父视图的手势不干扰自己的点击,从而保证按钮的 target-action 优先。

2.2 泳道图:手势、响应者与控件的优先级

flowchart TB
    subgraph 触摸发生
        T1[手指按下]
    end
    subgraph 系统
        S1[hit-test 得到 view]
        S2[手势识别器优先]
    end
    subgraph 手势层
        G1[识别成功?]
        G2[消费事件]
        G3[识别失败]
    end
    subgraph 响应者层
        R1[视图 touches / UIControl]
        R2[沿 next 传递]
    end
    T1 --> S1
    S1 --> S2
    S2 --> G1
    G1 -->|是| G2
    G1 -->|否| G3
    G3 --> R1
    R1 --> R2

2.3 小结

层级 说明
手势识别 先于视图的 touches 参与识别,成功则可消费事件
视图 touches 手势未消费时,由 hit-test view 及其 next 链处理
UIControl 通过内部逻辑与 gesture 的配合,保证点击等行为优先

三、扩大点击区域与事件穿透

3.1 扩大点击区域

视觉较小的按钮或图标,可通过重写 point(inside:with:) 扩大可点击范围(如四周各扩展 20pt),提升可点性 [5]

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let margin: CGFloat = 20
    return bounds.insetBy(dx: -margin, dy: -margin).contains(point)
}

3.2 事件「穿透」到下层

若希望某视图不响应触摸、让触摸落到下层视图,可重写 hitTest(_:with:),在满足条件时返回 nil,则当前视图及其子视图不参与命中,系统会继续用其兄弟或父视图参与 hit-test [6]

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    let hit = super.hitTest(point, with: event)
    // 若希望本视图不拦截,可返回 nil;否则返回 hit
    return shouldPassThrough ? nil : hit
}

商用场景示例:视频播放页上的礼物动画、点赞动效浮层使用 PassThroughView 或重写 hitTest 返回 nil,使点击落到下层进度条、暂停按钮;或活动弹窗关闭后遮罩不拦截,点击空白处关闭。

3.3 手势与按钮共存的完整代码(商用:列表 Cell 内按钮由 VC 处理)

// Cell 内「加购」按钮希望由 ListViewController 处理,用 delegate 传递,避免 nil target 链不到 VC
protocol ProductCellDelegate: AnyObject {
    func productCell(_ cell: ProductCell, didTapAddCart productId: String)
}

class ProductCell: UITableViewCell {
    weak var delegate: ProductCellDelegate?
    private var productId: String = ""
    @objc private func addCartTapped() {
        delegate?.productCell(self, didTapAddCart: productId)
    }
}

class ListViewController: UIViewController, ProductCellDelegate {
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ProductCell", for: indexPath) as! ProductCell
        cell.delegate = self
        cell.configure(productId: items[indexPath.row].id)
        return cell
    }
    func productCell(_ cell: ProductCell, didTapAddCart productId: String) {
        // 加购、埋点、弹 toast 等
        cartService.add(productId: productId)
    }
}

3.4 应用场景分类(思维导图)

mindmap
  root((应用场景))
    列表与 Cell
      Cell 内按钮 delegate
      扩大热区 小图标
    浮层与遮罩
      穿透 hitTest 返回 nil
      手势与按钮共存
    编辑与输入
      编辑菜单 copy paste
      自定义 inputView
    手势与链
      手势优先 再响应者链
      gestureRecognizerShouldBegin

四、SwiftUI 与 UIKit 的对比(简要)

SwiftUI 没有 UIKit 式的「响应者链」[7][8]

  • 使用 Gesture 修饰符在视图上声明手势,由系统做 hit-test 与手势竞争,不会把事件沿「next」链向上冒泡。
  • 可点击区域由 framecontentShape 决定;.allowsHitTesting(false) 相当于从 hit-test 中排除。
  • 多手势的优先级通过 highPriorityGesturesimultaneousGesture 等显式组合,而非依赖「链」传递。

UIKit 与 SwiftUI 混用时,需注意:SwiftUI 宿主视图内的交互由 SwiftUI 管理;嵌入的 UIKit 视图仍走 UIKit 的 hit-test 与响应者链。


五、应用场景小结

场景 涉及机制 建议
按钮/控件由上层 VC 统一处理 target-action + nil target → 响应者链 Cell 内或复杂层级下优先显式 target 或 delegate
编辑菜单(复制/粘贴) 链上查找 canPerformAction / target 在合适响应者上实现 copy/paste/cut 等方法
扩大按钮可点区域 point(inside:with:) 重写并扩大 bounds 的「有效」区域
浮层不拦截触摸 hitTest(_:with:) 返回 nil 指定条件下返回 nil 实现穿透
手势与按钮共存 手势识别 vs 响应者 用 gestureRecognizerShouldBegin 等保护控件
自定义键盘/输入条 First Responder + inputView 成为第一响应者并设置 inputView / inputAccessoryView
事件/回调传递方式选型 Delegate / Block / 闭包 / 函数封装 / 遍历 06-响应者链传递方式与编程模式详解

5.1 商用场景速查

场景 做法
电商列表加购/收藏 Cell 内小按钮用 delegate 交给 VC,或扩大热区 + target-action
视频/直播浮层不挡点击 浮层 view 重写 hitTest 在命中自己时返回 nil
活动弹窗遮罩点击关闭 遮罩用 PassThroughView 或 hitTest 返回 nil,按钮在遮罩上方单独处理
设置页开关/列表点击 系统 UITableView 的 didSelect + 响应者链;Cell 内控件用 delegate 更稳
安全输入/自定义键盘 自定义 UITextField 的 inputView,canBecomeFirstResponder = true

参考文献

[1] Using responders and the responder chain to handle events - Controls and the responder chain
[2] UIControl | Apple Developer Documentation
[3] UIControl Target Action event not flowing up the responder chain (Stack Overflow)
[4] Using responders and the responder chain - Gesture recognizers
[5] How to implement point(inside:with:) (Stack Overflow)
[6] Hacking Hit Tests (Khanlou)
[7] SwiftUI Gesture System Internals (DEV)
[8] SwiftUI Hit-Testing & Event Propagation Internals (DEV)

02-主题|事件响应者链@iOS-hitTest与事件传递详解

2026年3月2日 14:13

本文专门讲解 iOS 中事件传递的「确定目标」阶段:hitTest(_:with:)point(inside:with:) 的原理、算法、子视图遍历顺序,以及可响应条件、自定义命中区域和常见图示。与「响应者链」的传递阶段配合理解,可参见 03-响应者链与 nextResponder 详解


一、为什么需要 Hit-Testing

触摸发生时,系统需要确定「触摸点落在哪个视图上」,以便将事件交给该视图并进入响应者链。Hit-testing 即在这一阶段,从窗口根视图开始,沿视图层级向下查找最底层且包含该点的视图,该视图将作为该触摸事件的第一响应者(hit-test view)[1]


二、核心 API

方法 所属 作用
hitTest(_:with:) UIView 在视图树中查找包含指定点的最底层子视图;返回 nil 表示当前视图及其子视图均不接收该点
point(inside:with:) UIView 判断给定点是否在当前视图的 bounds 内(可被重写以扩展或缩小命中区域)

系统从 UIWindow 开始,对根视图调用 hitTest(_:with:),传入触摸点(已转换为该视图坐标系)。视图内部会先调用 point(inside:with:) 判断点是否在自己范围内,再递归对子视图调用 hitTest(_:with:)


三、hitTest 算法与伪代码

3.1 可响应前提

视图要参与 hit-test,通常需同时满足(否则当前分支会被剪掉,返回 nil)[[2]][[3]]:

  • isUserInteractionEnabled == true
  • isHidden == false
  • alpha > 0.01

不满足时,hitTest(_:with:) 直接返回 nil,该视图及其子视图都不会成为命中目标。

3.2 系统 hitTest 逻辑(伪代码)

以下为对系统行为的等价描述,便于理解顺序与剪枝逻辑;实际实现以 Apple 源码为准。

函数 hitTest(point, event) -> UIView?:
    若 当前视图 不满足可响应条件(userInteractionEnabled / hidden / alpha):
        返回 nil

    若 pointInside(point, event) 为 false:
        返回 nil   // 点不在当前视图内,整棵子树不再查找

    // 按子视图「从后往前」顺序遍历(逆序:最后加入的、Z 轴更靠前的先测)
    对 每个 subview 从 subviews.last 到 subviews.first:
        candidate = subview.hitTest( 将 point 转换到 subview 坐标系, event )
        若 candidate != nil:
            返回 candidate   // 找到第一个有返回值的子视图即停止

    若没有子视图命中:
        返回 self   // 点在自己范围内且没有更底层子视图命中,则自己就是 hit-test view

要点:

  1. 先判 pointInside:点不在当前视图内则直接返回 nil,整棵子树被剪枝。
  2. 子视图逆序:按 subviews 从后往前遍历,即** Z 轴靠前的子视图优先**,与视觉上的「最上层」一致。
  3. 第一个非 nil 即返回:找到第一个返回非 nil 的子视图就停止,该子视图即为 hit-test view。

3.3 point(inside:with:) 默认行为

默认实现等价于:判断点是否落在视图的 bounds 内(通常不考虑 subview 的超出部分;且若父视图 clipsToBounds == true,超出父视图 bounds 的子视图区域不会参与父视图的 hit-test,因为点不在父视图 bounds 内会先被剪枝)[[1]]。

函数 pointInside(point, event) -> Bool:
    返回 CGRectContainsPoint(self.bounds, 将 point 转换到当前视图的 bounds 坐标系)

可重写以扩大或缩小可点击区域(如圆形按钮、不规则形状、透明区域穿透等)。


四、事件传递流程(自上而下)

4.1 流程图

flowchart TB
    A[触摸发生] --> B[UIWindow 收到事件]
    B --> C[对根 view 调用 hitTest:withEvent:]
    C --> D{pointInside 为 true?}
    D -->|否| E[返回 nil,该分支结束]
    D -->|是| F[按逆序遍历子视图]
    F --> G[对子视图递归 hitTest]
    G --> H{有子视图返回非 nil?}
    H -->|是| I[返回该子视图 作为 hit-test view]
    H -->|否| J[返回 self]
    I --> K[该 view 成为触摸的 first responder]
    J --> K

4.2 泳道图:Hit-Test 各角色协作

flowchart TB
    subgraph 用户
        U1[手指触摸屏幕]
    end
    subgraph 系统_UIApplication
        S1[事件入队]
        S2[派发至 keyWindow]
    end
    subgraph 系统_UIWindow
        W1[hitTest 根 view]
        W2[得到 hit-test view]
    end
    subgraph 视图层级
        V1[pointInside 判断]
        V2[逆序遍历子视图]
        V3[递归 hitTest]
        V4[返回最终 view]
    end
    U1 --> S1
    S1 --> S2
    S2 --> W1
    W1 --> V1
    V1 --> V2
    V2 --> V3
    V3 --> V4
    V4 --> W2

4.3 Hit-Test 知识结构(思维导图)

mindmap
  root((Hit-Test))
    入口
      UIWindow 根视图
      hitTest:withEvent:
    条件
      userInteractionEnabled
      hidden / alpha
      pointInside
    遍历
      子视图逆序
      Z 轴优先
    结果
      hit-test view
      first responder
    自定义
      扩大热区
      穿透
      不规则区域

五、子视图顺序与 Z 轴

子视图在 subviews 数组中的索引越大,在 hit-test 时越被遍历,因此后加入的、索引更大的子视图会优先被命中,与它们在屏幕上的「盖在上面」一致。若两个子视图重叠,则上面那一层会先被 hitTest 到并成为 hit-test view。

flowchart LR
    subgraph 视图层级
        V[父视图]
        V --> A[子视图 A index 0]
        V --> B[子视图 B index 1]
        V --> C[子视图 C index 2]
    end
    subgraph hitTest 顺序
        C --> B
        B --> A
    end

六、clipsToBounds 与命中

  • pointInside 只判断点是否在当前视图的 bounds 内。
  • 若父视图设置了 clipsToBounds = true,子视图超出父视图 bounds 的部分会被裁剪掉显示,但 hit-test 仍按 bounds 判断:若触摸点落在父视图 bounds 外(即使落在子视图的 frame 内),父视图的 pointInside 会返回 false,整棵子树不会参与命中 [[1]]。
  • 因此:子视图若超出父视图 bounds 且父视图 clipsToBounds,超出部分在默认实现下无法被 hit-test 命中,除非在父视图层重写 point(inside:with:)hitTest(_:with:) 做特殊处理。

七、自定义 hitTest / pointInside 的常见用法

需求 做法
扩大点击区域 重写 point(inside:with:),对中心区域做扩展(如上下左右各扩展 44pt)
透明区域不响应 重写 point(inside:with:),根据像素透明度返回 false
让触摸「穿透」到下层 重写 hitTest(_:with:),在特定条件下返回 nil,使当前视图不参与命中
指定子视图优先 重写 hitTest(_:with:),自定义遍历顺序或强制返回某子视图

示例(扩大点击区域):

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let inset: CGFloat = -20
    return bounds.insetBy(dx: inset, dy: inset).contains(point)
}

商用场景示例:商品列表 Cell 内「加购」「收藏」等小图标,视觉约 24pt,为提升点击率将热区扩大到 44pt,重写该图标的容器 view 或子类的 point(inside:with:) 即可。

穿透示例(浮层不拦截、点击落到下层):

/// 用于半透明遮罩:触摸不消费,交给下层视图(如背后的列表、按钮)
class PassThroughView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hit = super.hitTest(point, with: event)
        return hit == self ? nil : hit  // 若命中自己则返回 nil,让下层接收
    }
}

商用场景示例:活动弹窗关闭后残留半透明遮罩,希望点击遮罩空白处能穿透到下层(如关闭按钮、跳过);或直播/视频上的礼物动画层不拦截点击,让下层进度条、点赞可点。

Swift 完整示例:可复用的「扩大热区」UIView 子类(适用于任意按钮/图标):

/// 将子视图的可点击区域向外扩展,不改变视觉 frame
final class ExpandHitAreaView: UIView {
    var hitAreaInset: UIEdgeInsets = .zero  // 负值表示扩大,如 (-10,-10,-10,-10)
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        bounds.inset(by: hitAreaInset).contains(point)
    }
}
// 使用:将按钮包在 ExpandHitAreaView 内,设置 hitAreaInset = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)

八、与响应者链的衔接

hit-test 得到的是触摸事件的第一响应者(某个 UIView)。触摸事件会先发给该视图(及其上的手势识别器);若视图未处理或未实现 touchesBegan 等,事件会沿 nextResponder 向上传递。因此:

  • 阶段一(本文):hit-test,自顶向下,确定「谁被点中」。
  • 阶段二:响应者链,自底向上,确定「谁处理」。详见 03-响应者链与 nextResponder 详解

参考文献

[1] Using responders and the responder chain to handle events - Determine which responder contained a touch event
[2] Event handling for iOS - hitTest:withEvent: and pointInside:withEvent:
[3] HitTest and UIResponder in iOS (Medium)

OpenClaw 不错,但我好像没有那么需要 - 肘子的 Swift 周报 #125

作者 Fatbobman
2026年3月2日 22:00

3 月 1 日早上,我收到了 OpenClaw 发来的信息。这是我在安装它之后设置的一个定时任务:每个月的第一天,向我汇报过去一个月它为我执行过的主要任务汇总。看着汇总中寥寥数语,我不由得陷入了思考——现阶段,我似乎真的还不需要一个个人智能体。说实话,如果不是它昨天发来的这条消息,我几乎已经忽略了它的存在。

12-主题|内存管理@iOS-Option与内存优化技术

本文介绍与内存相关的几类优化与极限管理Option/位运算共用内存(多选项共用一个整数)、内存的极限管理(低内存策略与约束)、Copy-on-Write(写时拷贝)Tagged Pointer。与 01-内存五大分区11-深浅拷贝与内存07-实践与常见问题 配合阅读。


一、Option 与位运算共用内存

1.1 概念

  • 将多个布尔或选项压缩到一个整数的不同上,通过位运算读写,实现「多个开关/状态共占一块内存」;在 C/OC 中常用 NS_OPTIONS位域(bitfield),在 Swift 中对应 OptionSet
  • 内存:N 个独立 BOOLbool 可能占 N 个字节(甚至对齐后更多);用一个整型的若干位表示,只需 1 个整型(如 4 或 8 字节),在选项较多或实例数量巨大时显著节省内存并利于缓存。

1.2 NS_OPTIONS / 位运算示例(Objective-C)

// 多个「选项」共用一个整型,每位表示一种开关
typedef NS_OPTIONS(NSUInteger, ViewOptions) {
    ViewOptionsNone     = 0,
    ViewOptionsHidden   = 1 << 0,   // 1
    ViewOptionsDisabled = 1 << 1,   // 2
    ViewOptionsSelected = 1 << 2,   // 4
    ViewOptionsLoading  = 1 << 3,   // 8
};

// 使用:一个 NSUInteger 存下所有选项
ViewOptions opts = ViewOptionsHidden | ViewOptionsSelected;

// 判断
BOOL isHidden = (opts & ViewOptionsHidden) != 0;

// 置位 / 清位
opts |= ViewOptionsLoading;
opts &= ~ViewOptionsDisabled;
  • 上述 ViewOptions 只占 一个 NSUInteger(8 字节),即可表示 64 个独立布尔;若用 64 个 BOOL 属性,会占用更多内存且不利于缓存。

1.3 位域(bitfield)共用内存

// 结构体内用位域:多个成员共占一个或多个整型
struct PackedFlags {
    unsigned int visible  : 1;  // 1 bit
    unsigned int enabled  : 1;
    unsigned int selected : 1;
    unsigned int loading  : 1;
    unsigned int reserved : 4;  // 预留
};  // 整体可仅占 4 字节(一个 unsigned int)
  • 多个成员共享同一整型内存,适合配置、状态、权限等密集布尔/小范围枚举,在大量实例(如 Cell、配置项)时减少内存占用。

1.4 典型场景

场景 说明
UI 状态 如 hidden、enabled、selected、loading 等用 NS_OPTIONS 或 OptionSet 存为一个整数
权限/能力 读、写、执行等用位表示,一个整数表示一组权限
配置/特性开关 大量配置项用位域或 OptionSet,减少结构体/对象体积
网络/解析标志 协议中的 flags 字段,多位表示多种含义,共用内存

二、内存的极限管理

2.1 目标与场景

  • 内存紧张(低内存设备、后台、系统压力大)时,通过主动释放、限制缓存、延迟加载等手段,把占用控制在系统允许范围内,避免被系统杀掉或 OOM。
  • 07-实践与常见问题 中的「内存警告」「音视频/图层场景」配合使用。

2.2 策略要点

策略 说明
响应 didReceiveMemoryWarning 释放可重建的缓存(图片、数据)、释放非当前页大对象;主线程不阻塞,异步释放。
缓存上限与淘汰 图片/数据缓存设置 maxCount / maxCost,LRU 等淘汰;避免无界增长。
后台释放 进入后台时释放非必要资源(解码器、大缓冲、预览图),回到前台再按需加载。
按需加载 / 流式 大列表、大文件不一次性进内存;分页、流式读取、大图降采样。
@autoreleasepool 循环中大量临时对象用 @autoreleasepool {} 控制峰值,见 [05-AutoreleasePool与RunLoop](05-主题 内存管理@iOS-AutoreleasePool与RunLoop.md)。
内存映射 大文件用 mmap 等映射访问,减少常驻 RSS;注意映射大小与释放时机。

2.3 极限下的注意

  • 不保留可重建数据:能重新下载、重新计算的就不要在内存里常驻。
  • 控制单页/单模块占用:列表、相册、音视频播放等设定上限,避免单场景吃满内存。
  • Instruments:用 Allocations、VM Tracker、Leaks 做「极限场景」压测(反复进入退出、后台、低内存模拟),观察峰值与泄漏。

三、Copy-on-Write(写时拷贝)

3.1 概念

  • Copy-on-Write(COW):多个逻辑上的「副本」在未修改前共享同一份底层存储;仅在某一方发生写操作时才为该方复制出一份新存储,再修改,从而避免「一赋值就整块拷贝」的开销。
  • 与深浅拷贝的关系:浅拷贝是「多引用、共享子对象」;COW 是「多引用、共享存储,写时才真正拷贝」,在保证值语义的前提下减少内存与 CPU 消耗。详见 11-深浅拷贝与内存

3.2 Swift 中的 COW

  • Array、Dictionary、Set、String 等值类型在 Swift 标准库中实现了 COW:赋值时不立即复制底层 buffer,而是共享;首次发生写操作时,若检测到 buffer 被多处引用(非唯一引用),则先复制 buffer 再写。
  • 实现要点:内部持有一个引用类型的 buffer;写前通过 isKnownUniquelyReferenced(或等价机制)判断是否唯一引用,若不唯一则 copy buffer 再写。
  • 效果:大量「只读共享」的赋值与传参几乎零拷贝成本;只有写时才付出拷贝代价,适合读多写少的集合与字符串。

3.3 与内存的关系

  • 省内存:未修改的「副本」不占额外存储,仅多一个指向同一 buffer 的引用。
  • 写时峰值:在共享的 buffer 上首次写入会触发一次拷贝,此时有短暂的内存与 CPU 开销;若写非常频繁且共享多,需注意是否适合用 COW 结构。
  • 自定义值类型:Swift 不会自动为自定义 struct 实现 COW,若需要需自己维护「内部引用 + 写时复制」逻辑。

3.4 流程图(概念)

flowchart LR
    A[赋值/传参] --> B{写操作?}
    B -->|否| C[继续共享 buffer]
    B -->|是| D{唯一引用?}
    D -->|是| E[直接写]
    D -->|否| F[复制 buffer 再写]

四、Tagged Pointer

4.1 概念

  • Tagged Pointer 是 Apple 在 64 位 架构下的一种优化:把「小对象」的数据与类型信息直接编码进指针值本身,而不在堆上分配对象;该「指针」并不是指向堆地址,而是即是指针也是数据
  • 内存:不占用,不参与引用计数(retain/release 对 Tagged Pointer 为 no-op);仅占一个指针宽度(8 字节),无额外分配、无 isa、无引用计数块,极限节省小对象的内存与调用开销。

4.2 原理(64 位简要)

  • 64 位下对象指针通常 16 字节对齐,低 4 位恒为 0;系统用最高位或最低位(依平台而定,如 ARM64 常用最高位)作为 tag,表示「这是 Tagged Pointer」。
  • 其余位中:若干位表示类型(如 NSNumber、NSString、NSDate 等),其余位存数据(如小整数、短字符串的编码)。
  • 运行时通过「解 tag + 类型 + 数据位」还原出逻辑上的「对象」,不访问堆,不触发 retain/release。

4.3 典型类型与约束

类型 说明
NSNumber 小整数、部分浮点数可直接存进指针,不分配堆对象。
NSString 较短字符串(如 ASCII 或少量字符)在较新系统上可能用 Tagged Pointer;更长则仍为堆上分配。
NSDate 部分小对象类型在系统实现中可能使用 Tagged Pointer。
  • 约束:能编码进指针的数据量有限(几十 bit),仅适用于「小」数据;大数、长字符串、复杂对象仍走普通堆分配。

4.4 对内存管理的影响

  • 无堆分配:Tagged Pointer 不占堆,不增加 Allocations 中的对象数。
  • 无引用计数:对 Tagged Pointer 发 retain/release 会被识别并忽略,不会造成过度释放或泄漏(从引用计数角度)。
  • 不可假设地址:不能把 Tagged Pointer 当普通指针做指针运算或与 C 内存接口混用;判断是否 Tagged Pointer 可用运行时 API(如 objc_isTaggedPointer)。

4.5 小结对比

维度 普通堆对象 Tagged Pointer
存储位置 指针值本身(无堆)
引用计数 无(no-op)
内存占用 对象头 + 实例 + 指针 仅 8 字节指针
适用 任意对象 小数据(小整数、短字符串等)

五、思维导图

mindmap
  root((Option 与内存优化))
    Option 位运算
      NS_OPTIONS OptionSet
      位域 共用整型
    内存极限管理
      内存警告 缓存上限
      后台释放 按需加载
    CopyOnWrite
      写时复制 共享 buffer
      Swift 集合 isKnownUniquelyReferenced
    Tagged Pointer
      小对象编码进指针
      无堆 无引用计数

参考文献

11-主题|内存管理@iOS-深浅拷贝与内存

本文介绍 浅拷贝(Shallow Copy)深拷贝(Deep Copy) 的含义、在 Objective-C / Foundation 中的表现、与内存的关系(引用计数、新对象分配、共享与独立),以及 NSCopying、属性 copy、Swift 值类型与写时拷贝。前置知识见 03-引用计数与MRC详解04-ARC详解


一、浅拷贝与深拷贝的定义

1.1 概念

类型 含义 内存上的表现
浅拷贝 只复制「当前这一层」:得到一个新对象(新指针),但对象内部的元素/子对象仍指向原有的实例。 新对象占用新内存(新引用计数);内部元素复制,多出一份对原元素的引用(引用计数 +1)。
深拷贝 递归复制整棵对象树:当前对象及其内部所有引用到的对象都重新创建一份。 整棵对象树都占用新内存,拷贝前后完全独立,无共享引用。
  • 单层对象(如 NSString、NSData):浅拷贝与深拷贝在「是否共享内容」上的差异,取决于类型是否可变、实现是否共享底层存储(如 copy 后可能共享 buffer,仅引用计数 +1)。
  • 集合类(NSArray、NSDictionary 等):浅拷贝 = 新容器 + 元素仍指向原元素;深拷贝 = 新容器 + 对每个元素再递归 copy,需自行实现或使用 initWithArray:copyItems:YES 等 API。

1.2 与内存、引用计数的关系

  • 浅拷贝:生成一个新对象(容器或包装类),该对象对内部子对象的引用会使这些子对象的引用计数 +1;子对象本身不复制,内存上共享子对象。
  • 深拷贝:生成全新的对象图,每个被拷贝的对象都有新内存、新引用计数;原对象与拷贝无共享,释放一方不影响另一方。

二、Foundation 中的 copy 与 mutableCopy

2.1 常见类型的拷贝语义(概览)

类型 copy mutableCopy 说明
NSString 不可变副本(可能共享存储,引用计数 +1) NSMutableString 不可变 → 不可变 多为浅拷贝;不可变 → 可变 会分配新缓冲
NSMutableString 不可变 NSString(新内存) NSMutableString(浅拷贝) copy 得到不可变,防止外部修改
NSArray 浅拷贝,新数组、元素仍指向原元素 NSMutableArray(浅拷贝) 元素引用计数 +1,元素本身不复制
NSDictionary 浅拷贝 NSMutableDictionary(浅拷贝) 同上
NSData 浅拷贝(可能共享字节) NSMutableData 实现可能共享底层 buffer
自定义类 copyWithZone: 实现决定 mutableCopyWithZone: 决定 可做浅拷贝或深拷贝
  • 上述「浅拷贝」指:容器是新对象,元素仍是原对象引用;对容器增删不影响对方,对元素内容的修改可能影响对方(若元素可变)。

2.2 集合的「单层深拷贝」

  • [[NSArray alloc] initWithArray:array copyItems:YES]:会向每个元素发送 copy,得到新数组 + 一层新元素;若元素本身是集合,其内部不会递归 copy,因此是单层深拷贝,不是递归深拷贝。
  • 真正递归深拷贝需自己实现或使用序列化(如 NSKeyedArchiver)再反序列化,注意性能与内存。

三、NSCopying 与 NSMutableCopying

3.1 协议

  • NSCopying:实现 - (id)copyWithZone:(NSZone *)zone;调用 [obj copy] 时最终走 copyWithZone:
  • NSMutableCopying:实现 - (id)mutableCopyWithZone:(NSZone *)zone;调用 [obj mutableCopy] 时走 mutableCopyWithZone:

3.2 拷贝与内存管理

  • ARC:copy/mutableCopy 返回的对象由调用方持有(引用计数 +1),遵循 ARC 规则。
  • MRC:返回的对象为调用方拥有,需在适当时机 release 或 autorelease;在 copyWithZone: 里返回的对象应为 +1 所有权(alloc 或 copy 出来的)。

3.3 自定义类的浅拷贝与深拷贝示例(概念)

// 浅拷贝:新对象,但 property 仍指向原对象(retain/copy 使引用计数 +1)
- (id)copyWithZone:(NSZone *)zone {
    MyClass *copy = [[MyClass allocWithZone:zone] init];
    copy.name = self.name;           // 若 name 是 copy 属性,会 [self.name copy]
    copy.child = self.child;         // 若 child 是 strong,仅 retain,共享同一 child
    return copy;
}

// 深拷贝:递归复制子对象
- (id)copyWithZone:(NSZone *)zone {
    MyClass *copy = [[MyClass allocWithZone:zone] init];
    copy.name = [self.name copy];
    copy.child = [self.child copy];  // 子对象也 copy,完全独立
    return copy;
}
  • 选择浅拷贝还是深拷贝取决于业务:共享子对象可省内存但需注意多线程/可变性;完全独立则省心但内存与耗时更大。

四、属性的 copy 与内存

4.1 copy 属性

  • 声明为 @property (copy) NSString *name 时,setter 会对传入值调用 copy,即持有的是「传入对象的拷贝」的所有权;若传入的是 NSMutableString,拷贝后得到不可变 NSString,避免外部在别处修改导致当前实例被意外改动。
  • Block 使用 copy 属性:Block 的 copy 会把栈 Block 拷贝到堆(见 10-Block内存管理),并持有该堆 Block;与「深浅拷贝」中的「拷贝」语义不同,但都涉及「新对象 + 引用计数」。

4.2 深浅拷贝与属性

  • 若属性是 集合(如 NSArray),用 copy 只是对集合本身做浅拷贝(新容器、元素仍共享);若希望「外部传入的数组」与内部完全隔离,要么接受浅拷贝(元素共享),要么在 setter 里做一层 initWithArray:copyItems:YES 或自定义深拷贝,并注意内存与性能。

五、内存注意与选型

5.1 浅拷贝

优点 缺点
省内存、速度快 与原对象共享子对象;若子对象可变,一边修改会影响另一边;多线程需额外同步

5.2 深拷贝

优点 缺点
完全独立,无共享,线程安全更易控制 内存与 CPU 开销大,递归深拷贝需防循环引用与栈溢出

5.3 何时用哪种

  • 浅拷贝:只关心「多一份容器引用」、元素共享可接受(或元素不可变)时;Foundation 的 copy/mutableCopy 默认多为浅拷贝(容器层)。
  • 深拷贝:需要「完全独立副本」、避免外部修改或跨线程共享可变状态时;可单层深拷贝(copyItems:YES)或自定义递归深拷贝。

六、流程图:浅拷贝与深拷贝的内存关系(概念)

flowchart TB
    subgraph 浅拷贝
        A1[原容器] --> A2[新容器]
        A1 --> A3[元素a]
        A2 --> A3
    end
    subgraph 深拷贝
        B1[原容器] --> B2[新容器]
        B1 --> B3[元素a]
        B2 --> B4[元素a 的副本]
    end

七、Swift 中的「拷贝」与内存

7.1 值类型与引用类型

  • 值类型(struct、enum、基础类型):赋值与传参是拷贝语义(复制一份值);从「不共享同一块堆对象」的角度看,更像「深拷贝」。
  • 引用类型(class):赋值与传参是引用,不产生新对象,仅多一个指针;若要独立副本需显式实现拷贝(如实现 NSCopying 或自定义 copy() 方法)。

7.2 写时拷贝(Copy-on-Write)

  • Array、Dictionary、Set 等是值类型,但底层存储可能共享 buffer;修改时才复制一份(Copy-on-Write),既保证值语义又减少不必要的内存与拷贝开销。
  • 与 OC 的「浅拷贝」不同:Swift 集合的「拷贝」在未修改前可能共享存储,修改时再分配新内存,由标准库保证语义正确。COW 原理、Swift 实现要点(如 isKnownUniquelyReferenced)及与内存的关系见 12-Option与内存优化技术 中的「Copy-on-Write」一节。

八、思维导图:深浅拷贝与内存

mindmap
  root((深浅拷贝与内存))
    概念
      浅拷贝 新对象 共享元素
      深拷贝 新对象 递归复制
    引用计数
      浅拷贝 元素 rc+1
      深拷贝 全新对象图
    Foundation
      copy mutableCopy
      集合 copyItems
    NSCopying
      copyWithZone
      自定义浅/深拷贝
    属性 copy
      setter 调 copy
      Block NSString
    Swift
      值类型 拷贝语义
      CopyOnWrite

九、参考文献

10-主题|内存管理@iOS-Block内存管理

本文专门介绍 Objective-C BlockSwift 闭包内存管理:Block 的三种类型(全局/栈/堆)、捕获变量与内存、copy 语义、循环引用 与破除,以及作为属性/参数时的注意点。前置知识见 04-ARC详解06-weak与循环引用


一、Block 是什么(与内存的关系)

  • Block 是 Apple 对 C 语言扩展的闭包:可捕获外部变量、作为对象参与引用计数;在内存上既包含代码(函数指针),也包含捕获的变量(结构体形式),因此既有「存在位置」(栈/堆/全局)也有「对捕获对象的持有关系」。
  • 内存管理 需关注两点:Block 对象本身 的分配与释放(栈 block / 堆 block / 全局 block),以及 Block 对捕获变量(尤其是 OC 对象) 的强引用/弱引用,避免循环引用与泄漏。

二、Block 的三种类型与内存位置

2.1 类型与存储位置

类型(运行时 isa) 存储位置 产生条件(典型)
NSGlobalBlock 全局区(.data/.text) 未捕获任何外部变量(或仅捕获全局/静态变量)
NSStackBlock 捕获了自动变量(局部变量),且未 copy 到堆(MRC 下常见)
NSMallocBlock 对栈 block 执行 copy,或 ARC 下多数「需要逃逸」的 block 被编译器自动 copy 到堆
  • 全局 Block:不依赖栈帧,无需 copy,可当作单例使用。
  • 栈 Block:随栈帧销毁而失效,若要在作用域外使用(如存为属性、异步回调),必须先 copy 到堆;ARC 下编译器会在赋值给 strong/copy 属性、跨函数传递等场景自动插入 copy。
  • 堆 Block:参与引用计数,由 ARC/MRC 管理;copy 时引用计数 +1,release 时 -1。

2.2 简单判断示例(ARC)

// 无捕获 → 全局 Block(__NSGlobalBlock__)
void (^gBlock)(void) = ^{ NSLog(@"no capture"); };

// 捕获局部变量 → 栈 Block(__NSStackBlock__),若赋给 strong/copy 属性则会被 copy 成堆 Block
int a = 1;
void (^sBlock)(void) = ^{ NSLog(@"%d", a); };
// 赋值给 copy/strong 属性或作为参数传给需要「持有」的 API 时,会变成 __NSMallocBlock__

2.3 MRC 下 Block 的 copy 必要性

  • MRC 下,栈上的 Block 在函数返回后栈帧被回收,若此时 block 已被传给调用方或存到堆对象(如属性),再执行会野指针/未定义行为
  • 因此 MRC 下:凡是需要跨作用域保留的 block,必须对其执行一次 copy,将栈 block 拷贝到堆上,得到 NSMallocBlock,之后按普通 OC 对象做 retain/release;用完后要对堆 block 做 release(或 autorelease)。
  • ARC 下:编译器在「赋值给 strong/copy 属性、作为参数传给会保留 block 的 API」等场景自动插入 copy,一般无需手写 [block copy]

三、Block 捕获变量与内存

3.1 捕获方式概览

捕获对象/变量 默认行为(OC 对象) 对引用计数的影响
局部 OC 对象(自动变量) 强引用(strong) Block 被 copy 到堆时,会 retain 被捕获的对象;block 释放时 release
局部标量(int、结构体等) 值拷贝 不涉及引用计数
__block 修饰的变量 生成结构体,block 与外部共享 若 __block 变量指向 OC 对象,需注意 MRC/ARC 下 retain 行为;__block 可改写
__weak 修饰的对象 弱引用 Block 不持有该对象,不增加引用计数,可避免循环引用

3.2 对象捕获与循环引用

  • Block 若强引用了某个对象 A(如直接使用 self),而 A 又强引用了该 block(如 block 被 A 的 strong/copy 属性持有),则形成 self → block → self 的循环,两者都不会释放。
  • 解决:在 block 外使用 __weak typeof(self) wself = self,block 内使用 wself,这样 block 对 self 是弱引用;若在 block 执行过程中担心 self 被释放,可在 block 内再用 __strong typeof(wself) sself = wself 强引用一次(仅限 block 执行期),避免执行到一半 self 被置 nil。详见 06-weak与循环引用

3.3 __block 与内存(简述)

  • __block 使局部变量在 block 内可被修改,编译器会生成一个包装结构,block 捕获的是该结构;若 __block 变量指向 OC 对象,在 ARC 下通常不会造成 block 对对象的强引用(对象存在 __block 结构里),但若在 block 内给该变量赋新值,会涉及旧值 release、新值 retain。MRC 下 __block 不会自动 retain 对象,需自行管理。
  • 历史上用 __block 打破循环(__block self + block 内置 nil)的写法在 ARC 下不推荐,应使用 __weak 打破循环。

四、Block 作为属性、参数与返回值

4.1 属性声明

属性修饰 说明
copy 设值时对 block 执行 copy;MRC 时代推荐,ARC 下 strong 与 copy 对 block 效果类似(都会 copy 到堆),习惯上仍常用 copy 表达「这是 block」的语义。
strong ARC 下与 copy 类似,赋值时也会把栈 block copy 到堆并强引用。
  • Block 属性应避免用 assign(栈 block 离开作用域后失效,会野指针)。

4.2 作为参数与返回值

  • 作为参数:若 API 会保存 block(如延迟执行、存入数组),API 内部应对传入的 block 做 copy(或由 ARC 在传入时保证是堆 block);调用方传栈 block 时,由被调用方 copy 到堆是常见约定。
  • 作为返回值:返回 block 时,若希望调用方在函数返回后仍能使用,应返回堆上的 block(MRC 下 return 前对 block 做 copy/autorelease;ARC 下编译器会根据返回类型自动处理)。

五、ARC 与 MRC 下 Block 内存小结

场景 MRC ARC
栈 block 需跨作用域使用 必须对 block 执行 copy,用完后 release 编译器在赋值给 strong/copy、传参等场景自动 copy
Block 属性 copy,setter 里对 block copy、对旧 block release copystrong 均可,都会导致 copy 到堆并强引用
Block 内引用 self 避免 self→block→self:用 __weak 或 __block+置 nil __weak self,必要时 block 内 __strong 一次
Block 捕获的 OC 对象 copy 到堆时 block 会 retain 捕获的对象;block release 时 release 这些对象 同左,由编译器插入

六、流程图:Block 从创建到释放(概念)

flowchart LR
    A[定义 Block] --> B{是否捕获自动变量?}
    B -->|否| C[__NSGlobalBlock__ 全局]
    B -->|是| D[__NSStackBlock__ 栈]
    D --> E[赋值给 strong/copy 或 传参]
    E --> F[copy 到堆 __NSMallocBlock__]
    F --> G[Block 被 release]
    G --> H[对捕获对象 release]

七、Swift 闭包与内存

  • Swift 闭包 与 OC Block 语义对应:闭包会捕获外部变量,默认对类对象是强引用
  • 循环引用:若对象强引用闭包,闭包内又使用了 self(或捕获了 self),则形成循环;解决方式为在闭包捕获列表中写 [weak self][unowned self](后者在 self 一定不会先于闭包释放时使用,否则会野指针)。
  • @escaping:标记闭包会「逃逸」出当前函数(如异步回调),编译器会按需将闭包拷贝到堆上,与 OC 中「block 被 copy 到堆」对应。

八、思维导图:Block 内存管理知识结构

mindmap
  root((Block 内存管理))
    三种类型
      全局 Block 无捕获
      栈 Block 捕获未 copy
      堆 Block copy 后
    捕获与引用
      对象默认强引用
      __weak 破循环
      __block 可改写
    属性与生命周期
      copy/strong 属性
      MRC 需手写 copy
      ARC 自动 copy
    循环引用
      self → block → self
      weak self strong self

九、参考文献

09-主题|内存管理@iOS-Category与关联对象内存管理

本文介绍 Objective-C Category(分类) 与内存的关系,以及通过 关联对象(Associated Objects) 在 Category 中「挂载」数据时的内存管理:关联策略(policy)、释放时机、循环引用与最佳实践。前置知识见 04-ARC详解06-weak与循环引用


一、Category 与内存的关系

1.1 Category 是什么

  • Category 用于在不修改原类的前提下,为已有类添加方法(以及通过关联对象间接添加「属性」式的存储)。
  • Category 不能直接添加实例变量(ivar),因此不会改变类实例的内存布局sizeof;实例大小由原类及其子类的 ivar 决定。

1.2 对内存管理的影响

维度 说明
实例大小 Category 不增加实例占用,无需从「对象体积」角度做特殊内存管理。
方法实现 Category 中的方法若创建或持有对象,仍遵循 ARC/MRC 规则(谁持有谁释放、避免循环引用)。
「属性」存储 若在 Category 中通过 关联对象 模拟属性,则关联的 value 的持有方式association policy 决定,需正确设置以避免泄漏或野指针。

下文重点说明关联对象的内存语义与使用注意。


二、关联对象(Associated Objects)简述

2.1 作用

  • 不增加 ivar 的前提下,把键值对绑在某个对象上:主对象被释放时,运行时会自动释放其关联的 value(按 policy 做 release 等)。
  • 常用于在 Category 中为已有类添加「存储型属性」、或为任意对象挂载扩展数据。

2.2 API(Objective-C 运行时)

// 设置:object 为主对象,key 为键,value 为值,policy 为关联策略
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy);

// 获取
id objc_getAssociatedObject(id object, const void *key);

// 移除(将 value 设为 nil 即可,会按 policy 释放原 value)
objc_setAssociatedObject(object, key, nil, policy);

三、关联策略(policy)与内存管理

3.1 常用策略对照表

策略常量 语义(对 value 的持有方式) 适用场景
OBJC_ASSOCIATION_RETAIN 强引用(retain),主对象释放时对 value release 普通 OC 对象属性(类似 strong)
OBJC_ASSOCIATION_COPY 拷贝后强引用(copy),主对象释放时对拷贝 release 字符串、block 等需拷贝的类型
OBJC_ASSOCIATION_ASSIGN 不持有(assign),主对象释放时不对 value 做 release 基本类型、或「弱引用」场景(注意野指针)
OBJC_ASSOCIATION_RETAIN_NONATOMIC 同 RETAIN,非原子 性能敏感、不需原子性时
OBJC_ASSOCIATION_COPY_NONATOMIC 同 COPY,非原子 同上

3.2 与 ARC 属性修饰符的对应

若属性声明为 关联时建议 policy
strong(对象) OBJC_ASSOCIATION_RETAIN
copy(block/NSString) OBJC_ASSOCIATION_COPY
assign / weak OBJC_ASSOCIATION_ASSIGN(assign 不保证置 nil,若为对象有野指针风险;true weak 需运行时支持,关联对象常用 ASSIGN 存 weak 包装或非持有)

3.3 释放时机

  • 主对象 dealloc 时,运行时会自动对所有关联的 value 按各自 policy 执行 release(或等效操作),无需在 dealloc 里手动 objc_setAssociatedObject(..., nil, ...) 或单独 release。
  • 若在业务上希望提前解除某条关联,可主动 objc_setAssociatedObject(object, key, nil, policy),原 value 会按 policy 被释放。

四、Category 中「属性」的常见写法与内存

4.1 强引用存储(RETAIN)

// Category 中为 NSObject 添加一个“强引用”属性
static const void *kMyKey = &kMyKey;

- (void)setMyProperty:(id)obj {
    objc_setAssociatedObject(self, kMyKey, obj, OBJC_ASSOCIATION_RETAIN);
}
- (id)myProperty {
    return objc_getAssociatedObject(self, kMyKey);
}
  • 内存:set 时对新 value retain、对旧 value release;主对象 dealloc 时自动 release 当前 value,无泄漏。
  • 注意:若 myProperty 内部又强引用主对象(如 block 捕获 self),会循环引用,需用 weak 打破(见下)。

4.2 拷贝存储(COPY,如 block)

- (void)setMyBlock:(void (^)(void))block {
    objc_setAssociatedObject(self, kBlockKey, block, OBJC_ASSOCIATION_COPY);
}
  • Block 常用 COPY,与属性 copy 一致;主对象释放时会对拷贝的 block release。

4.3 弱引用 / 不持有(ASSIGN)与循环引用

  • 若用 OBJC_ASSOCIATION_ASSIGN 存一个对象指针,主对象不会持有该对象;但主对象 dealloc 时不会把该指针置 nil,若外部未持有,可能产生野指针
  • 循环引用:主对象 A 通过 RETAIN 关联了对象 B,B 又强引用了 A → 双方都不释放。解决办法:让 B 对 A 使用 weak(若 B 是自定义类可改);或 A 不通过 RETAIN 关联 B,改用 ASSIGN + 弱引用包装(需注意生命周期与野指针)。
  • Category 中若「属性」是 delegate 或会反向引用 self 的对象,应避免用 RETAIN 持有该对象,可考虑 ASSIGN 存 weak 包装或不在 Category 里存该引用。

五、流程图:关联对象生命周期

flowchart LR
    A[主对象存在] --> B[setAssociatedObject value policy]
    B --> C[value 被 retain/copy 等]
    A --> D[主对象 dealloc]
    D --> E[运行时按 policy 释放所有关联 value]
    E --> F[value 引用计数减一 或 置空]

六、小结与最佳实践

场景 建议
Category 中存普通 OC 对象 使用 OBJC_ASSOCIATION_RETAIN(或 RETAIN_NONATOMIC)。
Category 中存 block / 需拷贝类型 使用 OBJC_ASSOCIATION_COPY
不持有、仅赋值指针(如 delegate) 可用 OBJC_ASSOCIATION_ASSIGN,注意主对象释放后不置 nil,避免野指针。
避免循环引用 不在 Category 中用 RETAIN 关联「会强引用主对象」的对象;或对方对主对象使用 weak。
释放 主对象 dealloc 时关联会自动清理,一般无需在 dealloc 里手动移除。

参考文献

08-主题|内存管理@iOS-内存对齐

本文介绍 内存对齐(Memory Alignment) 的概念、为何需要对齐、结构体内存对齐 的规则与示例,以及在 iOS/ARM64 下的典型约定。与「内存五大分区」中数据在栈、堆、全局区的布局密切相关,见 01-主题|内存管理@iOS-内存五大分区


一、什么是内存对齐

1.1 定义

  • 内存对齐:数据在内存中的起始地址满足一定约束,通常是「地址为自身所占字节数的整数倍」(或按平台规定的对齐值)。
  • 例如:4 字节的 int 在多数平台上需** 4 字节对齐**(地址为 4 的倍数);8 字节的 double8 字节对齐(地址为 8 的倍数)。

1.2 为什么需要对齐

原因 说明
CPU 访问效率 许多 CPU 对未对齐访问有性能惩罚或需多次总线访问;对齐后可按固定步长、单次或更少次数访问。
硬件与 ABI 要求 ARM、x86 等架构对某些类型有对齐要求;未对齐访问在部分平台可能触发异常(如 ARM 未对齐访问可配置为 fault)。
以空间换时间 通过填充(padding) 满足对齐,会多占一些字节,但换来稳定、高效的访问。

二、基本类型的对齐(典型值)

以下为 64 位 iOS/ARM64 下常见类型的典型对齐与大小(具体以 ABI 与编译器为准):

类型 大小(字节) 典型对齐(字节)
char / bool 1 1
short 2 2
int 4 4
long / 指针(64 位) 8 8
float 4 4
double 8 8
long double 8 或 16 8 或 16

平台约定:iOS 64 位(ARM64)下,编译器常采用 8 字节 作为结构体整体对齐的上限之一(即结构体大小与起始地址常为 8 的倍数);32 位下多为 4 字节。


三、结构体内存对齐规则

3.1 三条常见规则

  1. 成员对齐:结构体第一个成员的偏移为 0;后续成员的起始偏移 = 该成员自身对齐值的整数倍,不足则插入 padding
  2. 嵌套结构体:若成员是结构体,该成员的起始偏移 = 其内部最大成员对齐值的整数倍(即嵌套结构体按自身「最严格」对齐要求对齐)。
  3. 整体对齐:结构体的总大小 = 其内部最大成员对齐值的整数倍;末尾不足则补足,以便结构体数组时每个元素仍对齐。

3.2 流程图:计算结构体布局(伪流程)

flowchart TB
    A[遍历每个成员] --> B[当前偏移 是 该成员对齐的整数倍?]
    B -->|否| C[补 padding 到满足]
    B -->|是| D[放置该成员]
    C --> D
    D --> E[偏移 += 成员大小]
    E --> A
    F[所有成员放完] --> G[总大小 是 最大成员对齐的整数倍?]
    G -->|否| H[末尾补 padding]
    G -->|是| I[得到 sizeof]
    H --> I

四、示例:结构体大小与 padding

4.1 C / Objective-C 示例

// 假设 64 位:指针 8 字节、int 4 字节、char 1 字节
struct Example1 {
    double a;   // 8 字节,偏移 0,[0-7]
    char b;     // 1 字节,偏移 8,[8]
    int c;      // 4 字节,需 4 对齐,故偏移 12,[12-15]
    short d;    // 2 字节,偏移 16,[16-17]
};              // 最大成员对齐 8,总大小需 8 的倍数:18 → 24,末尾补 6 字节
// sizeof(Example1) == 24
成员 大小 对齐 起始偏移 说明
a 8 8 0 第一个成员
b 1 1 8 无 padding
c 4 4 12 偏移 9、10、11 不满足 4 对齐,补 3 字节
d 2 2 16 无 padding
(尾部) 18→24 总大小凑成 8 的倍数

4.2 成员顺序对大小的影响

同一批成员、顺序不同会导致 padding 不同,从而总大小不同

struct Compact {
    double a;   // 0-7
    int b;      // 8-11
    int c;      // 12-15
    char d;     // 16
};              // 总大小 17 → 对齐 8 → 24 字节(末尾补 7)

struct Sparse {
    char a;     // 0
    double b;   // 需 8 对齐 → 8-15,前补 7
    int c;      // 16-19
};              // 总大小 20 → 对齐 8 → 24 字节

实践建议:若需节省结构体占用,可将大类型放前、小类型集中,减少中间 padding。


五、Swift 中的内存布局与对齐

5.1 MemoryLayout

  • MemoryLayout<T>.size:类型 T 的实际占用字节数(不含尾部为数组元素对齐而留的 padding)。
  • MemoryLayout<T>.stride:在连续存储(如数组)中,相邻两个 T 的起始地址之差,即「对齐后的大小」。
  • MemoryLayout<T>.alignment:类型 T 的对齐要求(字节数)。

5.2 示例

struct SHPerson {
    var age: Int    // 8 字节
    var weight: Int // 8 字节
    var sex: Bool   // 1 字节
}
// size  = 17(实际成员占用)
// stride = 24(8 字节对齐后,用于数组等)
// alignment = 8

六、与内存五大分区的关系

  • 栈、堆、全局区中存放的局部变量、对象、全局/静态变量,其起始地址与内部成员都受对齐约束;编译器与运行时在分配时会保证对齐。
  • 理解对齐有助于:估算结构体/类实例占用、排查「sizeof 与预期不符」、与 C 互操作或做底层布局时避免未对齐访问。

七、自定义对齐与 packed(简述)

手段 说明
_attribute_((aligned(n))) 指定变量或结构体按 n 字节对齐(如缓存行 64 字节)。
_attribute_((packed)) 取消结构体内部 padding,成员紧挨排列;可减小体积但可能未对齐,访问效率或安全性下降,需谨慎使用。

八、小结(思维导图)

mindmap
  root((内存对齐))
    目的
      CPU 访问效率
      ABI 与硬件要求
    规则
      成员按自身对齐
      整体大小为最大对齐的整数倍
    iOS/ARM64
      常用 8 字节整体对齐
      size 与 stride
    实践
      成员顺序影响大小
      packed / aligned 慎用

参考文献

07-主题|内存管理@iOS-实践与常见问题

本文在 01~06 基础上,汇总 内存警告Instruments 排查与泄漏分析Timer 管理野指针音视频与图层场景 等实践要点,以及常见问题与最佳实践。建议先掌握总纲与 ARC、weak 等再阅读本文;Timer 与 NSProxy 见 06-weak与循环引用


一、内存警告(Memory Warning)

1.1 机制

  • 系统在内存紧张时向应用发送 UIApplication 内存警告(如 didReceiveMemoryWarning);若不释放非必要缓存,系统可能终止进程

1.2 响应建议

做法 说明
释放缓存 图片缓存、数据缓存等可重建的,在收到警告时清理或缩小
释放不可见资源 非当前页的大图、大模型等可延迟重新加载的,可先释放
不阻塞主线程 释放与重建尽量异步,避免卡顿

1.3 回调示例(ViewController)

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // 释放可重建的缓存、大图等
}

二、内存泄漏(Leak)排查

2.1 常见原因

  • 循环引用:对象成环,引用计数永不为 0 → 用 weak 打破。
  • 定时器/观察者未移除:NSTimer、KVO、Notification 等强引用 target/observer,未在 dealloc 前移除 → 及时 invalidate/removeObserver。
  • Block/闭包强引用:block 强引用 self 且 self 强引用 block → [weak self];Block 类型与 copy 语义见 10-Block内存管理
  • Category 关联对象:用 objc_setAssociatedObject 时若用 RETAIN 关联了「会强引用主对象」的对象(如 block 捕获 self),会形成循环引用;应避免或改用 weak 打破。详见 09-Category与关联对象内存管理

2.2 Instruments(Leaks / Allocations)

  • Leaks:检测进程内已无法被引用到的「泄漏」内存块。
  • Allocations:查看各对象分配与存活情况,结合 GenerationsMark Generation 观察某操作后是否持续增长不降。
  • 结合 Call Tree 与源码,定位泄漏对象与引用链。

2.3 内存泄漏的内存分析(进阶)

  • 堆快照与对比:在 Allocations 中多次 Mark Generation(如进入页面前 Mark、返回后再 Mark),对比两次快照的「Persistent」对象数量与大小,找出本应释放却仍存活的对象。
  • VM 区域:在 Allocations 的 StatisticsVM Tracker 中查看各 VM 区域(如 CG image、Image IO、IOSurface、Audio 等),定位是哪类内存持续增长(如解码图、音视频缓冲未释放)。
  • 引用链分析:对疑似泄漏对象右键 Show in Memory Graph 或查看 Reference History,看清「谁在持有它」的引用链,从而找到应改为 weak 或应 invalidate/remove 的持有方。
  • Malloc Stack / Call Tree:开启 Malloc Stack(Allocations 模板或 Edit Scheme → Diagnostics)可看到分配时的调用栈,便于确认泄漏对象来自哪段代码;Call Tree 的「Invert Call Tree」「Hide System」可快速聚焦业务代码。
  • Leaks 与 Allocations 配合:Leaks 只报「不可达」的泄漏;很多「仍被错误持有」的对象不会报 Leak,需用 Allocations 的 Generation 对比 + 引用链分析。

2.4 Timer 管理(详细)

  • NSTimer 会强引用 target,且 RunLoop 会持有 timer;若 VC 强引用 timer 且 target 是 self,则 VC → timer → self 形成循环,VC 不会 dealloc。
  • 解决:① 在 dealloc 前 invalidate(若循环未破,dealloc 不会被调用,故须先破环);② 用 NSProxy 弱引用 self 作为 timer 的 target,使 timer 强引用的是 proxy 而非 VC,详见 06-weak与循环引用 中的「NSProxy 与 Weak、Timer 管理」;③ iOS 10+ 使用 block 版 scheduledTimerWithTimeInterval:repeats:block:,block 内用 weak self,timer 不直接强引用 self。
  • CADisplayLink 同样强引用 target,需用相同思路(proxy 或 block 若可用)并在 dealloc 里 invalidate

三、野指针与崩溃

3.1 成因

  • 对象已 release/dealloc,仍有指针访问该内存 → 野指针;再次向该对象发消息或访问成员易 EXC_BAD_ACCESS 等崩溃。

3.2 预防

手段 说明
ARC + weak 使用 weak 时,对象释放后指针自动置 nil,发送消息无效果但不会崩溃
不重复 release(MRC) MRC 下严格配对,避免对同一对象多次 release
置空指针 释放后将指针置 nil(ARC 中 weak 自动完成)

四、音视频场景内存注意

  • 解码缓冲与采样缓冲:音视频解码会产生 CVPixelBufferCMSampleBuffer 等,若不及时释放或重复堆积,会快速推高内存;播放/渲染完或不再需要时应及时释放,避免在回调或队列中积压。
  • 大文件/流:避免一次性将整段音视频读入内存;使用 AVAssetReader流式读取 等按需加载,及时释放已解码帧或已播放的缓冲。
  • 后台与生命周期:进入后台时释放非必要解码器、清空大缓冲或暂停解码,回到前台再重建,可配合 UIApplication 后台通知didReceiveMemoryWarning
  • 循环引用:在 AVFoundation 回调、block 中若使用 self,需 weak self,避免 VC 或播放器持有 block 且 block 强引用 self 导致不释放。
  • CVPixelBuffer / 图像缓冲:渲染或处理完及时 CVPixelBufferRelease(若自己 retain 过)或交给系统回收;避免在缓存中无上限保留未释放的 buffer。

内存极限管理(缓存上限、后台释放、按需加载、内存映射等)见 12-Option与内存优化技术 中的「内存的极限管理」一节。


五、图层处理场景内存注意

  • 图片解码与尺寸:UIImage 在赋值给 UIImageView 或绘制前会解码为位图,大图会占用 宽×高×4 字节 量级内存;应对大图做降采样(如用 Image I/O 或 Core Graphics 按显示尺寸解码),或使用缩略图/裁剪,避免全尺寸解码多张大图。
  • CALayer 与 backing store:图层有内容(如 contents、drawRect)时会有 backing store 占用内存;离屏渲染(圆角+裁剪、阴影、group opacity 等)会生成额外离屏缓冲,多而大时会增加内存与 GPU 压力,可适当减少离屏层或用位图缓存。
  • 离屏渲染与缓存shouldRasterize = YES 会缓存光栅化结果,图层复杂或尺寸大时缓存会占内存;在不需要时关闭或缩小 layer bounds。
  • 大图列表:列表(UITableView/UICollectionView)中大量大图时,做好 复用按需加载内存警告时释放;可配合 didReceiveMemoryWarning 清空图片缓存。
  • Core Graphics / 位图:自己创建的 CGContextCGBitmapContext 在不用时 CGContextRelease;UIGraphics 的 context 若为自己创建需对应释放。

六、最佳实践小结

场景 建议
属性默认 对象类型用 strong;delegate/dataSource 用 weak
Block 若 block 被 self 持有且 block 内用 self,用 weak self;block 内若需保证执行期 self 存活,可再 strong 一次;Block 属性用 copy/strong,详见 [10-Block内存管理](10-主题 内存管理@iOS-Block内存管理.md)
定时器/通知 在 dealloc 前 invalidate timer、removeObserver,避免强引用导致不释放
大量临时对象 循环内使用 @autoreleasepool 控制峰值
内存警告 实现 didReceiveMemoryWarning,释放可重建缓存
Category 关联对象 OBJC_ASSOCIATION_RETAIN/COPY 存对象、OBJC_ASSOCIATION_COPY 存 block;避免关联会强引用主对象的对象以防循环引用,详见 [09-Category与关联对象内存管理](09-主题 内存管理@iOS-Category与关联对象内存管理.md)
集合/对象拷贝 区分浅拷贝(新容器、元素共享)与深拷贝(完全独立);属性 copy 对集合仅浅拷贝,需完全隔离时考虑深拷贝或 copyItems,详见 [11-深浅拷贝与内存](11-主题 内存管理@iOS-深浅拷贝与内存.md)
Timer NSProxy 弱引用 self 作 target 破循环,或 iOS 10+ 用 block 版 API;dealloc 前必须 invalidate,详见 [06-weak与循环引用](06-主题 内存管理@iOS-weak与循环引用.md)
音视频 及时释放解码/采样缓冲,流式加载大文件,后台释放非必要资源,回调中用 weak self
图层/大图 大图降采样、控制离屏渲染与 rasterize 缓存、列表复用与按需加载、CGContext 及时释放

七、流程图:泄漏排查思路

flowchart LR
    A[怀疑泄漏] --> B[Instruments Leaks]
    B --> C[看引用链]
    C --> D[查循环引用/未移除的观察者等]
    D --> E[weak/移除/改设计]

参考文献

06-主题|内存管理@iOS-weak与循环引用

本文介绍 weak(弱引用) 的语义、在运行时中的实现思路(SideTable/weak_table)、循环引用 的成因与破除方式,以及 block、delegate 等场景下的注意点。ARC 基础见 04-ARC详解。Block 的三种类型、copy 与捕获变量见 10-Block内存管理


一、weak 的语义

1.1 定义

  • weak:不增加对象的引用计数,不拥有对象;当对象被释放时,所有指向它的 weak 指针会被自动置为 nil,避免野指针。
  • strong 对比:strong 持有对象(rc+1),strong 不释放则对象不 dealloc;weak 不持有,对象可被其他引用释放,释放后 weak 自动置 nil。

1.2 使用场景

场景 说明
打破循环引用 A → B → A,将其中一条边改为 weak,避免双方都无法释放
非拥有关系 delegate、dataSource 等,通常用 weak,由外部持有生命周期
block 内引用 self 使用 [weak self] 避免 self → block → self 循环

二、循环引用(Retain Cycle)

2.1 成因

  • 循环引用:对象 A 强引用 B,B 又强引用 A(或经过多条边回到 A),形成环;双方引用计数都不为 0,永远无法 dealloc,造成泄漏。

2.2 常见情形与破除

情形 破除方式
两个对象互相 strong 一方改为 weak(如 child 对 parent 用 weak)
self → block → self block 内用 [weak self],必要时内部再 strong 一次避免提前释放
delegate 双方都 strong 通常 delegate 属性声明为 weak,由外部持有
Timer 强引用 target VC 强引用 timer,timer 强引用 target(即 VC)→ 循环;用 NSProxy 弱引用 VC 作为 timer 的 target,或 iOS 10+ 用 block 版 API

2.3 Block 中 weak self 示例(Objective-C)

__weak typeof(self) wself = self;
self.block = ^{
    __strong typeof(wself) sself = wself; // 避免 block 执行过程中 self 被释放
    if (!sself) return;
    [sself doSomething];
};

三、NSProxy 与 Weak、Timer 管理

3.1 NSTimer 的循环引用问题

  • NSTimer强引用target;若 target 是 VC(或任意对象 A),且 A 又强引用了该 timer(如 self.timer),则形成 A → timer → target(A) 的循环,A 与 timer 都不会释放。
  • 仅在 A 里用 __weak self 给 timer 的 target 传参无效:timer 内部保存的是传入的 target 指针并对其强引用,不会因为调用方用 weak 而改为弱引用。

3.2 用 NSProxy 打破 Timer 循环引用

  • 思路:让 timer 的 target 不是一个强引用 self 的对象,而是一个中间对象;该中间对象对 self 只持 weak,并把 timer 的回调转发给 self。这样引用关系为:VC → timer → proxy(弱引用 VC),VC 释放时 proxy 的 weak 置 nil,proxy 可随之释放;timer 需在 VC 的 dealloc 里 invalidate,或由 proxy 在转发时发现 target 为 nil 时 invalidate(视实现而定)。
  • NSProxy 是专门做「转发」的根类,不继承自 NSObject,实现 forwardInvocation:methodSignatureForSelector:,把消息转给 weak 持有的 target 即可;内存上 proxy 只多一个 weak 指针,不增加 target 的引用计数。

3.3 WeakProxy 示例(Objective-C)

@interface WeakProxy : NSProxy
@property (nonatomic, weak) id target;
+ (instancetype)proxyWithTarget:(id)target;
@end

@implementation WeakProxy
+ (instancetype)proxyWithTarget:(id)target {
    WeakProxy *p = [WeakProxy alloc];
    p.target = target;
    return p;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.target methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.target];
}
@end

// 使用:timer 强引用的是 proxy,proxy 只 weak 引用 self
WeakProxy *proxy = [WeakProxy proxyWithTarget:self];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:proxy selector:@selector(onTick) userInfo:nil repeats:YES];
// dealloc 中仍须 [self.timer invalidate],否则 RunLoop 仍持有 timer

3.4 Timer 管理要点小结

要点 说明
invalidate VC(或持有 timer 的对象)dealloc 前必须调用 [timer invalidate],否则 RunLoop 持有 timer,timer 又强引用 target,导致泄漏或野指针。
block 版 API(iOS 10+) +[NSTimer scheduledTimerWithTimeInterval:repeats:block:] 的 block 里用 [weak self],timer 不直接强引用 self,可避免 timer→self 的强引用;仍需在 dealloc 里 invalidate。
子线程 子线程 RunLoop 默认不跑,timer 需加到 RunLoop 并 run;线程结束时记得 invalidate。

四、weak 实现思路(简述)

4.1 全局 weak 表

  • 运行时维护全局的 weak 表(与对象地址关联):记录「哪些 weak 指针正在指向该对象」。
  • 当对象 dealloc 时,查该表,把表中所有 weak 指针置为 nil,再销毁对象。

4.2 SideTable 与 weak_table(概念)

  • 为减少锁竞争,常用 SideTable 分片:根据对象地址映射到某一张 SideTable;每张表内有 weak_table,存「对象 → 指向它的 weak 指针列表」。
  • storeWeak 等函数:在注册 weak 指针、对象释放时更新对应 SideTable 中的 weak 表。

4.3 流程图:对象释放时 weak 置 nil

flowchart TB
    A[对象 dealloc] --> B[查 weak 表]
    B --> C[遍历指向该对象的 weak 指针]
    C --> D[将每个 weak 指针置为 nil]
    D --> E[销毁对象]

五、思维导图小结

mindmap
  root((weak 与循环引用))
    weak 语义
      不增加引用计数
      对象释放时置 nil
    循环引用
      成环 无法释放
      破除 一方改 weak
    Block
      weak self
      strong self 防提前释放
    NSProxy 与 Timer
      Timer 强引用 target
      WeakProxy 转发 破循环
    实现
      SideTable weak_table
      dealloc 时清空 weak

参考文献

05-主题|内存管理@iOS-AutoreleasePool与RunLoop

本文介绍 自动释放池(AutoreleasePool) 的原理、底层结构(AutoreleasePoolPage)、与 RunLoop 的协作关系,以及对象何时被批量 release。引用计数基础见 03-引用计数与MRC详解


自动释放池是什么(简要介绍)

自动释放池(AutoreleasePool) 是用于延迟释放对象的机制:当对象收到 autorelease 时,不会立即让引用计数 -1,而是被加入当前线程的自动释放池;当池被 pop/drain 时,池会对其中所有对象统一发送 release,从而在「某一时刻」批量 -1。在 MRC 下需手写 autorelease;在 ARC 下由编译器在需要时自动插入。主线程的 RunLoop 在每次循环开始会 push 一个池、在休眠或退出前 pop 该池,因此主线程上的 autorelease 对象多在「本次事件处理结束」时被释放。子线程若无 RunLoop,应显式使用 @autoreleasepool { } 控制释放时机,避免临时对象堆积。


一、AutoreleasePool 的作用

1.1 为什么需要

  • autorelease 表示「稍后再 release」:不立刻 -1,而是把对象交给当前自动释放池,由池在某一时刻统一对池内对象发送 release。
  • 作用:延迟释放,避免在密集创建临时对象的场景下频繁立刻 release,可将多次 release 合并到池 drain 时执行,有利于性能与局部性。

1.2 与 RunLoop 的关系(主线程)

  • 主线程 RunLoop 在一次循环中会:
    • 进入时:push 一个 AutoreleasePool;
    • 休眠/退出前:pop 该池,即对池内所有对象执行 release(drain)。
  • 因此,主线程上没有显式 @autoreleasepool 时,当前 RunLoop 迭代结束前创建的 autorelease 对象,会在本次迭代末尾被批量释放。

二、@autoreleasepool 语法与底层

2.1 语法

@autoreleasepool {
    // 池内创建的 autorelease 对象,在 } 时统一 release
    id obj = [SomeObject createObject]; // 若返回 autorelease 对象
}
// 池 pop,obj 收到 release

2.2 底层对应(伪代码)

  • @autoreleasepool { ... } 编译后等价于:
    • 入口:objc_autoreleasePoolPush()(入栈一个哨兵/边界);
    • 出口:objc_autoreleasePoolPop()(pop 到该边界,对之间加入的对象依次 release)。

2.3 AutoreleasePoolPage(简述)

  • 自动释放池由 AutoreleasePoolPage 组成的栈结构实现;每页约 4KB,存若干对象指针。
  • push 时可能新开一页或复用当前页;pop 时从栈顶向栈底对每个对象 release,直到遇到对应 push 的边界。

三、释放时机小结

场景 释放时机
主线程、无显式 @autoreleasepool 当前 RunLoop 迭代结束前(休眠/退出时 pop 顶层池)
显式 @autoreleasepool { } 离开 } 时 pop,池内对象立即被 release
子线程 若没有 RunLoop 或未手动加池,需在线程中显式 @autoreleasepool,否则 autorelease 对象可能堆积到线程退出

四、流程图:RunLoop 与 AutoreleasePool 协作(主线程)

flowchart LR
    subgraph RunLoop 一次迭代
        A[进入] --> B[Push Pool]
        B --> C[处理事件]
        C --> D[休眠/退出前]
        D --> E[Pop Pool]
        E --> F[池内对象 release]
    end

五、应用场景

  • 循环中大量创建临时对象:在循环内层包一层 @autoreleasepool { },每轮迭代结束即释放,避免峰值过高。
  • 子线程中创建大量 autorelease 对象:在线程入口或循环内使用 @autoreleasepool,避免只依赖线程退出才释放。

参考文献

04-主题|内存管理@iOS-ARC详解

本文介绍 ARC(Automatic Reference Counting,自动引用计数) 的机制、strong/weak/unowned 等所有权修饰符、编译器如何插入引用计数代码,以及常见应用场景与注意事项。前置知识见 03-引用计数与MRC详解


ARC 是什么(简要介绍)

ARCAutomatic Reference Counting:在编译期由编译器根据代码中的所有权修饰符(如 strong、weak)和代码结构,自动插入 retain、release、autorelease 等调用,开发者不再手写这些方法。底层仍然使用与 MRC 相同的引用计数规则,只是「谁在何时 +1/-1」由编译器决定。ARC 自 iOS 5 / WWDC 2011 引入,现为 Objective-C 与 Swift 的推荐方式;Swift 仅支持 ARC。使用 ARC 时仍需理解强引用与弱引用循环引用自动释放池 的释放时机,见 05-AutoreleasePool与RunLoop06-weak与循环引用


一、ARC 是什么

1.1 定义

  • ARC 是编译期特性:编译器根据所有权修饰符代码结构,在合适位置自动插入 retain、release、autorelease 等调用。
  • 与 MRC 使用同一套引用计数规则,对象生命周期语义一致;开发者不再手写 retain/release,减少遗漏与错误。

1.2 与 MRC 对比

维度 MRC ARC
谁写 retain/release 开发者手写 编译器自动插入
所有权表达 通过方法名约定 + 手写调用 通过变量/属性修饰符(strong/weak 等)
autorelease 手写 autorelease 编译器在需要时插入
循环引用 需手写 weak 或打破引用 同样需用 weak/unowned 打破

二、所有权修饰符(Objective-C)

2.1 常见修饰符

修饰符 含义 引用计数影响
__strong(默认) 强引用,拥有对象 赋值时 retain,离开作用域或置 nil 时 release
__weak 弱引用,不拥有对象 不增加引用计数;对象释放时自动置为 nil
__unsafe_unretained 不保留引用,不拥有 不增加引用计数;对象释放后不置 nil,可能野指针
__autoreleasing 通过引用传入并在 autorelease 池中释放 用于 out 参数等场景

2.2 属性与修饰符对应

属性声明 默认修饰符 说明
strong __strong 强引用,常用
weak __weak 弱引用,打破循环或非拥有关系
copy __strong(拷贝语义) 设值时 copy,用于 block、NSString 等;深浅拷贝与 copy 语义见 [11-深浅拷贝与内存](11-主题 内存管理@iOS-深浅拷贝与内存.md)
assign __unsafe_unretained 不持有,多用于基本类型或需避免循环时(非对象慎用)

三、ARC 下的典型场景

3.1 强引用与释放时机

// 局部变量:离开作用域时自动 release
- (void)foo {
    NSObject *obj = [[NSObject alloc] init]; // 强引用,rc=1
    // 使用 obj
} // 作用域结束,编译器插入 release,obj 可能 dealloc

3.2 弱引用与循环引用

// 两个对象互相强引用 → 循环引用,都无法释放
// 解决:一方改为 weak
@interface Child : NSObject
@property (nonatomic, weak) Parent *parent; // 弱引用父类
@end

3.3 Block 中的循环引用

// self → block → self,形成循环
__weak typeof(self) wself = self;
self.block = ^{
    __strong typeof(wself) sself = wself;
    [sself doSomething];
};

详见 06-weak与循环引用。Block 的三种类型(全局/栈/堆)、copy 语义与 MRC/ARC 差异见 10-Block内存管理


四、流程图:ARC 编译期插入示意

flowchart TB
    subgraph 源码
        A[strong 赋值]
        B[变量离开作用域]
    end
    subgraph 编译器插入
        A --> C[插入 retain]
        B --> D[插入 release]
    end

五、Swift 中的 ARC

  • Swift 仅支持 ARC,无 MRC。
  • strong(默认)、weakunowned 与 OC 语义对应;闭包捕获列表 [weak self] / [unowned self] 用于避免循环引用。
  • 详见 Swift 官方 - Automatic Reference Counting

参考文献

03-主题|内存管理@iOS-引用计数与MRC详解

本文介绍 引用计数(Reference Counting) 的原理、MRC(Manual Reference Counting,手动引用计数) 下的规则与配对原则,以及 retain/release/autorelease 的语义与典型用法。ARC 在此基础上由编译器自动插入,见 04-ARC详解


MRC 是什么(简要介绍)

MRCManual Reference Counting:由开发者手动调用 retainreleaseautorelease 来增加或减少对象的引用计数,从而决定对象何时被释放。在 iOS 5 之前是 Objective-C 的主流方式;理解 MRC 的规则与配对原则,是理解 ARC 和自动释放池的基础。下文的「引用计数原理」与「加一/减一规则」同时适用于 MRC 与 ARC,ARC 只是把这些调用交给编译器自动插入。


一、引用计数原理

1.1 基本思想

  • 每个堆上对象维护一个引用计数(retain count):表示当前有多少处「引用」正在使用该对象。
  • 引用计数为 0 时,对象不再被任何引用使用,系统销毁对象并回收内存
  • +1:新增加一处引用(如持有、拷贝得到新指针);-1:减少一处引用(如不再持有、释放)。

1.2 规则小结

事件 引用计数变化
创建对象(alloc/new/copy/mutableCopy) +1,创建者持有
retain +1
release -1
autorelease 将「稍后 -1」交给当前 AutoreleasePool 处理

1.3 流程图:对象生命周期

flowchart LR
    A[alloc/new 等] --> B[rc=1]
    B --> C{有 retain?}
    C -->|是| D[rc+=1]
    D --> C
    C -->|否| E{有 release/autorelease?}
    E -->|是| F[rc-=1]
    F --> G{rc==0?}
    G -->|是| H[dealloc 回收]
    G -->|否| E

二、MRC 下的「加一」与「减一」

2.1 引用计数 +1 的操作

方法/操作 说明
alloc 分配内存并返回对象,调用者拥有,rc 初始为 1
new 等价于 alloc + init,调用者拥有
copy / mutableCopy 得到新对象,调用者拥有新对象(rc=1)
retain 对已有对象调用,表示多一处引用,rc+1

2.2 引用计数 -1 的操作

方法/操作 说明
release 立即减少引用计数,rc-1;若 rc 变为 0 则 dealloc
autorelease 将对象加入当前 AutoreleasePool,在池 drain 时对该对象 release(延迟 -1)

2.3 配对原则(MRC 核心)

  • 谁让引用计数 +1,谁就要在合适时机让引用计数 -1(自己 release 或 autorelease)。
  • 只 +1 不 -1 → 泄漏;若多 -1 或对已释放对象再访问 → 野指针/崩溃。

三、伪代码与算法说明

3.1 retain / release 语义(伪代码)

函数 retain(obj):
    若 obj == nil: 返回 nil
    obj.retainCount += 1
    返回 obj

函数 release(obj):
    若 obj == nil: 返回
    obj.retainCount -= 1
    若 obj.retainCount == 0:
        调用 obj.dealloc
        释放对象内存

3.2 autorelease 语义(伪代码)

函数 autorelease(obj):
    若 obj == nil: 返回 nil
    将 obj 加入当前线程的 AutoreleasePool 栈顶
    返回 obj
// 当 AutoreleasePool pop/drain 时,对池内每个对象调用 release

3.3 MRC 下典型写法示例(Objective-C)

// 创建并持有:alloc 后 rc=1,需要在不使用时 release
NSObject *obj = [[NSObject alloc] init];
// ... 使用 obj ...
[obj release];

// 通过方法返回「已 autorelease」的对象:调用者不拥有,不需 release
- (NSString *)name {
    return [[[NSString alloc] initWithFormat:@"name"] autorelease];
}

四、所有权与命名约定(MRC 时代)

  • 方法名以 alloc / new / copy / mutableCopy 开头:返回的对象调用者拥有,需负责 release 或 autorelease。
  • 其他返回对象的方法:默认约定返回 autorelease 对象,调用者不拥有,不应 release(除非先 retain)。

五、与 ARC 的衔接

ARC 仍基于同一套引用计数规则,只是 retain/release/autorelease编译器在编译期自动插入;开发者通过 strong/weak 等修饰符表达所有权,编译器据此生成对应的 retain/release。详见 04-ARC详解


参考文献

尝试给Lookin 支持 MCP

作者 FeliksLv
2026年3月1日 22:33

不知道大家在 Vibe Coding 的时候,是否经常遇到这样的情况,让 AI 修改一个复杂页面,改完之后发现布局乱了,只能通过文字描述让 AI 去改,还经常改不对。

在日常开发中,我们经常会使用 Lookin 来查看布局,我想能否给 Lookin 支持 MCP 查看布局+刷新。这样是不是就不用我们自己给 AI 描述问题了。

于是我开始了这个集成工作,用本文记录下整个过程。先放下最终效果:

path-image-1e9c6001a4ab420da34cb981b0080ae1.png

image.png

image.png

Lookin MCP 支持的所有方法

# 方法名 描述 参数
1 get_status 获取 Lookin 服务器状态和连接状态,返回是否有 iOS 应用连接以及是否有层级数据
2 list_apps 列出所有连接的 iOS 应用及其基本信息
3 get_hierarchy 获取已连接 iOS 应用的完整视图层级,返回所有视图及其属性、frame 和关系 flat: bool (可选) - 是否返回扁平数组
maxDepth: int (可选) - 最大遍历深度
4 get_view 通过 oid 获取指定视图的详细信息,包括 frame、bounds、类继承链等 oid: int (必需) - 视图对象 ID
5 get_screenshot 获取指定视图的截图,返回 base64 编码的 PNG 图片 oid: int (必需) - 视图对象 ID
6 search_views 按类名、文字内容或 oid 搜索视图 query: string (必需) - 搜索关键词
type: enum (可选) - 搜索类型: "class"/"text"/"oid"
7 list_viewcontrollers 列出应用中所有的 ViewController,包括类名、内存地址和关联的视图 oid
8 get_app_info 获取已连接 iOS 应用的详细信息,包括应用名、Bundle ID、设备名、OS 版本、屏幕尺寸
9 reload_hierarchy 重新加载视图层级数据,用于 UI 变化后刷新数据
10 get_view_attributes 获取视图的完整属性详情,包括所有属性组(Layout、AutoLayout、UILabel、UIScrollView 等)、事件处理器(手势、target-action)和 AutoLayout 约束 oid: int (必需) - 视图对象 ID

第一版-双进程

MCP 协议要求 Server 通过 stdio(标准输入/输出)与 Client 通信,这对于 GUI 应用来说是个问题:

  • macOS GUI 应用没有 stdin/stdout
  • GUI 应用不适合作为子进程被其他应用启动
  • Lookin 需要保持独立运行以维护与 iOS 设备的连接

所以第一版采用双进程架构:一个独立的命令行工具 lookin-mcp 处理 MCP 协议,通过 HTTP 与 Lookin 主应用通信。

sequenceDiagram
    participant AI as AI 工具
    participant MCP as lookin-mcp
    participant HTTP as LKMCPServer
    participant iOS as iOS App
    
    Note over AI,MCP: stdio (JSON-RPC)
    Note over MCP,HTTP: HTTP (REST)
    Note over HTTP,iOS: Peertalk/Bonjour
    
    AI->>MCP: tools/call (get_hierarchy)
    MCP->>HTTP: GET /hierarchy
    HTTP->>iOS: 获取视图数据
    iOS-->>HTTP: LookinHierarchyInfo
    HTTP-->>MCP: JSON Response
    MCP-->>AI: Tool Result
组件 角色 通信方式
lookin-mcp MCP Server stdio (JSON-RPC)
LKMCPServer HTTP Server HTTP REST API
Lookin.app 主应用 内嵌 HTTP Server

实现细节

1. 手写 MCP 协议

MCP 协议基于 JSON-RPC 2.0,需要实现请求/响应的解析和序列化:

struct JSONRPCRequest: Codable {
    let jsonrpc: String
    let id: RequestId?
    let method: String
    let params: AnyCodable?
}

struct JSONRPCResponse: Codable {
    let jsonrpc: String
    let id: RequestId?
    let result: AnyCodable?
    let error: JSONRPCError?
}

2. HTTP Server (LKMCPServer)

在 Lookin 主应用中内嵌一个轻量级 HTTP Server,监听 127.0.0.1:47199:

@implementation LKMCPServer

- (void)start {
    self.server = [[GCDWebServer alloc] init];
    
    // GET /status - 检查连接状态
    [self.server addHandlerForMethod:@"GET" path:@"/status" 
        requestClass:[GCDWebServerRequest class]
        processBlock:^GCDWebServerResponse *(GCDWebServerRequest *request) {
            return [self handleStatusRequest];
        }];
    
    // GET /hierarchy - 获取视图层级
    [self.server addHandlerForMethod:@"GET" path:@"/hierarchy" ...];
    
    // POST /reload - 刷新数据
    [self.server addHandlerForMethod:@"POST" path:@"/reload" ...];
    
    [self.server startWithPort:47199 bonjourName:nil];
}

@end

3. MCP Tools 定义

第一版实现了 8 个 Tools:

Tool 描述 HTTP 映射
status 检查连接状态 GET /status
get_hierarchy 获取视图层级 GET /hierarchy
get_view 获取视图详情 GET /view/:oid
search 搜索视图 GET /search?q=&type=
get_screenshot 获取视图截图 GET /screenshot/:oid
get_app_info 获取应用信息 GET /app-info
list_view_controllers 列出 VC GET /viewcontrollers
reload 刷新层级数据 POST /reload

4. 构建和部署

需要在 Xcode Build Phase 中添加脚本,将 lookin-mcp 复制到 app bundle:

# Scripts/build_mcp.sh
cd "${SRCROOT}/LookinMCP"
swift build -c release

mkdir -p "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Helpers"
cp ".build/release/lookin-mcp" "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Helpers/"

通信流程

启动流程:

sequenceDiagram
    participant User as 用户
    participant Lookin as Lookin.app
    participant Pref as LKPreferenceManager
    participant HTTP as LKMCPServer
    
    User->>Lookin: 启动应用
    Lookin->>Pref: 读取 enableMCPServer
    alt MCP Server 已启用
        Pref-->>Lookin: YES
        Lookin->>HTTP: start()
        HTTP-->>Lookin: 监听 127.0.0.1:47199
    else MCP Server 已禁用
        Pref-->>Lookin: NO
        Note over HTTP: 不启动
    end

查询流程:

sequenceDiagram
    participant AI as AI 工具
    participant MCP as lookin-mcp
    participant HTTP as LKMCPServer
    participant DS as DataSource
    
    AI->>MCP: {"method": "tools/call", "params": {"name": "get_hierarchy"}}
    MCP->>HTTP: GET http://127.0.0.1:47199/hierarchy
    HTTP->>DS: flatItems
    DS-->>HTTP: [LookinDisplayItem]
    HTTP-->>MCP: {"views": [...], "total": 150}
    MCP-->>AI: {"content": [{"type": "text", "text": "..."}]}

使用方式

以OpenCode为例,修改 ~/.config/opencode/opencode.json

{
  "mcp": {
    "lookin": {
        "type": "local",
        "command": "/Applications/Lookin.app/Contents/Helpers/lookin-mcp"
    }
  }
}

运行试了一下,可以拿到对应节点的视图信息。

1040g3g831t60a6umla06gl60tgb1aifea95vsf8.jpg

第一版的问题

虽然能用,但有几个不太满意的地方:

  1. 双进程架构复杂 - 需要维护两套代码,调试也麻烦
  2. 手写协议不可靠 - MCP 协议还在演进,手写实现容易出 bug
  3. 部署麻烦 - 需要在 Build Phase 中复制二进制文件
  4. 配置繁琐 - 用户需要手动修改 JSON 配置文件

第二版

MCP 是有 Swift 版本官方 SDK 的:

github.com/modelcontex…

既然有官方 SDK,为什么还要自己手写协议呢?而且第一版的双进程架构也有点复杂。于是我决定重构,目标是:

  1. 使用官方 Swift SDK 替换手写的 MCP 协议实现
  2. 将 MCP Server 内嵌到 Lookin 主应用,去掉独立进程
  3. 使用 HTTP Transport,简化用户配置

新架构

graph TB
    subgraph "AI 工具"
        AI[OpenCode / Claude / Cursor ...]
    end
    
    subgraph "Lookin.app"
        HTTP[NIO HTTP Server<br/>:47199/mcp]
        MCP[MCP Server<br/>官方 Swift SDK]
        DS[LKStaticHierarchyDataSource]
        Apps[LKAppsManager]
    end
    
    subgraph "iOS App"
        LookinServer[LookinServer SDK]
    end
    
    AI <-->|HTTP<br/>MCP Protocol| HTTP
    HTTP --> MCP
    MCP --> DS
    MCP --> Apps
    Apps <-->|USB/WiFi| LookinServer

对比一下两个版本:

对比项 第一版 第二版
协议实现 手写 JSON-RPC 官方 Swift SDK
进程模型 双进程 (stdio + HTTP) 单进程 (内嵌 HTTP)
通信方式 stdio → HTTP → 数据源 HTTP → 数据源
配置方式 修改配置文件 一行命令
依赖管理 复制二进制到 app bundle SPM 本地 Package

SDK 选型

官方 SDK 版本 0.11.0 支持多种 Transport:

  • StdioServerTransport: 传统的 stdio 方式
  • StreamableHTTPServerTransport: 有状态的 HTTP 流式传输
  • StatelessHTTPServerTransport: 无状态 HTTP,适合简单场景

我选择了 StatelessHTTPServerTransport,因为 Lookin 的场景不需要维护会话状态,每次请求都是独立的查询。

实现细节

1. LookinMCP Package 结构

重构后的 Package 变得更简洁:

LookinMCP/
├── Package.swift                    # SPM 配置,依赖官方 SDK
└── Sources/LookinMCP/
    ├── LookinMCPDataSource.swift    # 数据源协议 + 模型定义
    ├── LookinMCPServer.swift        # HTTP Server + MCP Server
    └── LookinMCPToolHandler.swift   # 9 个 Tools 的注册和处理

Package.swift 配置:

// swift-tools-version: 6.1
import PackageDescription

let package = Package(
    name: "LookinMCP",
    platforms: [.macOS(.v13)],
    products: [
        .library(name: "LookinMCP", targets: ["LookinMCP"]),
    ],
    dependencies: [
        .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0"),
    ],
    targets: [
        .target(
            name: "LookinMCP",
            dependencies: [
                .product(name: "MCP", package: "swift-sdk"),
            ]
        ),
    ]
)

2. HTTP Server 实现

SDK 提供了 StatelessHTTPServerTransport,但需要自己搭建 HTTP Server。参考 SDK 示例,使用 swift-nio:

public func start() async throws {
    let bootstrap = ServerBootstrap(group: eventLoopGroup)
        .serverChannelOption(.backlog, value: 256)
        .childChannelInitializer { channel in
            channel.pipeline.configureHTTPServerPipeline().flatMap {
                channel.pipeline.addHandler(HTTPHandler(transport: self.transport))
            }
        }
    
    let channel = try await bootstrap.bind(host: host, port: port).get()
    // Server started on http://127.0.0.1:47199/mcp
}

3. Tool 注册

使用 SDK 的 withMethodHandler API 注册 Tools:

await server.withMethodHandler(ListTools.self) { _ in
    ListToolsResult(tools: [
        Tool(name: "status", description: "Check Lookin connection status"),
        Tool(name: "get_hierarchy", description: "Get view hierarchy", inputSchema: ...),
        // ... 更多 tools
    ])
}

await server.withMethodHandler(CallTool.self) { params in
    switch params.name {
    case "status":
        let status = await dataSource.getStatus()
        return CallToolResult(content: [.text(status.toJSON())])
    case "get_hierarchy":
        // ...
    }
}

4. 主应用集成

AppDelegate.m 中启动 MCP Server:

if ([LKPreferenceManager mainManager].enableMCPServer) {
    [[MCPServerManager shared] start];
}

MCPServerManager 是一个 Swift 类,提供 @objc 接口供 Obj-C 调用:

@objc(MCPServerManager)
@MainActor
final class MCPServerManager: NSObject {
    @objc static let shared = MCPServerManager()
    
    @objc func start() {
        Task {
            let server = try await LookinMCPServer(dataSource: dataProvider, port: 47199)
            try await server.start()
        }
    }
}

使用方式

对于 OpenCode:

// .config/opencode/opencode.json
{
    // ...
  "mcp": {
    "lookin": {
      "type": "remote",
      "url": "http://127.0.0.1:47199/mcp",
      "enabled": true
    }
  }
}

对于 Claude Code:

claude mcp add --transport http lookin http://127.0.0.1:47199/mcp

然后就可以直接使用了:

image.png

现在可以愉快地让 AI 帮我看布局、找问题了!

代码放在 fork 的仓库里:github.com/FeliksLv01/…

01-研究系统框架@Web@iOS | JavaScriptCore 框架:从使用到原理解析

2026年3月1日 20:10

JavaScriptCore 框架:从使用到原理解析

JavaScript 越来越多地出现在我们客户端开发的视野中,从 React Native 到 JSPatch,JavaScript 与客户端相结合的技术开始变得魅力无穷。本文主要讲解 iOS 中的 JavaScriptCore 框架,正是它为 iOS 提供了执行 JavaScript 代码的能力。未来的技术日新月异,JavaScript 与 iOS 正在碰撞出新的激情。

JavaScriptCoreJavaScript虚拟机,为 JavaScript 的执行提供底层资源。


📋 目录


一、JavaScript

在讨论JavaScriptCore之前,我们首先必须对JavaScript有所了解。

1. JavaScript干啥的?

  • 说的高大上一点:一门基于原型、函数先行的高级编程语言,通过解释执行,是动态类型的直译语言。是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程。
  • 说的通俗一点:主要用于网页,为其提供动态交互的能力。可嵌入动态文本于HTML页面,对浏览器事件作出响应,读写HTML元素,控制cookies等。
  • 再通俗一点:抢月饼,button.click()。(PS:请谨慎使用while循环)

img

2. JavaScript起源与历史

  • 1990年底,欧洲核能研究组织(CERN)科学家Tim Berners-Lee,在互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。
  • 1994年12月,Netscape 发布了一款面向普通用户的新一代的浏览器Navigator 1.0版,市场份额一举超过90%。
  • 1995年,Netscape公司雇佣了程序员Brendan Eich开发这种嵌入网页的脚本语言。最初名字叫做Mocha,1995年9月改为LiveScript。
  • 1995年12月,Netscape公司与Sun公司达成协议,后者允许将这种语言叫做JavaScript。

3. JavaScript与ECMAScript

  • “JavaScript”是Sun公司的注册商标,用来特制网景(现在的Mozilla)对于这门语言的实现。网景将这门语言作为标准提交给了ECMA——欧洲计算机制造协会。由于商标上的冲突,这门语言的标准版本改了一个丑陋的名字“ECMAScript”。同样由于商标的冲突,微软对这门语言的实现版本取了一个广为人知的名字“Jscript”。
  • ECMAScript作为JavaScript的标准,一般认为后者是前者的实现。

4. Java和JavaScript

img

《雷锋和雷峰塔》

Java 和 JavaScript 是两门不同的编程语言 一般认为,当时 Netscape 之所以将 LiveScript 命名为 JavaScript,是因为 Java 是当时最流行的编程语言,带有 “Java” 的名字有助于这门新生语言的传播。

二、 JavaScriptCore

1. 浏览器演进

  • 演进完整图

upload.wikimedia.org/wikipedia/c…

  • WebKit分支

现在使用WebKit的主要两个浏览器Sfari和Chromium(Chorme的开源项目)。WebKit起源于KDE的开源项目Konqueror的分支,由苹果公司用于Sfari浏览器。其一条分支发展成为Chorme的内核,2013年Google在此基础上开发了新的Blink内核。

img

2. WebKit排版引擎

webkit是sfari、chrome等浏览器的排版引擎,各部分架构图如下

img

  • webkit Embedding API是browser UI与webpage进行交互的api接口;
  • platformAPI提供与底层驱动的交互, 如网络, 字体渲染, 影音文件解码, 渲染引擎等;
  • WebCore它实现了对文档的模型化,包括了CSS, DOM, Render等的实现;
  • JSCore是专门处理JavaScript脚本的引擎;

3. JavaScript引擎

  • JavaScript引擎是专门处理JavaScript脚本的虚拟机,一般会附带在网页浏览器之中。第一个JavaScript引擎由布兰登·艾克在网景公司开发,用于Netscape Navigator网页浏览器中。JavaScriptCore就是一个JavaScript引擎。
  • 下图是当前主要的还在开发中的JavaScript引擎

img

4. JavaScriptCore组成

JavaScriptCore主要由以下模块组成:

  • Lexer 词法分析器,将脚本源码分解成一系列的Token
  • Parser 语法分析器,处理Token并生成相应的语法树
  • LLInt 低级解释器,执行Parser生成的二进制代码
  • Baseline JIT 基线JIT(just in time 实施编译)
  • DFG 低延迟优化的JIT
  • FTL 高通量优化的JIT

关于更多JavaScriptCore的实现细节,参考 trac.webkit.org/wiki/JavaSc…

5. JavaScriptCore 框架与历史

JavaScriptCore 是一个 C++ 实现的开源项目(WebKit 的一部分)。历史上,JSC 长期作为 Safari / WebKit 的内置 JS 引擎;自 iOS 7.0 / OS X 10.9 起,Apple 将 JavaScriptCore 以系统框架 JavaScriptCore.framework 的形式开放给开发者,使其可在 Objective-C 或基于 C 的程序中执行 JavaScript 代码,并向 JS 环境中插入自定义对象,而无需依赖 UIWebView。这为 Hybrid 应用、热更新、脚本引擎等场景提供了统一的底层能力。

JavaScriptCore.h 中,我们可以看到:

#ifndef JavaScriptCore_h
#define JavaScriptCore_h

#include <JavaScriptCore/JavaScript.h>
#include <JavaScriptCore/JSStringRefCF.h>

#if defined(__OBJC__) && JSC_OBJC_API_ENABLED

#import "JSContext.h"
#import "JSValue.h"
#import "JSManagedValue.h"
#import "JSVirtualMachine.h"
#import "JSExport.h"

#endif

#endif /* JavaScriptCore_h */

这里已经很清晰地列出了JavaScriptCore的主要几个类:

  • JSContext
  • JSValue
  • JSManagedValue
  • JSVirtualMachine
  • JSExport

接下来我们会依次讲解这几个类的用法。

6. Hello World!

这段代码展示了如何在 Objective-C 中执行一段 JavaScript 代码,并且获取返回值并转换成 OC 数据打印:

// 创建虚拟机
JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];

//创建上下文
JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];

//执行JavaScript代码并获取返回值
JSValue *value = [context evaluateScript:@"1+2*3"];

// 转换成 OC 数据并打印
NSLog(@"value = %d", [value toInt32]);
// Output: value = 7

Swift 等价写法:

import JavaScriptCore

let vm = JSVirtualMachine()!
let context = JSContext(virtualMachine: vm)!
let value = context.evaluateScript("1 + 2 * 3")!
print("value =", value.toInt32())  // value = 7

三、 JSVirtualMachine

一个JSVirtualMachine的实例就是一个完整独立的JavaScript的执行环境,为JavaScript的执行提供底层资源。

这个类主要用来做两件事情:

  1. 实现并发的 JavaScript 执行
  2. JavaScript 和 Objective-C 桥接对象的内存管理

看下头文件 JSVirtualMachine.h 里有什么:

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSVirtualMachine : NSObject

/* 创建一个新的完全独立的虚拟机 */
(instancetype)init;

/* 对桥接对象进行内存管理 */
- (void)addManagedReference:(id)object withOwner:(id)owner;

/* 取消对桥接对象的内存管理 */
- (void)removeManagedReference:(id)object withOwner:(id)owner;

@end

每一个JavaScript上下文(JSContext对象)都归属于一个虚拟机(JSVirtualMachine)。每个虚拟机可以包含多个不同的上下文,并允许在这些不同的上下文之间传值(JSValue对象)。

然而,每个虚拟机都是完整且独立的,有其独立的堆空间和垃圾回收器(garbage collector ),GC无法处理别的虚拟机堆中的对象,因此你不能把一个虚拟机中创建的值传给另一个虚拟机。

img

线程和JavaScript的并发执行

JavaScriptCore API都是线程安全的。你可以在任意线程创建JSValue或者执行JS代码,然而,所有其他想要使用该虚拟机的线程都要等待。

  • 如果想并发执行JS,需要使用多个不同的虚拟机来实现。
  • 可以在子线程中执行JS代码。

通过下面这个 demo 来理解这个并发机制:

JSContext *context = [[CustomJSContext alloc] init];
JSContext *context1 = [[CustomJSContext alloc] init];
JSContext *context2 = [[CustomJSContext alloc] initWithVirtualMachine:[context virtualMachine]];
NSLog(@"start");
dispatch_async(queue, ^{
    while (true) {
        sleep(1);
        [context evaluateScript:@"log('tick')"];
    }
});
dispatch_async(queue1, ^{
    while (true) {
        sleep(1);
        [context1 evaluateScript:@"log('tick_1')"];
    }
});
dispatch_async(queue2, ^{
    while (true) {
        sleep(1);
        [context2 evaluateScript:@"log('tick_2')"];
    }
});
[context evaluateScript:@"sleep(5)"];
NSLog(@"end");

context和context2属于同一个虚拟机。

context1属于另一个虚拟机。

三个线程分别异步执行每秒1次的js log,首先会休眠1秒。

在context上执行一个休眠5秒的JS函数。

首先执行的应该是休眠5秒的JS函数,在此期间,context所处的虚拟机上的其他调用都会处于等待状态,因此tick和tick_2在前5秒都不会有执行。

而context1所处的虚拟机仍然可以正常执行tick_1

休眠5秒结束后,tick和tick_2才会开始执行(不保证先后顺序)。

实际运行输出的 log 是:

start
tick_1
tick_1
tick_1
tick_1
end
tick
tick_2

四、 JSContext

一个JSContext对象代表一个JavaScript执行环境。在native代码中,使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问native的对象、方法、函数。

img

1. JSContext执行JS代码

  • 调用evaluateScript函数可以执行一段top-level 的JS代码,并可向global对象添加函数和对象定义
  • 其返回值是JavaScript代码中最后一个生成的值

API Reference

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:
        (JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL     NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;
@property (copy) void(^exceptionHandler)(JSContext *context, JSValue
    *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);


@end

2. JSContext访问JS对象

一个JSContext对象对应了一个全局对象(global object)。例如web浏览器中中的JSContext,其全局对象就是window对象。在其他环境中,全局对象也承担了类似的角色,用来区分不同的JavaScript context的作用域。全局变量是全局对象的属性,可以通过JSValue对象或者context下标的方式来访问。

示例代码:

JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];

NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);
// Output: a = 7, a = 7, a = 7

这里列出了三种访问JavaScript对象的方法

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式

设置属性也是对应的。

API Reference

/* 为 JSContext 提供下标访问元素的方式 */
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end



/* 例如:以下代码在JavaScript中创建了一个实现是Objective-C block的function */
context[@"makeNSColor"] = ^(NSDictionary *rgb){
    float r = [rgb[@"red"] floatValue];
    float g = [rgb[@"green"] floatValue];
    float b = [rgb[@"blue"] floatValue];
    return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f)         alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeNSColor({red:12, green:23, blue:67})"];

五、 JSValue

一个JSValue实例就是一个JavaScript值的引用。使用JSValue类在JavaScript和native代码之间转换一些基本类型的数据(比如数值和字符串)。你也可以使用这个类去创建包装了自定义类的native对象的JavaScript对象,或者创建由native方法或者block实现的JavaScript函数。

每个JSValue实例都来源于一个代表JavaScript执行环境的JSContext对象,这个执行环境就包含了这个JSValue对应的值。每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有(retain),这个JSContext就会一直存活。通过调用JSValue的实例方法返回的其他的JSValue对象都属于与最始的JSValue相同的JSContext。

img

每个JSValue都通过其JSContext间接关联了一个特定的代表执行资源基础的JSVirtualMachine对象。你只能将一个JSValue对象传给由相同虚拟机管理(host)的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发一个Objective-C异常。

img

1. JSValue类型转换

JSValue提供了一系列的方法将native与JavaScript的数据类型进行相互转换:

img

2. NSDictionary与JS对象

NSDictionary 对象以及其包含的 keys 与 JavaScript 中的对应名称的属性相互转换。key 所对应的值也会递归地进行拷贝和转换。

[context evaluateScript:@"var color = {red:230, green:90, blue:100}"];

//js->native 给你看我的颜色
JSValue *colorValue = context[@"color"];
NSLog(@"r=%@, g=%@, b=%@", colorValue[@"red"], colorValue[@"green"], colorValue[@"blue"]);
NSDictionary *colorDic = [colorValue toDictionary];
NSLog(@"r=%@, g=%@, b=%@", colorDic[@"red"], colorDic[@"green"], colorDic[@"blue"]);

//native->js 给你点颜色看看
context[@"color"] = @{@"red":@(0), @"green":@(0), @"blue":@(0)};
[context evaluateScript:@"log('r:'+color.red+'g:'+color.green+' b:'+color.blue)"];
// Output:
// r=230, g=90, b=100
// r=230, g=90, b=100
// r:0 g:0 b:0

可见,JS中的对象可以直接转换成Objective-C中的NSDictionary,NSDictionary传入JavaScript也可以直接当作对象被使用。

3. NSArray与JS数组

NSArray 对象与 JavaScript 中的 array 相互转换。其子元素也会递归地进行拷贝和转换。

[context evaluateScript:@"var friends = ['Alice','Jenny','XiaoMing']"];

//js->native 你说哪个是真爱?
JSValue *friendsValue = context[@"friends"];
NSLog(@"%@, %@, %@", friendsValue[0], friendsValue[1], friendsValue[2]);
NSArray *friendsArray = [friendsValue toArray];
NSLog(@"%@, %@, %@", friendsArray[0], friendsArray[1], friendsArray[2]);

//native->js 我觉得 XiaoMing 不错,给你再推荐个 Jimmy
context[@"girlFriends"] = @[friendsArray[2], @"Jimmy"];
[context evaluateScript:@"log('girlFriends :'+girlFriends[0]+' '+girlFriends[1])"];
// Output: Alice, Jenny, XiaoMing / girlFriends : XiaoMing Jimmy

4. Block/函数和JS function

Objective-C中的block转换成JavaScript中的function对象。参数以及返回类型使用相同的规则转换。

将一个代表native的block或者方法的JavaScript function进行转换将会得到那个block或方法。

其他的JavaScript函数将会被转换为一个空的dictionary。因为JavaScript函数也是一个对象。

5. OC对象和JS对象

对于所有其他 native 的对象类型,JavaScriptCore 都会创建一个拥有 constructor 原型链的 wrapper 对象,用来反映 native 类型的继承关系。默认情况下,native 对象的属性和方法并不会导出给其对应的 JavaScript wrapper 对象。通过 JSExport 协议可选择性地导出属性和方法。下面第六节对 JSExport 与原生对象导出做详细讲解。


六、JSExport 与原生对象导出

JSExport 是 JavaScriptCore 框架中的协议,用于将 Objective-C/Swift 的类(属性与方法)选择性导出给 JavaScript,使 JS 代码可以像调用普通对象一样调用原生对象 [1][2]。

6.1 作用与机制

  • 遵循 JSExport 的协议中声明的属性和方法,会在将 native 对象注入到 JSContext(如 context[@"bridge"] = nativeObject)时,自动暴露为 JS 侧的属性和函数。
  • 若类未实现 JSExport 或未在协议中声明,则对应属性/方法不会出现在 JS 中;这样可控制「桥接面」,避免暴露内部实现 [1][2]。

6.2 使用示例(概念)

@protocol MyPointExport <JSExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
- (NSString *)description;
@end

@interface MyPoint : NSObject <MyPointExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
@end

MyPoint 实例赋给 context[@"point"] 后,在 JS 中可访问 point.xpoint.y 并调用 point.description()
注意:若在 Block 或导出方法中再次使用 JSValueJSContext,需注意线程与内存管理(见第七节 JSManagedValue)[1][2]。

Swift 中的等价写法(通过 JSContext 注入遵循 JSExport 的类):

import JavaScriptCore

@objc protocol PointExport: JSExport {
    var x: Double { get set }
    var y: Double { get set }
    func description() -> String
}

class Point: NSObject, PointExport {
    @objc var x: Double
    @objc var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
    func description() -> String { "Point(\(x), \(y))" }
}

// 注入到 context
let context = JSContext()!
context.setObject(Point(x: 1, y: 2), forKeyedSubscript: "point" as NSString)
context.evaluateScript("point.x; point.description()")

6.3 与 Block 注入的对比

方式 适用场景
context[@"fn"] = ^(id arg){ ... } 单次或简单逻辑,直接暴露为 JS 函数
JSExport 协议 + 原生对象 需要暴露多个方法/属性、保持对象身份与状态的「桥接对象」

七、JSManagedValue 与内存管理

7.1 为何需要 JSManagedValue

  • JSValueJSContext强引用JSContext 又挂在 JSVirtualMachine 上。
  • 若在 堆上的 OC 对象(如某 ViewController 的 property)中直接强引用 JSValue,而该 JSValue 通过某种方式(例如被注入到 context 的全局对象)又引用回该 OC 对象,会形成 OC ↔ JS 的循环引用,导致 Context 与 OC 对象均无法释放 [1][2]。

7.2 JSManagedValue 的职责

JSManagedValueJSValue 的包装类,用于在「被 OC 堆对象持有」的场景下,以条件保留的方式引用 JS 值,并可与 JSVirtualMachineaddManagedReference:withOwner: / removeManagedReference:withOwner: 配合,让虚拟机在合适的时机断开或保留对 native 对象的引用,从而打破循环、避免 JSContext 无法释放 [1][2]。

7.3 使用要点(概念)

  • 当需要把 JSValue(或从 JS 传回的函数/对象)存为 OC 对象的成员变量时,应使用 JSManagedValue 包装,并以 owner 注册到 JSVirtualMachine;在 owner 析构或不再需要时调用 removeManagedReference:withOwner: [1][2]。
  • 仅临时在栈上使用 JSValue(如 evaluateScript 的返回值在方法内使用后不再持有)时,一般无需 JSManagedValue。

八、关键概念图示与流程

8.1 VM、Context、Value 关系

flowchart TB
  subgraph VM1[JSVirtualMachine 1]
    C1[JSContext 1]
    C2[JSContext 2]
  end
  subgraph VM2[JSVirtualMachine 2]
    C3[JSContext 3]
  end
  C1 --> V1[JSValue]
  C2 --> V2[JSValue]
  C1 -.->|可传值| C2
  C1 -.->|不可跨 VM| C3

同一 JSVirtualMachine 下多个 JSContext 可共享、传递 JSValue;不同 VM 之间不能传递 JSValue [3]。

8.2 JavaScriptCore 引擎执行层级(概念)

源码经 Lexer → Parser 得到语法树并生成字节码后,由下至上的执行/编译层级可概括为:

flowchart LR
  A[源码] --> B[Lexer]
  B --> C[Parser / AST]
  C --> D[字节码]
  D --> E[LLInt 解释器]
  E --> F[Baseline JIT]
  F --> G[DFG JIT]
  G --> H[FTL JIT]
  • LLInt:低级解释器,低延迟启动。
  • Baseline JIT:首次 JIT,兼顾分析与回退。
  • DFG:基于数据流的优化 JIT。
  • FTL:更高优化层(历史上曾用 LLVM/B3 后端)[4][5]。

更多实现细节见 WebKit JavaScriptCore Wiki


九、应用场景与最佳实践

9.1 典型应用场景

场景 说明
Hybrid 应用 在 App 内执行 JS 脚本、调用原生能力(如弹窗、定位、支付),JavaScriptCore 提供 OC/Swift 与 JS 的双向桥接 [1][2]
React Native / 类 RN 方案 早期 RN 等方案在 iOS 上依赖 JSC 执行 JS bundle;JSC 提供 VM、Context、Value 等能力 [3]
JSPatch 等热修复 通过下发 JS 脚本并在 JSC 中执行,动态调用原生类与方法,实现热更新(需注意安全与审核政策)[3]
WKWebView 与 Web 页面 WKWebView 内部使用系统 WebKit,其 JS 引擎与 Safari 一致;独立使用 JSC 时无需 WebView 即可执行 JS [1][2]
规则引擎 / 脚本配置 将业务规则或配置写成 JS,由原生在 JSC 中执行并取结果,便于迭代与 A/B 测试

9.2 最佳实践要点

  • 线程:同一 VM 下多线程会串行等待;需并发执行 JS 时使用多个 JSVirtualMachine [3]。
  • 异常:设置 context.exceptionHandler,在 JS 抛错时记录或上报,避免静默失败 [3]。
  • 内存:在 OC 堆对象中持有 JS 值时使用 JSManagedValue + add/removeManagedReference,避免循环引用 [1][2]。
  • 安全:执行来自网络或不可信来源的 JS 时,需做沙箱与权限控制;避免将敏感 API 无限制暴露给 JS [3]。

十、伪代码与算法说明

10.1 执行脚本并取返回值(概念)

function evaluateScript(script: String) -> JSValue:
  parse script -> AST
  generate bytecode from AST
  execute bytecode (via LLInt or JIT tier)
  return last expression value as JSValue

10.2 将 Native 对象注入 Context(概念)

function setObject(object: Any, forKey key: String):
  if object is Block or conforms to JSExport:
    create JS wrapper (function or object with exported properties/methods)
  else:
    create generic wrapper preserving native type hierarchy
  set wrapper on context.globalObject[key]

10.3 JS 调用 Native Block 时(概念)

JavaScript 侧,调用通过 context[@"key"] 注入的 Block,与调用普通函数一致:

// 假设 Native 已注入:context["makeColor"] = ^(NSDictionary *rgb) { ... }
var color = makeColor({ red: 12, green: 23, blue: 67 });

底层流程(伪代码):

当 JS 调用 context 中注册的 Block 时:
  1. JSC 将 JS 参数按类型转换为 OC 对象(NSNumber/NSString/NSDictionary/NSArray 等)
  2. 调用 Block,传入转换后的参数
  3. 将 Block 返回值按类型转换为 JSValue 并返回给 JS

参考文献

[1] Apple. JavaScriptCore Framework. iOS / macOS Developer Documentation.
[2] 掘金 / 博客. iOS 与 JS 交互开发知识总结JavaScriptCore 初探 等.
[3] 本文原稿与常见 JSC 教程(JSVirtualMachine、JSContext、JSValue、并发与内存).
[4] WebKit. Introducing the WebKit FTL JIT. webkit.org/blog/3362/i…
[5] WebKit. JavaScriptCore - Deep Dive. docs.webkit.org/Deep%20Dive…
[6] trac.webkit.org. JavaScriptCore. trac.webkit.org/wiki/JavaSc…
[7] 美团技术团队. 深入理解 JSCore. blog.csdn.net/MeituanTech…

01-研究系统框架@Web@iOS | JavaScriptCore 框架:从使用到原理解析

JavaScriptCore 框架:从使用到原理解析

JavaScript 越来越多地出现在我们客户端开发的视野中,从 React Native 到 JSPatch,JavaScript 与客户端相结合的技术开始变得魅力无穷。本文主要讲解 iOS 中的 JavaScriptCore 框架,正是它为 iOS 提供了执行 JavaScript 代码的能力。未来的技术日新月异,JavaScript 与 iOS 正在碰撞出新的激情。

JavaScriptCoreJavaScript虚拟机,为 JavaScript 的执行提供底层资源。


📋 目录


一、JavaScript

在讨论JavaScriptCore之前,我们首先必须对JavaScript有所了解。

1. JavaScript干啥的?

  • 说的高大上一点:一门基于原型、函数先行的高级编程语言,通过解释执行,是动态类型的直译语言。是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程。
  • 说的通俗一点:主要用于网页,为其提供动态交互的能力。可嵌入动态文本于HTML页面,对浏览器事件作出响应,读写HTML元素,控制cookies等。
  • 再通俗一点:抢月饼,button.click()。(PS:请谨慎使用while循环)

img

2. JavaScript起源与历史

  • 1990年底,欧洲核能研究组织(CERN)科学家Tim Berners-Lee,在互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。
  • 1994年12月,Netscape 发布了一款面向普通用户的新一代的浏览器Navigator 1.0版,市场份额一举超过90%。
  • 1995年,Netscape公司雇佣了程序员Brendan Eich开发这种嵌入网页的脚本语言。最初名字叫做Mocha,1995年9月改为LiveScript。
  • 1995年12月,Netscape公司与Sun公司达成协议,后者允许将这种语言叫做JavaScript。

3. JavaScript与ECMAScript

  • “JavaScript”是Sun公司的注册商标,用来特制网景(现在的Mozilla)对于这门语言的实现。网景将这门语言作为标准提交给了ECMA——欧洲计算机制造协会。由于商标上的冲突,这门语言的标准版本改了一个丑陋的名字“ECMAScript”。同样由于商标的冲突,微软对这门语言的实现版本取了一个广为人知的名字“Jscript”。
  • ECMAScript作为JavaScript的标准,一般认为后者是前者的实现。

4. Java和JavaScript

img

《雷锋和雷峰塔》

Java 和 JavaScript 是两门不同的编程语言 一般认为,当时 Netscape 之所以将 LiveScript 命名为 JavaScript,是因为 Java 是当时最流行的编程语言,带有 “Java” 的名字有助于这门新生语言的传播。

二、 JavaScriptCore

1. 浏览器演进

  • 演进完整图

upload.wikimedia.org/wikipedia/c…

  • WebKit分支

现在使用WebKit的主要两个浏览器Sfari和Chromium(Chorme的开源项目)。WebKit起源于KDE的开源项目Konqueror的分支,由苹果公司用于Sfari浏览器。其一条分支发展成为Chorme的内核,2013年Google在此基础上开发了新的Blink内核。

img

2. WebKit排版引擎

webkit是sfari、chrome等浏览器的排版引擎,各部分架构图如下

img

  • webkit Embedding API是browser UI与webpage进行交互的api接口;
  • platformAPI提供与底层驱动的交互, 如网络, 字体渲染, 影音文件解码, 渲染引擎等;
  • WebCore它实现了对文档的模型化,包括了CSS, DOM, Render等的实现;
  • JSCore是专门处理JavaScript脚本的引擎;

3. JavaScript引擎

  • JavaScript引擎是专门处理JavaScript脚本的虚拟机,一般会附带在网页浏览器之中。第一个JavaScript引擎由布兰登·艾克在网景公司开发,用于Netscape Navigator网页浏览器中。JavaScriptCore就是一个JavaScript引擎。
  • 下图是当前主要的还在开发中的JavaScript引擎

img

4. JavaScriptCore组成

JavaScriptCore主要由以下模块组成:

  • Lexer 词法分析器,将脚本源码分解成一系列的Token
  • Parser 语法分析器,处理Token并生成相应的语法树
  • LLInt 低级解释器,执行Parser生成的二进制代码
  • Baseline JIT 基线JIT(just in time 实施编译)
  • DFG 低延迟优化的JIT
  • FTL 高通量优化的JIT

关于更多JavaScriptCore的实现细节,参考 trac.webkit.org/wiki/JavaSc…

5. JavaScriptCore 框架与历史

JavaScriptCore 是一个 C++ 实现的开源项目(WebKit 的一部分)。历史上,JSC 长期作为 Safari / WebKit 的内置 JS 引擎;自 iOS 7.0 / OS X 10.9 起,Apple 将 JavaScriptCore 以系统框架 JavaScriptCore.framework 的形式开放给开发者,使其可在 Objective-C 或基于 C 的程序中执行 JavaScript 代码,并向 JS 环境中插入自定义对象,而无需依赖 UIWebView。这为 Hybrid 应用、热更新、脚本引擎等场景提供了统一的底层能力。

JavaScriptCore.h 中,我们可以看到:

#ifndef JavaScriptCore_h
#define JavaScriptCore_h

#include <JavaScriptCore/JavaScript.h>
#include <JavaScriptCore/JSStringRefCF.h>

#if defined(__OBJC__) && JSC_OBJC_API_ENABLED

#import "JSContext.h"
#import "JSValue.h"
#import "JSManagedValue.h"
#import "JSVirtualMachine.h"
#import "JSExport.h"

#endif

#endif /* JavaScriptCore_h */

这里已经很清晰地列出了JavaScriptCore的主要几个类:

  • JSContext
  • JSValue
  • JSManagedValue
  • JSVirtualMachine
  • JSExport

接下来我们会依次讲解这几个类的用法。

6. Hello World!

这段代码展示了如何在 Objective-C 中执行一段 JavaScript 代码,并且获取返回值并转换成 OC 数据打印:

// 创建虚拟机
JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];

//创建上下文
JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];

//执行JavaScript代码并获取返回值
JSValue *value = [context evaluateScript:@"1+2*3"];

// 转换成 OC 数据并打印
NSLog(@"value = %d", [value toInt32]);
// Output: value = 7

Swift 等价写法:

import JavaScriptCore

let vm = JSVirtualMachine()!
let context = JSContext(virtualMachine: vm)!
let value = context.evaluateScript("1 + 2 * 3")!
print("value =", value.toInt32())  // value = 7

三、 JSVirtualMachine

一个JSVirtualMachine的实例就是一个完整独立的JavaScript的执行环境,为JavaScript的执行提供底层资源。

这个类主要用来做两件事情:

  1. 实现并发的 JavaScript 执行
  2. JavaScript 和 Objective-C 桥接对象的内存管理

看下头文件 JSVirtualMachine.h 里有什么:

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSVirtualMachine : NSObject

/* 创建一个新的完全独立的虚拟机 */
(instancetype)init;

/* 对桥接对象进行内存管理 */
- (void)addManagedReference:(id)object withOwner:(id)owner;

/* 取消对桥接对象的内存管理 */
- (void)removeManagedReference:(id)object withOwner:(id)owner;

@end

每一个JavaScript上下文(JSContext对象)都归属于一个虚拟机(JSVirtualMachine)。每个虚拟机可以包含多个不同的上下文,并允许在这些不同的上下文之间传值(JSValue对象)。

然而,每个虚拟机都是完整且独立的,有其独立的堆空间和垃圾回收器(garbage collector ),GC无法处理别的虚拟机堆中的对象,因此你不能把一个虚拟机中创建的值传给另一个虚拟机。

img

线程和JavaScript的并发执行

JavaScriptCore API都是线程安全的。你可以在任意线程创建JSValue或者执行JS代码,然而,所有其他想要使用该虚拟机的线程都要等待。

  • 如果想并发执行JS,需要使用多个不同的虚拟机来实现。
  • 可以在子线程中执行JS代码。

通过下面这个 demo 来理解这个并发机制:

JSContext *context = [[CustomJSContext alloc] init];
JSContext *context1 = [[CustomJSContext alloc] init];
JSContext *context2 = [[CustomJSContext alloc] initWithVirtualMachine:[context virtualMachine]];
NSLog(@"start");
dispatch_async(queue, ^{
    while (true) {
        sleep(1);
        [context evaluateScript:@"log('tick')"];
    }
});
dispatch_async(queue1, ^{
    while (true) {
        sleep(1);
        [context1 evaluateScript:@"log('tick_1')"];
    }
});
dispatch_async(queue2, ^{
    while (true) {
        sleep(1);
        [context2 evaluateScript:@"log('tick_2')"];
    }
});
[context evaluateScript:@"sleep(5)"];
NSLog(@"end");

context和context2属于同一个虚拟机。

context1属于另一个虚拟机。

三个线程分别异步执行每秒1次的js log,首先会休眠1秒。

在context上执行一个休眠5秒的JS函数。

首先执行的应该是休眠5秒的JS函数,在此期间,context所处的虚拟机上的其他调用都会处于等待状态,因此tick和tick_2在前5秒都不会有执行。

而context1所处的虚拟机仍然可以正常执行tick_1

休眠5秒结束后,tick和tick_2才会开始执行(不保证先后顺序)。

实际运行输出的 log 是:

start
tick_1
tick_1
tick_1
tick_1
end
tick
tick_2

四、 JSContext

一个JSContext对象代表一个JavaScript执行环境。在native代码中,使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问native的对象、方法、函数。

img

1. JSContext执行JS代码

  • 调用evaluateScript函数可以执行一段top-level 的JS代码,并可向global对象添加函数和对象定义
  • 其返回值是JavaScript代码中最后一个生成的值

API Reference

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:
        (JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL     NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;
@property (copy) void(^exceptionHandler)(JSContext *context, JSValue
    *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);


@end

2. JSContext访问JS对象

一个JSContext对象对应了一个全局对象(global object)。例如web浏览器中中的JSContext,其全局对象就是window对象。在其他环境中,全局对象也承担了类似的角色,用来区分不同的JavaScript context的作用域。全局变量是全局对象的属性,可以通过JSValue对象或者context下标的方式来访问。

示例代码:

JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];

NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);
// Output: a = 7, a = 7, a = 7

这里列出了三种访问JavaScript对象的方法

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式

设置属性也是对应的。

API Reference

/* 为 JSContext 提供下标访问元素的方式 */
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end



/* 例如:以下代码在JavaScript中创建了一个实现是Objective-C block的function */
context[@"makeNSColor"] = ^(NSDictionary *rgb){
    float r = [rgb[@"red"] floatValue];
    float g = [rgb[@"green"] floatValue];
    float b = [rgb[@"blue"] floatValue];
    return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f)         alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeNSColor({red:12, green:23, blue:67})"];

五、 JSValue

一个JSValue实例就是一个JavaScript值的引用。使用JSValue类在JavaScript和native代码之间转换一些基本类型的数据(比如数值和字符串)。你也可以使用这个类去创建包装了自定义类的native对象的JavaScript对象,或者创建由native方法或者block实现的JavaScript函数。

每个JSValue实例都来源于一个代表JavaScript执行环境的JSContext对象,这个执行环境就包含了这个JSValue对应的值。每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有(retain),这个JSContext就会一直存活。通过调用JSValue的实例方法返回的其他的JSValue对象都属于与最始的JSValue相同的JSContext。

img

每个JSValue都通过其JSContext间接关联了一个特定的代表执行资源基础的JSVirtualMachine对象。你只能将一个JSValue对象传给由相同虚拟机管理(host)的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发一个Objective-C异常。

img

1. JSValue类型转换

JSValue提供了一系列的方法将native与JavaScript的数据类型进行相互转换:

img

2. NSDictionary与JS对象

NSDictionary 对象以及其包含的 keys 与 JavaScript 中的对应名称的属性相互转换。key 所对应的值也会递归地进行拷贝和转换。

[context evaluateScript:@"var color = {red:230, green:90, blue:100}"];

//js->native 给你看我的颜色
JSValue *colorValue = context[@"color"];
NSLog(@"r=%@, g=%@, b=%@", colorValue[@"red"], colorValue[@"green"], colorValue[@"blue"]);
NSDictionary *colorDic = [colorValue toDictionary];
NSLog(@"r=%@, g=%@, b=%@", colorDic[@"red"], colorDic[@"green"], colorDic[@"blue"]);

//native->js 给你点颜色看看
context[@"color"] = @{@"red":@(0), @"green":@(0), @"blue":@(0)};
[context evaluateScript:@"log('r:'+color.red+'g:'+color.green+' b:'+color.blue)"];
// Output:
// r=230, g=90, b=100
// r=230, g=90, b=100
// r:0 g:0 b:0

可见,JS中的对象可以直接转换成Objective-C中的NSDictionary,NSDictionary传入JavaScript也可以直接当作对象被使用。

3. NSArray与JS数组

NSArray 对象与 JavaScript 中的 array 相互转换。其子元素也会递归地进行拷贝和转换。

[context evaluateScript:@"var friends = ['Alice','Jenny','XiaoMing']"];

//js->native 你说哪个是真爱?
JSValue *friendsValue = context[@"friends"];
NSLog(@"%@, %@, %@", friendsValue[0], friendsValue[1], friendsValue[2]);
NSArray *friendsArray = [friendsValue toArray];
NSLog(@"%@, %@, %@", friendsArray[0], friendsArray[1], friendsArray[2]);

//native->js 我觉得 XiaoMing 不错,给你再推荐个 Jimmy
context[@"girlFriends"] = @[friendsArray[2], @"Jimmy"];
[context evaluateScript:@"log('girlFriends :'+girlFriends[0]+' '+girlFriends[1])"];
// Output: Alice, Jenny, XiaoMing / girlFriends : XiaoMing Jimmy

4. Block/函数和JS function

Objective-C中的block转换成JavaScript中的function对象。参数以及返回类型使用相同的规则转换。

将一个代表native的block或者方法的JavaScript function进行转换将会得到那个block或方法。

其他的JavaScript函数将会被转换为一个空的dictionary。因为JavaScript函数也是一个对象。

5. OC对象和JS对象

对于所有其他 native 的对象类型,JavaScriptCore 都会创建一个拥有 constructor 原型链的 wrapper 对象,用来反映 native 类型的继承关系。默认情况下,native 对象的属性和方法并不会导出给其对应的 JavaScript wrapper 对象。通过 JSExport 协议可选择性地导出属性和方法。下面第六节对 JSExport 与原生对象导出做详细讲解。


六、JSExport 与原生对象导出

JSExport 是 JavaScriptCore 框架中的协议,用于将 Objective-C/Swift 的类(属性与方法)选择性导出给 JavaScript,使 JS 代码可以像调用普通对象一样调用原生对象 [1][2]。

6.1 作用与机制

  • 遵循 JSExport 的协议中声明的属性和方法,会在将 native 对象注入到 JSContext(如 context[@"bridge"] = nativeObject)时,自动暴露为 JS 侧的属性和函数。
  • 若类未实现 JSExport 或未在协议中声明,则对应属性/方法不会出现在 JS 中;这样可控制「桥接面」,避免暴露内部实现 [1][2]。

6.2 使用示例(概念)

@protocol MyPointExport <JSExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
- (NSString *)description;
@end

@interface MyPoint : NSObject <MyPointExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
@end

MyPoint 实例赋给 context[@"point"] 后,在 JS 中可访问 point.xpoint.y 并调用 point.description()
注意:若在 Block 或导出方法中再次使用 JSValueJSContext,需注意线程与内存管理(见第七节 JSManagedValue)[1][2]。

Swift 中的等价写法(通过 JSContext 注入遵循 JSExport 的类):

import JavaScriptCore

@objc protocol PointExport: JSExport {
    var x: Double { get set }
    var y: Double { get set }
    func description() -> String
}

class Point: NSObject, PointExport {
    @objc var x: Double
    @objc var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
    func description() -> String { "Point(\(x), \(y))" }
}

// 注入到 context
let context = JSContext()!
context.setObject(Point(x: 1, y: 2), forKeyedSubscript: "point" as NSString)
context.evaluateScript("point.x; point.description()")

6.3 与 Block 注入的对比

方式 适用场景
context[@"fn"] = ^(id arg){ ... } 单次或简单逻辑,直接暴露为 JS 函数
JSExport 协议 + 原生对象 需要暴露多个方法/属性、保持对象身份与状态的「桥接对象」

七、JSManagedValue 与内存管理

7.1 为何需要 JSManagedValue

  • JSValueJSContext强引用JSContext 又挂在 JSVirtualMachine 上。
  • 若在 堆上的 OC 对象(如某 ViewController 的 property)中直接强引用 JSValue,而该 JSValue 通过某种方式(例如被注入到 context 的全局对象)又引用回该 OC 对象,会形成 OC ↔ JS 的循环引用,导致 Context 与 OC 对象均无法释放 [1][2]。

7.2 JSManagedValue 的职责

JSManagedValueJSValue 的包装类,用于在「被 OC 堆对象持有」的场景下,以条件保留的方式引用 JS 值,并可与 JSVirtualMachineaddManagedReference:withOwner: / removeManagedReference:withOwner: 配合,让虚拟机在合适的时机断开或保留对 native 对象的引用,从而打破循环、避免 JSContext 无法释放 [1][2]。

7.3 使用要点(概念)

  • 当需要把 JSValue(或从 JS 传回的函数/对象)存为 OC 对象的成员变量时,应使用 JSManagedValue 包装,并以 owner 注册到 JSVirtualMachine;在 owner 析构或不再需要时调用 removeManagedReference:withOwner: [1][2]。
  • 仅临时在栈上使用 JSValue(如 evaluateScript 的返回值在方法内使用后不再持有)时,一般无需 JSManagedValue。

八、关键概念图示与流程

8.1 VM、Context、Value 关系

flowchart TB
  subgraph VM1[JSVirtualMachine 1]
    C1[JSContext 1]
    C2[JSContext 2]
  end
  subgraph VM2[JSVirtualMachine 2]
    C3[JSContext 3]
  end
  C1 --> V1[JSValue]
  C2 --> V2[JSValue]
  C1 -.->|可传值| C2
  C1 -.->|不可跨 VM| C3

同一 JSVirtualMachine 下多个 JSContext 可共享、传递 JSValue;不同 VM 之间不能传递 JSValue [3]。

8.2 JavaScriptCore 引擎执行层级(概念)

源码经 Lexer → Parser 得到语法树并生成字节码后,由下至上的执行/编译层级可概括为:

flowchart LR
  A[源码] --> B[Lexer]
  B --> C[Parser / AST]
  C --> D[字节码]
  D --> E[LLInt 解释器]
  E --> F[Baseline JIT]
  F --> G[DFG JIT]
  G --> H[FTL JIT]
  • LLInt:低级解释器,低延迟启动。
  • Baseline JIT:首次 JIT,兼顾分析与回退。
  • DFG:基于数据流的优化 JIT。
  • FTL:更高优化层(历史上曾用 LLVM/B3 后端)[4][5]。

更多实现细节见 WebKit JavaScriptCore Wiki


九、应用场景与最佳实践

9.1 典型应用场景

场景 说明
Hybrid 应用 在 App 内执行 JS 脚本、调用原生能力(如弹窗、定位、支付),JavaScriptCore 提供 OC/Swift 与 JS 的双向桥接 [1][2]
React Native / 类 RN 方案 早期 RN 等方案在 iOS 上依赖 JSC 执行 JS bundle;JSC 提供 VM、Context、Value 等能力 [3]
JSPatch 等热修复 通过下发 JS 脚本并在 JSC 中执行,动态调用原生类与方法,实现热更新(需注意安全与审核政策)[3]
WKWebView 与 Web 页面 WKWebView 内部使用系统 WebKit,其 JS 引擎与 Safari 一致;独立使用 JSC 时无需 WebView 即可执行 JS [1][2]
规则引擎 / 脚本配置 将业务规则或配置写成 JS,由原生在 JSC 中执行并取结果,便于迭代与 A/B 测试

9.2 最佳实践要点

  • 线程:同一 VM 下多线程会串行等待;需并发执行 JS 时使用多个 JSVirtualMachine [3]。
  • 异常:设置 context.exceptionHandler,在 JS 抛错时记录或上报,避免静默失败 [3]。
  • 内存:在 OC 堆对象中持有 JS 值时使用 JSManagedValue + add/removeManagedReference,避免循环引用 [1][2]。
  • 安全:执行来自网络或不可信来源的 JS 时,需做沙箱与权限控制;避免将敏感 API 无限制暴露给 JS [3]。

十、伪代码与算法说明

10.1 执行脚本并取返回值(概念)

function evaluateScript(script: String) -> JSValue:
  parse script -> AST
  generate bytecode from AST
  execute bytecode (via LLInt or JIT tier)
  return last expression value as JSValue

10.2 将 Native 对象注入 Context(概念)

function setObject(object: Any, forKey key: String):
  if object is Block or conforms to JSExport:
    create JS wrapper (function or object with exported properties/methods)
  else:
    create generic wrapper preserving native type hierarchy
  set wrapper on context.globalObject[key]

10.3 JS 调用 Native Block 时(概念)

JavaScript 侧,调用通过 context[@"key"] 注入的 Block,与调用普通函数一致:

// 假设 Native 已注入:context["makeColor"] = ^(NSDictionary *rgb) { ... }
var color = makeColor({ red: 12, green: 23, blue: 67 });

底层流程(伪代码):

当 JS 调用 context 中注册的 Block 时:
  1. JSC 将 JS 参数按类型转换为 OC 对象(NSNumber/NSString/NSDictionary/NSArray 等)
  2. 调用 Block,传入转换后的参数
  3. 将 Block 返回值按类型转换为 JSValue 并返回给 JS

参考文献

[1] Apple. JavaScriptCore Framework. iOS / macOS Developer Documentation.
[2] 掘金 / 博客. iOS 与 JS 交互开发知识总结JavaScriptCore 初探 等.
[3] 本文原稿与常见 JSC 教程(JSVirtualMachine、JSContext、JSValue、并发与内存).
[4] WebKit. Introducing the WebKit FTL JIT. webkit.org/blog/3362/i…
[5] WebKit. JavaScriptCore - Deep Dive. docs.webkit.org/Deep%20Dive…
[6] trac.webkit.org. JavaScriptCore. trac.webkit.org/wiki/JavaSc…
[7] 美团技术团队. 深入理解 JSCore. blog.csdn.net/MeituanTech…

01-HarmonyOS底层原理|HarmonyOS的各个渲染框架和HarmonyOS图层渲染原理

HarmonyOS 底层原理:各个渲染框架与图层渲染原理

前言


概述

本文主要对 HarmonyOS 页面渲染原理 展开讨论。在讨论本文主题之前,我们需要先了解 HarmonyOS,然后进行一定的知识铺垫,先带大家简单回顾一下 计算机图形渲染原理。若您不想了解 HarmonyOS 的系统背景,可以从第二节「铺垫知识」开始。若您也有一定的 计算机图形学基础,可以忽略前期的知识准备,直接从本文的第三节开始阅读。

本文总共有以下几个章节:


📋 目录


一、HarmonyOS 简述

HarmonyOS 系统由中国的华为公司发行。它作为首款完全自主国产智能移动终端搭载系统,自诞生以来就备受关注,至今为止已经迭代了 3+ 代。国内很多电子发烧友都想进一步了解 HarmonyOS,在此过程中也提出了一些疑问:HarmonyOS 是否是 Android 系统的套皮?(换言之就是怀疑:HarmonyOS 是否是以安卓操作系统为底座,修改了上层的 UI 图形显示界面的系统)。华为公司在多次系统发布会也对 HarmonyOS 的定位、它的设计等各方面做出了介绍。在本文中我们首先从以下几个方面来认识发烧友们的质疑是否可靠:

  • 系统定位
  • 内核对比
  • 运行速度

鸿蒙(HarmonyOS):一款面向万物互联时代的、全新的分布式操作系统。在传统的单设备系统能力基础上,HarmonyOS 提出了基于同一套系统能力、适配多种终端形态的分布式理念,能够支持手机、平板、智能穿戴、智慧屏、车机等多种终端设备,提供全场景(移动办公、运动健康、社交通信、媒体娱乐等)业务能力。

1.1 鸿蒙系统和 Android 系统的定位不同

华为官方对于 HarmonyOS 系统定位的介绍视频 我们可以得知:

Android 和 HarmonyOS 两款产品的研发初衷完全不一样,根本就不在同一个赛道上。安卓系统面向的是手机端,而鸿蒙系统面向的是这些年比较新的概念物联网,致力于利用其 5G 世界领先的技术,优先布局和打造一个超级终端、万物互联的生态。

安卓(Android): 是一种基于 Linux 内核(不包含 GNU 组件)的自由及开放源代码的操作系统,主要使用于移动设备,如智能手机和平板电脑,由美国 Google 公司和开放手机联盟领导及开发。

鸿蒙(HarmonyOS): 是一款面向万物互联时代的、全新的分布式操作系统。在传统的单设备系统能力基础上,HarmonyOS 提出了基于同一套系统能力、适配多种终端形态的分布式理念,能够支持手机、平板、智能穿戴、智慧屏、车机等多种终端设备,提供全场景(移动办公、运动健康、社交通信、媒体娱乐等)业务能力。

1.2 鸿蒙系统和 Android 系统的内核不同

安卓(Android): 基于 Linux 的宏内核设计。宏内核包含了操作系统绝大多数的功能和模块,而且这些功能和模块都具有最高的权限,只要一个模块出错,整个系统就会崩溃,这也是安卓系统容易崩溃的原因。

  • 系统开发难度低。

鸿蒙(HarmonyOS): 基于微内核设计。微内核仅包括了操作系统必要的功能模块(任务管理、内存分配等),处在核心地位具有最高权限;其他模块不具有最高权限,也就是说其他模块出现问题,对于整个系统的运行是没有阻碍的。

  • 微内核稳定性很高。
  • 鸿蒙系统包含了两个内核:
    • Linux 内核
    • LiteOS 内核
  • 内核子系统:HarmonyOS 采用多内核设计,支持针对不同资源受限设备选用适合的 OS 内核。
    • 内核抽象层(KAL,Kernel Abstract Layer) 通过屏蔽多内核差异,对上层提供基础的内核能力,包括进程/线程管理、内存管理、文件系统、网络管理和外设管理等。
  • 驱动子系统:硬件驱动框架(HDF)是 HarmonyOS 硬件生态开放的基础,提供统一外设访问能力和驱动开发、管理框架。

关于鸿蒙系统内核的介绍,我们也可以通过 官方视频 的介绍来进一步认识。

HarmonyOS 底层内核空间 以 【Linux Kernel】作为基石。上层用户空间由 Native 系统库虚拟机运行环境框架层 组成,通过系统调用(Syscall) 连通系统的 内核空间用户空间

对于用户空间主要采用 C++ 和 Java 代码编写,通过 JNI 技术 打通用户空间的 Java 层Native 层(C++/C),从而连通整个系统。

我们今天就以 HarmonyOS 渲染原理为主题,对 HarmonyOS 系统的渲染框架和渲染流水线展开讨论,以为后期在项目实施过程中做技术选型做知识储备!!那就让我们进入今天的正题吧!!!

1.3 鸿蒙系统和 Android 系统的运行速度对比

安卓(Android): 基于 Java 语言编码。Java 语言有个很大的缺点是其不能直接与底层操作系统通信,需要通过虚拟机(JVM)充当中间转换的角色,这是每一个 Java 开发人员都知道的知识点。虽然 Java 语言由于虚拟机的优化、编译器的优化、热点代码等技术使得其越来越快,但是无法直接与操作系统互相通信一直影响着其性能的突破。

鸿蒙(HarmonyOS): 鸿蒙的开发也可以采用 Java 语言,官方也推荐使用 Java 语言开发,但是 华为针对 Java 语言的这种特性,研发了方舟编译器,通过方舟编译器编译的软件可以直接与底层操作系统通信,方舟编译器在这一层面做到了取代虚拟机。通过方舟编译器转换为操作系统能够读懂的机器语言,这样就可以跳过虚拟机解释这一步骤,当然这是肯定对机器的内存要求比较高,应该也存在启动后无法继续优化等问题。

1.4 方舟编译器简单介绍

华为方舟编译器作为一款全新的编译器可以显著提高手机的运行速度,它 不采用现有编译器边解释边执行的模式,而是将这种动态编译改为静态编译,可以做到全程执行机器码,进而高效运行程序,大大缩短程序响应时间

方舟编译器的优势

  • 多语言联合:将同一应用中的不同语言代码联合编译、联合优化,消除语言间的性能「鸿沟」,降低开发者的优化成本
  • 轻量运行时:通过编译器的语言实现能力和优化能力增强,应用运行时的开销更小
  • 软硬件协同:编译器与芯片实现软硬件协同优化,充分发挥硬件能效,应用体验更佳
  • 多平台支持:支持面向多样化的终端设备平台进行编译和运行,根据设备特征提供便捷的开发与部署策略,提高开发效率


二、铺垫知识

HarmonyOS 系统的图形渲染原理其实在核心部分都是和 计算机图形学 的计算机图形渲染原理一样的。所以我们在了解 HarmonyOS 的 视图系统 和其 2D、3D 渲染框架渲染流水线 之前,我们需要进入笔者的这篇文章:计算机图形渲染原理 进行一定的知识准备。

链接 附带的文章中,我们可以了解到「智能硬件 的 CPU、GPU 的设计理念以及两者之间的性能差异」、「计算机图形渲染芯片 GPU 的诞生史」、「围绕 GPU 工作的 3D 图形渲染库(OpenGL、DirectX 等)、图形学相关的专业术语和 OpenGL 工作的渲染流水线」、「屏幕成像的电子束 CRT 扫描原理」、「屏幕成像原理」等诸多相关的核心要点。

您若是不想关注 CPU、GPU,直接了解移动设备的屏幕成像原理,也可以阅读笔者这一份专门为移动而写的简约版:移动终端屏幕成像与卡顿
在这篇文章中,我们可以分别从两个维度去关注:第一个就是 系统成像遇到的 Bug 问题,第二个就是 解决问题的解决方案。几个要点可以简单归纳为:

  • 问题:「屏幕撕裂 Screen Tearing」、「掉帧 Jank」、视图成像切换衔接失误导致的画面空白
  • 解决方案:「Vsync」、「Double Buffering」、「Triple Buffering」

总结:我们这里主要关注屏幕成像的整个渲染流水线,以便于我们后面对 HarmonyOS 的图像渲染原理展开讨论:

① 获取图层渲染数据 → ② GPU 加工成像素数据 → ③ 帧缓冲器(存储像素信息)→ ④ 视频控制器读取缓存 → ⑤ 数模转换、显示器显示

我们今天的主题就是主要关注第一个环节。入手点分为几个:

  • HarmonyOS 系统的**视图层(Layer)视图窗口(Window)**以及系统中的各个图形渲染框架(2D/3D)
  • HarmonyOS 系统的渲染流水线
  • HarmonyOS 系统的事件机制

下面用一张流程图概括从「应用绘制」到「屏幕显示」的通用流水线(与第二节铺垫知识对应):

flowchart LR
  subgraph 应用与框架
    A[应用/ArkUI 绘制]
    B[图层数据]
  end
  subgraph 系统与硬件
    C[GPU 光栅化]
    D[帧缓冲]
    E[视频控制器]
    F[显示器]
  end
  A --> B --> C --> D --> E --> F

三、HarmonyOS 的视图层和视图窗口

本节在不删减原有结构的前提下,对 HarmonyOS 的 窗口(Window)窗口层级视图层(Layer)Surface 等概念做系统性补充,便于与后文「渲染框架与流水线」衔接。相关表述综合自华为/开放原子官方文档、开发者社区与项目实践 [1][2][3][4]。

3.1 窗口子系统与窗口类型

HarmonyOS 的窗口模块(窗口子系统)负责在同一块物理屏幕上提供多个应用界面的显示与交互,其核心职责包括 [2]:

  • 提供应用系统界面的窗口对象
  • 组织不同窗口的显示关系,维护窗口的叠加层次位置属性
  • 提供窗口动效交互
  • 指导输入事件分发

窗口在类型上可分为两大类 [2]:

类型 说明
系统窗口 完成系统特定功能的窗口,如音量条、壁纸、通知栏、状态栏、导航栏等
应用窗口 应用主窗口:显示应用主界面,在任务管理界面中显示;应用子窗口:弹窗、悬浮窗等辅助窗口,生命周期跟随主窗口

应用主窗口与子窗口在尺寸上有约束:宽度范围 [320, 2560] vp,高度范围 [240, 2560] vp(具体以当前版本文档为准)[1]。

3.2 窗口层级与 WindowType

窗口的前后叠加关系WindowTypepriority(优先级) 共同决定 [4]:

  • BelowApp:底层,如桌面、壁纸等,priority = 0
  • App:中间层,应用主窗口(priority = 0)、应用子窗口(priority = 1)等
  • AboveApp:上层,如锁屏(priority = 114)、状态栏(priority = 110)等

同一 WindowType 下,priority 值越大,层级越高,越靠近用户 [4]。窗口模式(WindowMode)可配置为全屏、分屏主/副、悬浮等(如 WINDOW_MODE_FULLSCREENWINDOW_MODE_FLOATING 等)[4]。

flowchart TB
  subgraph AboveApp
    L[锁屏 priority=114]
    S[状态栏 priority=110]
  end
  subgraph App
    M[应用主窗口 priority=0]
    C[应用子窗口 priority=1]
  end
  subgraph BelowApp
    D[桌面/壁纸 priority=0]
  end
  L --> S --> M --> C --> D

系统侧由 WindowManagerService(WMS) 负责窗口的创建、销毁、布局、层级与焦点管理;DisplayManagerService(DMS) 管理 Display 与 Screen 的映射关系。Screen 表示物理屏幕,Display 表示逻辑屏幕,Window 依附于某个 Display [4]。

3.3 UIAbility 与 WindowStage

在应用开发模型中,窗口生命周期与 UIAbilityWindowStage 绑定 [3][5]:

  • UIAbility 是应用组件的一种,代表一个「界面能力」的抽象;一个 UIAbility 可拥有一个主窗口及若干子窗口。
  • WindowStage 在 UIAbility 创建时被建立,负责该 Ability 下窗口的创建与生命周期维护
  • onWindowStageCreate 回调中,应用加载 UI 界面(如 ArkUI 页面),主窗口在此阶段被创建并展示。

因此,从「界面」到「窗口」的链条为:UIAbility → WindowStage → Window(s);渲染框架则基于这些窗口提供的 Surface 进行绘制与合成。

3.4 视图层(Layer)与 Surface

在图形栈中,窗口对应可绘制的表面(Surface)。应用或 ArkUI 将 UI 内容绘制到与窗口绑定的 Surface 上,形成图层(Layer)数据;多个 Layer 由系统的 GPU 合成器(如 Rosen / Render Service) 按 z-order 合成为最终一帧,再送入帧缓冲,经 VSync显示控制器输出到屏幕 [1][2][6]。

  • Surface:可绘制的缓冲区抽象,对应窗口的绘图目标;应用侧通过 Canvas、Skia/OpenGL 等接口向 Surface 提交绘制命令或像素。
  • Layer:可理解为某一层绘制结果(或某棵视图树对应的渲染结果);多层叠加后经合成器合成为一帧。

OpenHarmony 文档与社区资料中常出现 RSSurfaceRSWindow 等接口,用于创建和管理可绘制的表面与窗口,与上述概念对应 [6]。


四、HarmonyOS 的各个渲染框架和渲染流水线

本节系统性地介绍 HarmonyOS / OpenHarmony 的图形栈分层ArkUI 声明式框架2D/3D 渲染框架Measure-Layout-Draw 渲染管线以及 Rosen(Render Service)合成,并给出从应用层到屏幕的完整流水线概览。内容综合自华为/开放原子官方文档、InfoQ 等技术文章及开发者社区 [1][2][6][7][8][9]。

4.1 图形栈整体架构

OpenHarmony 采用自研的图形栈,按分层抽象可分为 [6][7]:

层次 内容说明
接口层 向应用提供 NDK 等能力,包括 WebGL、Native Canvas、OpenGL 指令级支持等
框架层 Render Service(RS)、Drawing、Animation、Effect、显示与内存管理等
引擎层 2D 图形库、3D 图形引擎等

华为开发者官网将 ArkGraphics 2D 作为 HarmonyOS 上二维图形绘制、渲染与显示的核心模块,采用 API 层 — 服务层 — 硬件适配层 的三层架构,支持 ArkTS 与 C/C++ 开发 [1]。整体上,应用 UI 框架(如 ArkUI) 调用 2D/3D 图形 API,由 RS 进行合成与 VSync 调度,最终输出到屏幕。

flowchart TB
  subgraph 应用层
    ArkUI[ArkUI / ArkUI JS]
  end
  subgraph 图形栈
    API[API 层 / ArkGraphics 2D 等]
    RS[Render Service / Rosen]
    Draw[Drawing / 2D 引擎]
    Eng[3D 引擎]
  end
  subgraph 硬件
    GPU[GPU]
    Disp[显示控制器]
  end
  ArkUI --> API --> Draw
  ArkUI --> Eng
  Draw --> RS
  Eng --> RS
  RS --> GPU --> Disp

4.2 ArkUI 框架与声明式渲染

ArkUI 是 HarmonyOS 上主推的 声明式 UI 框架,面向 1+8+N 多设备,支持 ArkUI JS(类 Web/小程序范式)与 ArkUI eTS(声明式 + 方舟编译器)两套开发范式 [7][8]。从渲染角度看,ArkUI 可概括为 [7][8][9]:

  • 声明层:通过 build() 描述 UI 结构,用 @State / @Prop / @Link 等装饰器管理状态,遵循 UI = f(State) 的声明式范式。
  • 节点层:将声明式描述转化为内部可计算的节点树(Component 树、Alignment 树、Render 树等),支持细粒度更新,避免整树重算。
  • 渲染管线层:在 VSync 驱动下,经历 Measure → Layout → Draw,最终通过统一的渲染引擎(如 Skia 或华为自研引擎)将内容绘制到 Surface [7][8]。

ArkUI 采用前后端分离:前端为声明式 DSL(eTS 或类 Web),后端为 C++ 编写的声明式后端引擎,包含布局、动画、多态组件、自绘制渲染管线等;底层使用统一的框架层渲染引擎(当前文档多提及 Skia,华为亦在自研替代方案)[7][8]。

4.3 2D 与 3D 渲染框架

HarmonyOS 在应用层可归纳为两类典型渲染路线 [1][9]:

方式 适用场景 说明
ArkUI + Canvas / 内置组件 常规 UI、轻量 2D 动效、小游戏 使用 ArkGraphics 2D、Canvas 等 API,由框架完成 Measure-Layout-Draw
XComponent + Native(OpenGL ES) 复杂 3D、高性能图形、游戏 通过 XComponent 获得 Native 层 Surface,直接调用 OpenGL ES,细粒度控制

ArkGraphics 2D 提供画布操作、图元绘制(几何、图片、文本)、文本模块、可变帧率、Vsync、Window 管理等能力 [1]。3D 渲染则依赖系统图形子系统(含 Rosen/RS)提供的 Native 缓冲区与 OpenGL ES/Vulkan 等接口,实现完整渲染管线控制 [9]。

4.4 渲染管线:Measure、Layout、Draw

ArkUI 的 UI 渲染管线与常见移动端框架一致,分为三个阶段 [8][9]:

  1. Measure(测量):系统询问每个组件的尺寸需求,父容器根据子元素约束与自身约束计算每个节点的宽高。
  2. Layout(布局):根据测量结果与布局规则(如 Column、Row、Flex)确定每个组件在父容器中的位置(x, y)。
  3. Draw(绘制):将组件的几何、图片、文本等绘制到 Surface 对应的缓冲区,最终由 RS 合成并送显。

伪代码(概念)

function renderFrame():
  for each node in renderTree (from root to leaf):
    node.measure()   // 测量宽高
  for each node in renderTree (from root to leaf):
    node.layout()    // 确定 x, y
  for each node in renderTree (in draw order):
    node.draw()      // 绘制到 Surface
  submitToRenderService()

自定义 NDK 组件可通过 onMeasure / onLayout / onDraw 等回调接入该管线;测量与布局相关的 API(如 measureNodelayoutNodesetMeasuredSizesetLayoutPosition)需在对应的 ARKUI_NODE_CUSTOM_EVENT_ON_MEASUREARKUI_NODE_CUSTOM_EVENT_ON_LAYOUT 回调中使用 [9]。

flowchart LR
  M[Measure 测量]
  L[Layout 布局]
  D[Draw 绘制]
  RS[Render Service]
  M --> L --> D --> RS

4.5 Rosen / Render Service 与合成

Rosen 是 OpenHarmony 的 GPU 合成与显示服务,在架构上类似 Android 的 SurfaceFlinger,负责 [6][7]:

  • 管理 RSSurfaceRSWindow 等可绘制表面与窗口;
  • 接收各应用/窗口提交的图层数据,按 z-order 与可见性进行 GPU 合成
  • DisplayManager 配合,分发 VSync 信号,实现帧同步与双缓冲/三缓冲,减少撕裂与掉帧。

可通过系统调试命令(如 hidumper -s RenderService)查看 RS 状态、屏幕、节点、FPS 等信息 [7]。图层数据经 RS 合成后写入帧缓冲,再由视频控制器读取并输出到物理屏幕,与第二节「铺垫知识」中的流水线一致。

4.6 从应用层到屏幕的完整流水线

将上述各节串联,从「应用 UI」到「屏幕显示」的完整流水线可概括为:

  1. 应用层:ArkUI(或 Native UI)根据状态构建/更新 Component 树 → Render 树,在 VSync 触发下执行 Measure → Layout → Draw
  2. 绘制输出:Draw 阶段将内容绘制到各窗口对应的 Surface,生成**图层(Layer)**缓冲区。
  3. 合成Render Service(Rosen) 收集所有窗口的 Layer,按层级与区域进行 GPU 合成,输出一帧到帧缓冲
  4. 显示视频控制器VSync 同步下读取帧缓冲,经数模转换输出到显示器

整体与第二节给出的「① 获取图层渲染数据 → ② GPU 加工 → ③ 帧缓冲 → ④ 视频控制器 → ⑤ 显示器」一致,HarmonyOS 在「①」环节通过 ArkUI、ArkGraphics 2D、Rosen 等框架与服务实现了从视图到图层的完整链路。

flowchart TB
  subgraph 应用
    A[ArkUI build/update]
    B[Measure / Layout / Draw]
  end
  subgraph 系统图形
    C[Surface / Layer]
    D[Render Service 合成]
    E[帧缓冲]
  end
  subgraph 硬件
    F[VSync]
    G[显示器]
  end
  A --> B --> C --> D --> E --> F --> G

五、总结

通过前面的介绍,我们基本知道了:

  • HarmonyOS 的定位是面向万物互联的分布式操作系统,与 Android 在定位、内核(微内核 vs 宏内核)、运行速度(方舟编译器) 等方面存在差异;底层以 Linux 内核为基石,用户空间通过 JNI 等连通 Java 与 Native。
  • 铺垫知识 部分强调了计算机图形渲染原理与移动端屏幕成像(Vsync、多缓冲)的通用流水线,本文主题聚焦该流水线的第一个环节:视图层、窗口与渲染框架。
  • 视图层与窗口:HarmonyOS 通过窗口子系统(WMS、DMS)管理系统窗口应用窗口(主窗口/子窗口),窗口层级由 WindowType + priority 决定;UIAbility / WindowStage 负责应用侧窗口生命周期;Surface / Layer 是绘制与合成的载体。
  • 渲染框架与流水线:图形栈分为接口层、框架层(含 RS)、引擎层ArkUI 提供声明式 UI 与 Measure–Layout–Draw 管线;2DArkGraphics 2D 为主,3D 通过 XComponent + OpenGL ES 等实现;Rosen(Render Service) 负责图层合成与 VSync,最终与帧缓冲、显示控制器一起完成从应用到屏幕的完整成像链路。

本篇文章,没有解决的问题如下:

  • HarmonyOS 系统事件机制(输入事件从硬件到应用的分发路径、与窗口/焦点的关系)的详细梳理;
  • ArkUI 与 Flutter / SwiftUI 在渲染管线与性能上的对比分析;
  • 更多性能调优卡顿排查在 HarmonyOS 上的具体工具与步骤(如 RS 的 hidumper、ArkUI 的布局与绘制耗时分析)。
  • ……

参考

  • 见文末 参考文献

六、文章推荐


相关阅读(共计 14 篇文章)

iOS 相关专题
webApp 相关专题
跨平台开发方案相关专题
阶段性总结:Native、WebApp、跨平台开发三种方案性能比较
Android、HarmonyOS 页面渲染专题
小程序页面渲染专题
总结

参考文献

[1] 华为开发者. 图形绘制概览 / ArkGraphics 2D(HarmonyOS 文档). developer.huawei.com/consumer/cn…
[2] HarmonyOS 应用窗口管理(Stage 模型)等. 博客园 / 华为云社区.
[3] 深入理解 HarmonyOS UIAbility:生命周期、WindowStage 与启动模式. 华为云社区. bbs.huaweicloud.com/blogs/41689…
[4] OpenHarmony 窗口子系统基本概念与流程分析. 掘金. juejin.cn/post/751099…
[5] 深入解析 HarmonyOS 5 UIAbility 组件:从核心架构到实战应用. CSDN.
[6] 深入解析 OpenHarmony:图层渲染与合成 SurfaceBuffer 实践指南. 百度云. cloud.baidu.com/article/327…
[7] OpenHarmony 实战开发——图形框架解析. 腾讯云开发者. cloud.tencent.com/developer/a…
[8] InfoQ. HarmonyOS ArkUI 框架的实现原理和落地实践. www.infoq.cn/article/tsa…
[9] HarmonyOS 开发者社区 / CSDN. ArkUI 渲染管线、Measure/Layout/Draw、自定义组件 NDK 等.
[10] 掘金. 鸿蒙 HarmonyOS 实战 - 窗口管理. juejin.cn/post/741784…

❌
❌