前言
出来混,凡事要讲证据的,你说卡就卡吗阿 sir。
你说这里卡,证据是什么?有一些卡顿,人的肉眼无法察觉,而且不容易稳定复现,测试说卡研发说说不卡,到底咋整?又是因为什么导致的卡顿,代码那么多那么长,逻辑那么多那么乱,总不能一个个排查过去吧。如果你也有这些苦恼,那么就是时候用 Xcode Instruments 了。
这篇文章会从一个治理列表卡顿的例子出发,讲清楚如何使用 Xcode Instrument 中的 Animation Hitches 找到卡顿的原因,不会展开具体优化的方法,只到定位到问题的那一步。然后,会详细说明 Hitches 中的那些指标的含义。
定位问题的大致流程是这样的:
- 以 Profile 模式运行项目
- 先选择 Animation Hitches,这个可以定位到那一帧没有按时提交
- Hitch type 会告诉你是哪个阶段导致的耗时
- Frame LifeTimes 会告诉你具体的耗时时间区间
- 筛选时间区间,使用 Time Profile 定位到具体的耗时方法
- 分析这个方法中在做什么,如何优化
- 重复这个流程,直到你觉得性能已经 OK 了
Profile 模式运行 App,会弹出 Instrument 面板:

在开始之前,可能需要对 iOS 的渲染流程有一个基本的了解,可以先看看 深入理解 iOS 渲染原理。
Animation Hitches
进入 Animiation Hitches 之后,点击左上角,会启动你的项目,然后你进入到你想排查的页面,比如我的要排查列表卡顿的问题,我进入到列表页面,进行快速的滚动,操作完成之后,点击结束,你就会得到一副分析图:

最上面的那一条条橙色绿色蓝色的竖线(颜色不代表耗时的严重程度,仅用于区分不同的帧),就代表一个 Hitch,Instrument 中产生一个 Hitch 的条件是:
某一帧的实际显示时间,晚于这帧允许的预计显示时间。
也就是说,这一帧迟到了。
我们先来看红色框中的那些指标的含义:
-
Htich Begin:预计上屏时间
-
Presentation:实际上屏时间
-
Htich Duration:晚到了多久
-
Acceptable latency:从这一帧开始处理,到预计上屏,允许使用的总时间
-
Buffer Count:使用了多少个帧缓冲区
-
Severity:这个 Hitch 的严重程度
- Low:晚了不超过 1 个刷新间隔
- Moderate:晚了超过 1 个、不超过 2 个刷新间隔
- High:晚了超过 2 个刷新间隔
- 60Hz 下,刷新间隔约为 16.7 ms
-
Hitch Type
- Pre-Commit(s) latency:Commit 还没开始,预算就耗尽了
- Expensive Commit(s):Commit 阶段超过预算
- Commit to Render latency:App 已提交,Render Server 没即使开始
- Expensive Rendering:Redner Server 在 CPU 上处理 layer tree 太慢
- CPU to GPU Rendering Latency:渲染命令准备好了,没及时交给 GPU 执行
- Expensive GPU:GPU 绘制渲染阶段超过预算
- Delay Frame Swap:CPU、GPU 已完成,但帧没有及时交换到屏幕
Buffer Count = 2, 说明当前显示管线使用了 2 个帧缓冲区,用来保存正在显示、等待显示或正在渲染的画面。
Buffer A:屏幕当前正在显示的帧
Buffer B:GPU 正在绘制下一帧
当下一帧绘制完成,在垂直同步信号 VSync 到来的时候,两者发生交换。
然后 Buffer A 可以继续被用于绘制后续的帧。
有些设备的是三缓冲:
Buffer A:屏幕当前正在显示
Buffer B:已经绘制完成,等到显示
Buffer C:GPU 正在绘制后续帧
这里 Apple 的说明是:默认使用两个 Buffer,当渲染发生延迟、Render Server 尝试追赶时,系统可以通过三缓冲继续维持渲染流水线。
知道这些概念后,我们再来看什么是 "这一帧迟到了"。
以刚刚截图中的 3 举例:
预计上屏时间(Hitch Begin):00:11.794.540 实际上屏时间(Presentation):00:11.827.876 迟到时间(Hitch Duration)= Presentation - Hitch Begin = 33.34 ms 允许处理时间(Acceptable latency):33.33 ms
因此这一帧的总生命周期为 33.33 + 33.34 = 66.67ms。
Frame Lifetimes
选中我们刚刚说的第 3 帧,放大,然后展开 Hitches:

Frame Lifetimes,是这一帧的生命周期,我们前面算过:
第 3 帧从开始到上屏的时间是 33.33 ms + Hitch Duration 33.34 ms = 67ms。
第一行,是 Hitch Duration,可以看到,蓝色竖线的时间点,就是 Hitch Begin,也就是原本应该上屏的时间,但是看 Commits 那一栏,可以发现 Commit 还没开始,说明时间主要耗在了 Commit 之前。
所以对于这一帧,我们需要分析的区间是:
Frame Lifetimes 左边缘 -> Commit 色块左边缘。
重合的情况
有时候你看 Frame Lifetime,会发现有重合的情况:
第 1 帧:

第 2 帧:

这个是正常的,因为渲染是流水线处理的,不同帧会同时处于不同的状态。
比如 60Hz、双缓冲时:
css
时间: 0ms 16.7ms 33.3ms 50ms
帧 A: [App 处理] [Render/GPU] [上屏]
帧 B: [App 处理] [Render/GPU] [上屏]
对应的 Frame Lifetime:
css
帧 A:0ms ───────── 33.3ms
帧 B: 16.7ms ───────── 50ms
两帧的生命周期会重合约 16.7 ms。
这表明同一时间可能是:
- App 正在准备帧 B
- Render Server 正在处理帧 A
- GPU 正在绘制更早的帧
Time Profile
接着分析这一帧,长按选中从 Frame Lifttimes 到 Commit Phase 的这块区域:

红色方块,代表这段区域我们在执行的代码,有个人物标志的,代表是用户代码,另外,这里的代码是一个包含的关系,上面的包含下面的代码。
可以看到,在 47 ms 的时间中,有 38 ms 都在处理这个代码,鼠标悬浮到那一行,会出现:

点击跳转可以直接定位到这个代码在项目中的位置:

至此,原因也就出来了,是因为 UIGraphicsImageRenderer 这个方法导致的耗时,这个方法内部,其实就是 sourceImage.draw() 方法,这里在主线程,去将图片数据绘制成一张位图:
- JPEG 压缩数据
UIImage(data:)创建sourceImagedraw(in:)触发 JPEG 像素解码- 缩放并绘制到新的位图缓冲区
- 得到位图形式的
UIImage
因为我们的 JPEG,只是一组压缩数据,它真正需要展示在屏幕上,是需要经过解码,绘制成位图,然后再展示的。
也正如 Time Profile 所示,解码实在是一个耗时的操作。
所以问题是:
主线程进行位图的绘制,会导致触发图片解码操作,从而影响绘制速度。
图片解码耗时,要么缩小图片尺寸,要么放到后台去做,我们使用放到后台去解码,然后解码是根据图片的尺寸来的,但是我们展示的容器并不需要那么大尺寸的图片,直接解码成显示所需要的尺寸,绘制成位图后,再回主线程赋值,后台解码期间,使用占位图占位。
这里只是为了演示怎么用 Instruments 定位到原因,优化的部分就不展开了。
Animation Hitches 里面有什么

被我马赛克掉的是项目名称。
Hitches 中,是按时间顺序排列的,从这里也可以看出苹果是怎么处理一帧画面的渲染的。
从一个用户操作(User Events)开始,到 App 侧的各种计算,提交到 Render Server,再到 GPU,Frame Lifetimes 是一帧的生命周期。
一个个说,不过前面 Hitch Type 的内容,我觉得有必要先补充一下。
Hitch Type
Hitch Type 是 Instrument 根据帧的生命周期中各阶段和时间预算的关系,给出的 "预算在哪个阶段被用完了" 的分类,类型前面已经列举过了,这里会记录一下,这些类型的超时,大概是什么原因引起的。
需要说明的是,Hitch Type 是用来提示帧在哪个阶段发生延迟,以及应该从哪里开始调查。最终原因还是需要借助其他手段,比如 Lifetimes、Commits、Renders、GPU、Time Profiler 来确认。
还有这些类型不是判断 "某个阶段自身是否超过了完整的预算",因为 Instrument 采用的是是累计判断,也就是说,到这个阶段预算被用完了,但是不一定是这个阶段本身导致的。
先说一下 iOS 大致的渲染过程:
- App 侧计算好内容
- Commit 到 Render Server
- 提交到 GPU
- 绘制进帧缓冲区
- 上屏
1. Pre-Commit(s) latency
rust
帧开始 -> 超时 -> Commit 开始
还没进入到 Commit 区间,就超时了。
这里的 Commit,指的是 Core Animation Commit:
App 将当前这一帧的图层树变化提交给 Render Server。
常见原因:
- 业务数据处理
- 图片解码、缩放
- 文本计算
- CollectionView / TableView 数据源处理
- 锁等待
- 同步 I/O
- 主线程调度延迟
这一步都发生在 App 侧,主要是由 CPU 处理的,通常涉及主线程。
2. Expensive Commit(s)
rust
Commit 开始 -> 超时 -> Commit 结束
执行 Commit 的过程中超时,常见原因:
layoutSubviews- Auto Layout
drawRect- 图片准备和解码
- 图层树遍历与打包
- 将图层树提交给 Render Server
Pre-Commit 和 Commit 都是在 CPU 侧,这也是我们需要处理的大头。
3. Commit to Render latency
rust
Commit 完成 -> 超时 -> Render Server 开始
App 提交了,但 Render Server 没有及时开始处理。
这段时间主要是阶段之间的等待,不能直接归因与某个 App 函数,可能涉及:
- 等待下一个 VSync/VBL
- Render Server 调整
- 系统渲染负载
- 其他进程或窗口的渲染竞争
我们的重点一般不需要放在这里。
4. Expensive Rendering
rust
Render Server CPU 开始 -> 超时 -> 结束
Render Server 需要先在 CPU 上解析 App 提交的 layer tree,并生成 GPU 可以执行的渲染命令。
常见原因:
- 图层数量过多
- 层级太深
- 复杂 mask
- 动态阴影
- blur / vibrancy
- 复杂图层组合
- 大量录屏渲染准备
这些也是我们在写代码的时候需要避免的,否则就会加大这里的工作量,从而导致超时。
另外这部分发生在 Render Server 进程,而不是 App 主线程,因此不能只用 App 的 Time Profiler 调用栈来定位。
5. CPU to GPU Rendering Latency
rust
Render CPU 完成 -> 等待时间过长 -> GPU 开始
Render Server 已经准备渲染命令了,但 GPU 没及时开始执行。
它表示的是 CPU 与 GPU 两个阶段之间的延迟,可能原因有:
- GPU 队列繁忙
- 系统 GPU 竞争
- 命令提交或调度延迟
它并不意味着 "Render CPU 本身慢",也不意味着 "GPU 执行本身慢"。
这里也不是我们要治理的重点。
6. Expensive GPU
rust
GPU 开始 -> 超时 -> GPU 完成
GPU 执行超时,常见原因有:
- 大面积透明混合
- blur
- 动态阴影
- mask
- 大图片纹理
- 高分辨率图片
- 多次离屏渲染
- 屏幕上同时存在大量动图
这里重点是看 GPU 和 Renders 轨道里的 Render Count、Offscreen Count,而不是只查主线程。
7. Delay Frame Swap
rust
GPU 完成 -> 等待时间过程 -> 屏幕显示
最后一步了,这里说明 GPU 生成好的帧,没有及时在 VSync 到来时交换到屏幕。
可能的原因:
- Frame Swap 错过 VSync
- 显示系统调度
- Buffer 状态
- 系统级渲染压力
这点我们基本无能为力。
User Events
里面包含了三个部分:
Input -> Handover -> Processing
Input
系统记录到这次 HID 输入的时间点,例如触摸、拖动等输入。
Handover
UIKit 准备把这个输入事件交给 App 主线程处理的时间点。
需要注意的是,这只表示事件已经准备交给主队列,不代表主线程已经开始执行事件回调。
不过这个我在测试的时候,经常看不到它,它不是每个输入都必然显示的。
Processing
App 实际分发和处理这个 UIEvent 的时间区间,从时间分发开始到时间分发结束。
这期间可能执行:
- 触摸事件分发
- 手势识别
- Responder Chain(响应链)
UIScrollView滚动处理- 控件的
action - 由事件直接触发的 App 代码
同一个输入也可能关联多个 Processing 区间。
所以这三个节点可以这么理解:
css
用户输入被系统记录
↓ Input
事件准备交给 App 主线程
↓ Handover
主线程开始分发并处理事件
↓ Processing
事件处理产生 UI 变化
↓
进入后面的 Commit、Render、GPU
分析的时候可以重点关注:
- Input → Handover 很长:延迟主要发生在输入送达主线程之前。
- Handover → Processing 很长:事件已经等待处理,但主线程迟迟没有空出来。
- Processing 很长:主线程处理这次输入事件本身耗时较长,需要结合 Time Profiler 查调用栈。
看个 demo,在一个拖动的回调中加一个 80ms 的耗时任务:

可以很明显的看到 Processing 过程占据了 Frame 生命周期很大的篇幅。
Commits
Commits 的定义:
与当前 Hitch 帧关联的各个进程执行 Core Animation Commit 的时间区间。
当 App 修改视图、图层属性后,这些变化最终需要整理成 Core Animation 的图层上下文,并提交给 Render Server。Instruments 把记录的这段 Core Animation Commit 显示在 Commits 轨道中。
这里需要区分 App 定义的广义上的 commit transaction,广义上包含四个阶段:
- layout
- display
- prepare
- commit
Instrument 的 Commits 轨道:根据 Core Animation 内部的 commit 追踪事件记录出来的区间,不能简单等同于整个广义的 transaction。
这里之所以是 Commits 而不是 Commit,是因为它可能包含多个进程的 Commit,比如这里的:
- SpringBoard
- 你的 App
SpringBoard 是 iOS 的系统界面进程,这个区间表示 SpringBoard 管理的某个系统图层上下文也在这一帧发生了 Commit。
如果 SpringBoard 和你的 App 出现在一起,并不是说 SpringBoard 调用了你的项目代码,它们的耗时也不是叠加的关系,只能说明,这一帧关联了两个进程的 Core Animation Commit。
Commits 能告诉我们:
- 当前 Hitch 帧关联了几次 Commit
- 每次 Commit 由哪个进程产生
- 每次 Commit 从什么时候开始、持续多久
- 客户端的时间预算是否在 Commit 区间内被耗尽
Renders
Renders 的定义:
Render Server 为当前 Hitch 帧执行渲染准备工作的时间区间。
它是在 Commit 和 GPU 中间的,这个时间区间主要是 Render Server 在工作:
- 汇总当前画面涉及的图层上下文
- 遍历图层树
- 处理图层顺序、变换、透明度、裁剪等合成信息
- 处理 Core Animation 动画系统
- 判断是否需要额外的离屏渲染
- 把图层树编译成 GPU 可以执行的渲染操作序列
Apple 把这部分称为 render prepare。之后 GPU 才在 render execute 中真正绘制像素。
Render Server 是系统的另外一个进程,不是在我们自己的 App 进程中的,它的工作量受 App 提交的图层树影响,例如:
- 图层数量多、层级深
- 动态阴影
- Mask
- 模糊和
UIVisualEffectView - 复杂的圆角裁剪
- 大量透明图层
- 需要多次离屏渲染
Render Count
如果在详情中看到 Render Count,说明这里有额外的的 Render / 离屏渲染。
可以进一步检查:
- 动态阴影是否设置了
shadowPath - 是否存在不必要的
Mask - 圆角裁剪是否产生离屏渲染
- 图层结构是否过深
- 是否存在复杂视觉效果
Renders 与 Hitch Type
如果客户端 Commit 已经完成,但 Render Server 的准备阶段用完了预算,Hitch Type 可能是 Expensive Rendering。
如果 Renders 本身很短,但结束后迟迟没有交给 GPU,则更可能是 CPU to GPU Rendering Latency。
GPU
这个区间的定义:
GPU 为当前 Hitch 帧执行渲染命令的时间区间。
到这一步,Render Server 已经把图层树整理成 GPU 可以执行的渲染操作。然后 GPU 会执行这些操作,将图层、图片、文字和视觉效果合成到最终的画面中。
Apple 把这个阶段称为 render execute。
GPU 这里的轨道图会告诉我们:
- GPU 什么时候开始处理这一帧
- GPU 在什么时候完成这一帧
- 预算是否是在这里耗尽的
分三段,如果是
-
Render Server 结束 -> GPU 开始
- 那么 Hitch Type 可能是
CPU to GPU Render Latency,
- 那么 Hitch Type 可能是
-
GPU 开始 -> GPU 结束
- 可能是
Expensive GPU
- 可能是
-
GPU 结束 -> Presentation
- 可能是
Delay Frame Swap
- 可能是
GPU 和 Renders 差不多,主要也是看 App 提交的图层树怎么样,我们从 App 提交到 Render Server 之后,怎么去走下一步,是系统自己的调度了,所以 GPU 渲染期间如果有什么问题,大概率还是我们提交的图层树太复杂:
- 离屏渲染 Pass 的数量
- 阴影、模糊、Mask 等每个 pass 的处理成本
- 需要反复绘制、复制、合成的像素范围
- 透明图层重叠造成的混合和 overdraw
- 图片纹理采样及其内存带宽消耗
Display
上面都是 Hitches 中的内容,Display 不是计算的阶段了,它是最终显示结果的轨道。
和之前的轨道不一样的额是,前面的轨道的含义是:这一帧在哪里处理?
Display 是:
最终有哪些帧真正显示出来,以及每帧在屏幕上停留了多久。
比如在稳定的 60Hz 动画期间:
- 一帧显示约 16.67 ms:正常更新。
- 某一帧显示约 33.33 ms:下一帧错过一次 VSync,旧帧多停留了一个刷新间隔
- 某一帧显示约 50ms:大约错过两次 VSync
Thread State Trace
线程占用 CPU 时,具体在执行什么函数?
线程当时到底有没有运行?如果没有运行,是被阻塞了,还是已经就绪但没有获得 CPU?
它不是在渲染流程中的一个阶段,而是辅助定位 App 线程为什么没有及时完成工作的一个工具。
常见的线程状态包括:
Running:线程正在某个 CPU 核心上执行代码Runnable:线程已经可以运行,但还没获得 CPUBlocked:线程正在等待某个条件,例如锁、信号量、I/O、Mach 消息或者 RunLoop 事件Preempted:线程原本在运行,但被调度器移出了 CPU,让其他工作先执行Interrupted:CPU 执行硬件中断,暂时中断了当前线程Idle:调度器记录的 CPU 空闲区间,没有合适的线程需要在该核心上执行Terminated:线程已经结束Unknown:Instruments 没有足够信息确定该区间的线程状态

图中的字段含义:
- Count:记录到的状态区间数量,不是线程数量
- Duration:所有这些状态区间的累计市场
- Min Duration:最短一次状态区间
- Avg Duration:平均一次持续时间
- Std Dev Duration:每次持续时间的离散程度
- Max Duration:最长一次状态区间
当主线程正在执行耗时代码时,是用 Time Profiler 看调用栈。
当主线程的 CPU 使用率很低、Time Profiler 中甚至是 No Data,但 UI 仍然没有响应时,就应该看 Thread State Trace。因为被阻塞的线程不占用 CPU、Time Profiler 采样不到。但 Thread State Trace 可以记录它阻塞了多久。
Thermal State
同样不是渲染流程中的阶段,是性能分析的环境信息:
当设备温度升高时,系统可能采取降温措施,降低可用的 CPU、GPU 等系统性能,从而让原本能够按时完成的代码开始出现 Hitch。
它记录的是系统综合判断出的热压力等级,不是具体温度,也不能告诉你是 App 的哪个函数导致了发热。
它有几种状态:
- Nominal:温度处于正常范围
- Fair:温度轻微升高,系统采取的措施会降低性能
- Serious:热状态较高,系统采取的措施会降低性能
- Critical:热状态已经明显影响系统性能,设备需要降温
- Unknown:Instruments 没有取得可用的热状态数据,不能理解为温度正常
这里需要注意因果关系:
App 长时间高负载,导致设备温度升高,然后系统采取降温措施,可用性能下降,Commit / Render / GPU 这个阶段更容易超过时间预算。
所以 Thermal State 可能是卡顿的放大因素,但它不能定位到具体原因。
如果测试期间已经到了 Serious,最好让设备冷却后重新采集,不然可能会因为发热导致对比环境不一样。
应用本身可以通过 ProcessInfo.processInfo.thermalState 读取这个状态。
Hangs
它跟 RunLoop 有关,它是关注的是:
App 的主线程有没有长时间无法处理新的事件。
啥意思呢?比如点了一个按钮,主线程开始处理图片、读文件或等锁。在这些处理结束之前,新的点击是无法得到处理的,用户就会感觉 App 卡住了,这就是 Hang。
主线程是有 RunLoop 的,它一直在不断的重复:
- 等待事件
- 收到并处理事件
- 更新 UI
- 重新等待事件
如果主线程处理一次任务的时间太长,一直回不去 "等待事件" 的状态,Instruments 就会把这段时间标记成一个潜在的 bug。
总结
先知道 iOS 的渲染流程,从 App 侧开始,到 Render Server,到 GPU,到上屏,这是一条完整的链路,我们要做到的知道这条链路哪里在消耗性能,而 Instruments 能够采集到这条链路上的各种操作的指标,帮助我们去定位问题。
比如:一个滚动的列表你觉得卡顿了,先用 Animation Hitches 找到卡顿的帧,结合 Frame Lifetimes、User Events、Commits、Renders、GPU、Display 这些指标,确认这一帧从输入、App 处理、Render Server、GPU 到最终上屏都分别经历了什么。
如果问题在 App 的 CPU 侧,并且是主线程的问题,就用 Time Profiler 定位耗时的方法。如果问题发生在 Render Server 或 GPU,需要检查图层树、离屏渲染和实际 GPU 执行情况。Thermal State 可以确认测试结果是否收到发热的影响。
然后循环往复,直到 Hitch 一个一个被消除。
Instruments 看着花花绿绿的,也挺好看的。