前言
很难想象 2026 年了还有人在写 iOS 的文章
这篇文章会从屏幕如何成像讲起,解释页面为什么会出现卡顿现象,梳理屏幕呈现一帧画面需要经历的流程,然后分析这个流程中的哪些环节可能会造成画面卡顿,并提供相应的解决方案。
大致有以下内容:
- 屏幕成像原理
- 画面撕裂原因
- 垂直同步信号及其作用
- 卡顿形成的原因
- iOS 渲染流程
- 图片的解码
- 异步绘制
- 提高流畅度的方案
另外,基于 iOS 的渲染流程,以及每个阶段可以做的优化,写成了一份针对 iOS 页面流畅度的 skill,可以让 ai 直接按照这个 ios-ui-performance-skill skill,去分析和调整项目中可能会影响流畅度的地方。
现在开始,我们从屏幕成像说起。
屏幕如何成像?

我们的屏幕在物理硬件上是由一个个像素点组成的。之所以能呈现不同的画面,是因为每个像素点可以发出不同的光。这些像素点一排排、一列列地组成屏幕,形成一个像素矩阵。
屏幕通过驱动电路进行扫描,扫描按行进行,从左往右、从上往下扫完一遍,就是一帧画面。但它并不是扫完之后才会出现一帧画面,因为扫描是一行一行进行的,扫到哪一行,那一行的像素就会发生变化,我们也就能识别到变化。所谓 120Hz、60Hz 的屏幕,指的是 1 秒钟内扫描发生的次数。120Hz 的屏幕也就是所谓的高刷屏,高刷就是高刷新率,1 秒钟可以显示 120 帧画面;而 60Hz 的屏幕,1 秒钟只能显示 60 帧画面,这就是为什么 120Hz 比 60Hz 更细腻。因为所谓的动画,就是由一幅幅静止图像连续地展示形成的,同等时间内展示的画面越多,就能做到越细腻。
完成一帧画面的扫描后,显示器就会发出一个垂直同步信号。显示器通常以固定频率刷新,这个刷新率就是垂直同步信号产生的频率。打游戏时,有些游戏可以设置是否开启垂直同步,说的就是这个垂直同步信号。
垂直同步信号,最初是用来解决画面撕裂的问题。
画面撕裂是怎么产生的?

首先要知道的是,显示器和 GPU 是各干各的。显示器的工作很机械,它从屏幕最上面一行开始,一行一行往下刷新,刷到最底下一行的时候,一帧就显示完了,然后回到顶上再来一遍。它的速度是固定的,比如一秒钟刷 60 遍,谁也改变不了它的节奏。
它刷新的时候,内容是从一块内存里面读出来的,这块内存就叫帧缓冲区。GPU 的任务,就是把新画面写进这块内存。
这就会出现一个问题:显示器在读,GPU 在写,操作的是同一块内存,会打架。所以系统准备了两块缓冲区:显示器读 A 的时候,GPU 往 B 里画新一帧。B 画好了,两块一交换,显示器接着读 B,GPU 再往 A 里画下一帧。这就是双缓冲。
而画面撕裂,就出现在 什么时候交换 这个点上。想象一个场景:显示器正在读 A,刚刷到屏幕中间,这时候 GPU 说"我画完了",啪一下把缓冲区换成了 B。其实显示器根本不知道发生了什么,继续往下刷,但它下半截读到的已经是新画面了。于是这一屏就变成,上半截是旧的一帧,下半截是新的一帧。如果画面正在动的话,比如游戏里镜头正在左右转,上下两截就错开一块,这就是撕裂。
为什么裂缝一定是横的?因为显示器是一行一行从上往下刷的,交换发生的那一瞬间刷到哪一行,裂缝就在哪一行。
垂直同步信号的作用
垂直同步信号的作用,就是给"交换缓冲区"立一条规矩:不许随时换,必须等显示器把当前这一帧完整刷完后再换。前面提到,显示器刷完一帧后会发出垂直同步信号。就算 GPU 提前画完了,也得拿着新画面等待这个信号,等显示器把当前帧刷完后才进行交换。这样,显示器每一遍刷新时读取的都是同一块完整缓冲区,画面始终是完整的一帧,也就不会发生撕裂。
当然,代价就是 GPU 画得再快也得等信号,所以帧率会被锁定在刷新率上。如果 GPU 画得慢了一点,没赶上这次信号,就只能等下一次------这一帧会在屏幕上多停留一个周期,用户看到的就是画面顿了一下,这就是掉帧。
在 iOS 里,垂直同步是强制开启的,而且它的角色不止是防止画面撕裂,整个渲染系统都是被它驱动着走的,CADisplayLink 就挂在这个信号上,Core Animation 每一帧的提交也是踩着它走的。
垂直同步信号如何传进 App?
垂直同步信号的本质,就是显示器在每个刷新周期的边界发出的一个中断。操作系统收到之后,把它变成消息分发给需要的对象。在 App 里面,它最直接的化身就是 CADisplayLink:可以在 App 中监听 CADisplayLink,注册之后,系统会在每个节拍点回调一次------很多帧率监控工具就是这么做的。
另外,当页面在连续变化的时候,比如列表正在滚动,主线程实际上也是被这个节拍一拍一拍的唤醒的:醒来干一轮活,睡下,下一拍再醒。
这里有个细节需要说清楚:App 并不是每一拍都会被唤醒。平时没事的时候,App 是事件驱动的------如果你不碰它,它就睡着,屏幕也不需要新画面,显示器会一直重复旧的那一帧。当然,这指的是画面中没有需要变化的内容,整个画面是静止的。但只要你碰了页面、点了一下,或者有一条数据回来需要刷新列表,从这一刻起 App 就上了钟:它必须赶在节拍点之前,把新一帧的内容准备好。
iOS 的一帧画面是怎么渲染出来的?
前面说到,页面开始变化之后,App 踩着 VSync 的节拍,一帧一帧地准备新的画面。
那么,所谓的「准备一帧」,是在准备什么?比如我们写的 view.frame = newFrame,最终是怎么变成屏幕上的像素的?
Apple 把一帧画面从事件到显示的整个过程称为 Render Loop,由五个阶段组成:

下面我从一个聊天列表收到新消息开始,介绍一帧画面是怎么一步一步走到屏幕上的。
第一阶段:Event
第一个阶段是事件处理。
这里的事件不只是用户点击屏幕,还包括:
- 列表滚动
- 定时器回调
- 网络数据回调
- 键盘输入
- ...
App 收到这些事件之后,执行我们的业务代码,然后修改页面。
例如聊天列表收到新消息后,我们可能会创建或者复用一个 Cell,然后修改它的内容:
objc
cell.contentLabel.text = message.content;
cell.avatarView.image = avatar;
cell.alpha = 1;
我们平时操作的是 UIView,但 UIView 并不是最终交给 GPU 的东西。
iOS 中每一个 UIView 背后都有一个 CALayer。UIView 主要负责事件、布局和视图层级,而 CALayer 负责记录这个视图应该怎么显示:
- 放在什么位置
- 有多大
- 透明度多少
- 使用什么变换
- 有没有圆角、阴影和裁剪
- 要显示什么内容
所有 UIView 背后的 CALayer 按照父子关系组织起来,就形成了一棵图层树:

所以,当我们修改 UIView 的时候,最终改变的是它背后的 CALayer。但这时只是修改了图层树中记录的状态,新的像素还没真正画出来。
如果这些变化影响了布局,系统会给对应的视图打上"需要布局"的标记;如果这些变化影响了内容,系统就会给它打上"需要重新显示"的标记。
这就是 setNeedsLayout 和 setNeedsDisplay 中 Needs 的含义:它们不是立刻执行布局或者绘制,而是先记下来,等后面的 Commit 阶段一起处理。
第二阶段:Commit
事件处理结束之后,如果这一轮有 UI 发生变化,系统就会进入 Commit 阶段。
Core Animation 不会在我们每修改一个属性时,就立即向 Render Server 提交一次。因为一次事件处理中,我们可能会连续修改很多状态:
swift
view.frame = newFrame;
view.alpha = 0.5;
view.backgroundColor = UIColor.redColor;
...
如果每修改一次就提交一次,前面的中间状态还没来得及显示,马上又会被后面的状态覆盖,只会产生大量无意义的工作。
所以 Core Animation 会使用 CATransaction,把这一轮发生的图层变化收集起来,最后统一处理和提交。
即使我们没有写动画,CATransaction 也仍然存在。事务的作用是合并图层树的变化,事务本身并不等于动画。
一个完整的 Commit Transaction,又分为四个明确顺序的阶段:
swift
Layout -> Display -> Prepare -> Commit
1. Layout
第一步是布局。
系统会找出所有被标记为需要布局的视图,然后调用它们的 layoutSubviews,布局会按照视图层级从父视图向子视图进行。
在聊天列表这个例子里,这一步需要确定:
- Cell 在列表中的位置和大小
- 头像放在哪里
- 昵称放在哪里
- 正文区域有多宽、多高
- 每个子视图最终的
frame是多少
如果使用 Auto Layout,解约束的过程也发生在这里,系统根据约束计算出视图的最终位置和尺寸,再把结果反映到对应的 CALayer 上。
文本排版中用于确定尺寸的工作也发生在这里。
例如,为了计算正文 Label 的高度,系统需要根据字体、字号、行宽和换行规则,把文字排列成一行一行,才能知道这段文字最后占多高。
所以文本处理需要拆成两个概念:
- 文本排版:计算每个字放在哪里,最终占多大
- 文本绘制:把排版结果真正变成像素
前者会参与布局和尺寸计算,后者发生在接下来的 Display 阶段。
2. Display
布局完成之后,第二步是 Display,也就是绘制。
系统会找出所有被标记为需要重新显示的视图和图层,为它们生成新的图层内容。
如果一个视图重写了 drawRect:,系统会在这里调用它。系统会准备一个由纹理支持的 Core Graphics 绘制上下文,然后让视图把内容画进去。
例如:
swift
- (void)drawRect:(CGRect)rect {
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(context, UIColor.redColor.CGColor);
CGContextFillRect(context, rect);
}
这并不是直接画到物理屏幕上,而是画进当前图层自己的内容存储中,也就是 backing store。绘制结束后,这张内容会交给后面的 Render Server 和 GPU 使用。
文本真正变成像素,也是发生在这个阶段。
前面的 Layout 已经算出了每个字应该放在哪里,Display 阶段再把这些字形绘制成图层内容。对于 Core Animation 来说,绘制结束后,它不再关心这是一段文字,只会把它当做图层需要显示的内容。
不过并不是每个 CALayer 都需要执行 drawRect:。
像背景色、边框、圆角、位置、透明度和变换,本身就是 CALayer 能够描述的属性。只修改这些属性时,系统可以直接更新图层树,不需要让 App 重新画一张位图。
同样,如果一个图层的内容没有发生变化,只是位置发生了变化,原来的内容也可以继续复用,不需要重新绘制。
3. Prepare
绘制完成之后,接下来是 Prepare,为提交做最后的内容准备。
图片解码就在这个阶段处理。
图片渲染有三个流程,要先分清楚:
- Load:把 JPEG、PNG 等压缩数据读进内存
- Decode:把压缩数据解码成像素数据
- Render:使用像素数据参与最终画面合成
JPEG、PNG 文件之所以在磁盘上比较小,是因为它们存放的是压缩后的数据。GPU 要显示图片,需要的是解压后的像素数据,不能直接拿 JPEG、PNG 文件里的压缩字节当普通纹理处理。
所以我们需要解压图片。
例如一张 1000 * 1000 的普通 RGBA 图片,磁盘上的 JPEG 文件可能只有几百 KB,但完整解码之后大约需要 1000 * 1000 * 4 字节 = 4 MB。
因为解码后每个像素通常需要红、绿、蓝、透明四个通道。
这里有一个最容易混淆的概念:就是 创建 UIImage 对象,不等于图片已经完成解码。
swift
UIImage *image = [UIImage imageWithData:data];
imageView.image = image;
第一行主要是用压缩数据创建了一个图片对象,只要它背后的像素数据还没有准备好,这张图片就是"尚未解码"的。
到了 Commit Transaction 的 Prepare 阶段,系统发现这一帧要使用这张图片,就会把它从 JPEG、PNG 等压缩格式解码成 GPU 可以使用的像素格式。
如果图片的颜色格式不是 GPU 能够直接处理的格式,系统还会在这里完成颜色的格式转换。转换过程中需要重新分配内存并复制像素,也会增加时间和内存开销。
所以默认情况下,一张尚未解码的图片第一次参与显示时,解码成本会落在这一帧的 Prepare 阶段。如果图片很大,这一步就会拉长整个 Commit Transaction,导致 App 错过提交截止的时间。
而所谓的"后台预解码",本质就是在图片进入页面之前,提前完成这个 Decode。
没有预解码:
swift
Load
↓
等到 Commit 的 Prepare 阶段
↓
Decode
↓
提交
预解码:
swift
后台 Load
↓
后台 Decode
↓
得到已经准备好的图片
↓
Commit 的 Prepare 阶段直接使用
4. Commit
Layout、Display 和 Prepare 都完成之后,最后一步才是真正的 Commit。
Core Animation 会从根节点开始,递归地整理发生变化的图层树,把图层之间的父子关系、位置、大小、透明度、变换以及准备好的图层内容打包起来,然后提交给 Render Server。
所以 App 交出去的不是一张已经合成完成的页面截图,也不是把 UIView 对象直接交给 GPU,它交出去的是 一棵准备好的图层树以及每个图层渲染时需要使用的内容。
到这里,App 进程这一部分才算结束。
如果 Layout、Display、Prepare 或 Commit 中的任何一步耗时太长,App 就不能按时完成提交。这类卡顿叫做 Commit Hitch。
第三阶段:Render Prepare
App 交出去的内容,给到了 Render Server。
Render Server 是一个 独立于 App 的系统进程 。它接收 Core Animation 提交的图层树,然后把这份 UI 描述转换成 GPU 能够执行的渲染任务。
这一阶段叫做 Render Prepare。
需要注意的是,Render Prepare 仍然是 CPU 在工作,只不过这部分 CPU 工作发生在 Render Server 进程,而不是 App 进程。
Render Server 不认识 UIView,它看到的是一棵图层树:
- 哪一层在上面
- 哪一层在下面
- 每一层放在哪里
- 每一层使用什么内容
- 每一层的透明度和变换是什么
- 哪些地方需要裁剪、遮罩或者阴影
Render Server 会从根节点开始遍历图层树,按照父子关系和叠放顺序,把它整理成一条 GPU 可以执行的渲染流水线。
在这个过程中,它还要计算:
- 动画在当前这一帧的状态
- 图层最终使用的几何变换
- 哪些图层需要透明混合
- 哪些效果需要额外的离屏渲染
- GPU 应该按照什么顺序执行绘制命令
例如我们提交了一个 Core Animation 位移动画,App 只需要提交动画的起点、终点、持续时间和时间曲线。动画开始之后,Render Server 会计算当前这一帧对应的位置,不需要 App 每一帧都重新修改 frame。
Render Prepare 完成后,Render Server 会得到一组可以交给 GPU 执行的绘制命令。
第四阶段:Render Execute
这一阶段就是真正由 GPU 执行渲染的阶段。
到了这里,GPU 不需要理解 UILabel、UIImageView 或者 UIButton。文本已经变成可以使用的图层内容,图片也已经解码成像素数据。
GPU 需要做的,就是按照 Render Server 准备好的命令,把所有图层合成一张最终画面。
GPU 会从后往前处理图层。
不透明图层可以直接覆盖下面的内容,半透明图层则需要读取下面的像素,再根据透明度计算混合结果。
如果某些效果无法在一次渲染中完成,例如一些遮罩、阴影或者裁剪组合,GPU 还需要把中间结果画到临时的渲染目标,再拿回来参与最终合成,这就是 离屏渲染。
全部图层合成完成后,GPU 会把最终画面写入 Render Buffer。
如果 Render Server 的 CPU 或者 GPU 执行没有按时完成,这类卡顿叫做 Render Hitch。
第五阶段:Display
GPU 把最终画面写进 Render Buffer 后,显示驱动会在目标 VSync 到来时读取准备好的缓冲区,然后把里面的像素更新到屏幕上。
在双缓冲模式下:
- 显示驱动读取一块缓冲区
- Render Server 和 GPU 准备另一块缓冲区
- 到了显示时刻,两块缓冲区交换
如果新的一帧画面没有赶上目标 VSync,它就不能按时显示。屏幕上只能让上一帧多停留一个刷新周期,用户看到的就是画面顿了一下。
这是一个并行流水线
前面的五个阶段有明确的先后依赖,但它们并不是等一帧完全显示结束,才开始处理下一帧。
在正常的双缓冲流水线中,同一个时间段可能是:
swift
App 正在准备第 N 帧
Render Server 正在渲染第 N - 1 帧
显示系统 正在显示第 N - 2 帧
所以,60Hz 下的 16.7 毫秒,不是让 App 在 16.7 毫秒内独自完成从业务代码到物理屏幕的全部工作。
它的含义是,整条流水线每隔约 16.7 毫秒就要向前推进一次。App 和 Render Server 都有自己的截止时间,只要每一阶段都能按时交出去,屏幕就可以持续得到新画面。
现在回头看,整个流程就是:
swift
Event
App 处理事件,修改 UIView 和 CALayer
↓
Commit Transaction
Layout → Display → Prepare → Commit
↓
Render Prepare
Render Server 使用 CPU 准备渲染命令
↓
Render Execute
GPU 合成图层,写入 Render Buffer
↓
Display
显示驱动在目标 VSync 将画面送上屏幕

任何一个阶段没有赶上自己的截止时间,新画面就会晚到,这就是 卡顿。
卡顿产生的原因
知道了渲染的流程,那么就来看看到底哪些地方可能会造成卡顿。
Apple 把渲染过程中的卡顿分成两类:
- Commit Hitch:App 没有按时处理完事件并提交这一帧
- Render Hitch:Render Server 没有按时完成这一帧的渲染
这个分类是按照卡顿发生的位置来分的,而不是简单地分成 CPU 卡和 GPU 卡。因为 CPU 工作不只发生在 App 进程,Render Server 准备渲染命令时同样需要使用 CPU。
Commit Hitch
如果 App 在 Event 或 Commit Transaction 中耗时太长,没有按时把新的图层树提交给 Render Server,就会产生 Commit Hitch。
在 Event 阶段,主线程的耗时操作可能来自:
- 正在解析大量 JSON
- 同步读写磁盘
- 等待锁
- 一次性创建和销毁大量对象
- ...
如果主线程正在做这些事情,后面的布局和提交就只能继续等待。
进入 Commit Transaction 之后,耗时又可能来自不同阶段:
- Layout:视图层级复杂、解 Auto Layout 约束耗时,或者反复计算文本尺寸
- Display:大量文本和自定义绘制需要重新生成图层内容
- Prepare:这一帧使用的图片尚未解码,或者需要进行颜色格式转换
- Commit:图层树过深,递归整理和打包需要更长时间
除了单次任务太重,更新过于频繁也会造成 Commit Hitch。
例如聊天列表每收到一条消息就全量刷新一次,每次刷新都重新触发布局、文本排版和绘制。单次刷新不一定超时,但如果每一帧都被排得很满,遇到一条更长的消息或者一张尚未解码的图片,就会超过这一帧的截止时间。
Render Hitch
App 按时提交了图层树,并不代表这一帧一定能按时显示。Render Server 还要完成 Render Prepare 和 Render Execute。这两个阶段超时都会产生 Render Hitch。
Render Prepare 使用的是 CPU,只不过工作发生在 Render Server 进程中。它需要遍历图层树、计算动画状态,并把图层的变换、裁剪、遮罩和阴影整理成 GPU 可以执行的绘制命令。如果图层树复杂,或者这一帧需要处理大量动画和特殊效果,Render Prepare 就可能超过截止时间。
Render Execute 才是 GPU 真正执行渲染的阶段。常见的压力主要来自:
- 透明混合:半透明图层不能直接覆盖下面的内容,需要读取下层像素并计算混合结果
- 重复绘制:同一块区域被多个图层反复覆盖,GPU 需要多次处理同一批像素
- 离屏渲染:动态阴影、遮罩、模糊等效果需要先生成中间结果,再复制到最终画面
- 纹理压力:一帧需要处理的纹理数量多、尺寸大,占用更多显存和传输带宽
不过,圆角不等于一定会触发离屏渲染。是否需要离屏,要看有没有裁剪子图层、使用遮罩,以及系统能不能直接完成这个效果。
大图也要分开看。如果图片还没解码,耗时发生在 App 的 Prepare 阶段,属于 Commit Hitch;如果 GPU 需要在一帧中处理大量大型纹理,问题才会出现在 Render 阶段。
在排查卡顿的时候,首先要确定的是 这一帧究竟是 App 没有按时提交,还是 Render Server 没有按时渲染。
如何解决卡顿?
解决卡顿的第一步,并不是看到耗时操作就把它放到后台,也不是遇到圆角、阴影就直接使用切图,而是要先定位:这一帧究竟迟在哪里?
如果应用没有及时生成并提交图层树,属于 Commit Hitch;如果应用已经按时提交,但 Render Server 或 GPU 没有及时完成渲染,则属于 Render Hitch。两种发生的位置不同,解决的方式也不同。
首先确定卡在哪里
在优化之前,需要先确定到底是哪里在卡,最好先在真机上稳定复现卡顿,并记录卡顿发生时的操作路径和性能指标。
我们的项目以 YYFPSLabel 为基础封装了一个性能小窗,在 FPS、CPU、内存的基础上,补充了 GPU 指标。在平时开发的时候,可以直接观察这些数据,有性能波动就能马上感知到。
(后面我写了个 SwiftUI 版本,增加了性能日志导出和图表功能:FRPerfKit)。
不过这些指标只能提供线索,不能直接判断卡顿发生在哪个阶段。发现性能波动后,仍然需要到 Xcode Instruments 的 Animation Hitches 中定位问题。

如果 Commits 阶段出现明显延迟,问题通常在应用进程一侧,可以继续使用 Time Profiler 查找主线程上的耗时函数。
如果 Renders 或 GPU 阶段出现延迟,则应该检查图层架构、离屏渲染、混合、过度绘制以及纹理大小。
解决 Commit Hitch
Commit Hitch 表示应用没有在这一帧的时间预算内完成 Event、Layout、Display、Prepare 和 Commit。
缩短这条关键路径,通常有三种方法:
- 提前做:把可以复用的结果在显示前算好
- 后台做:把不依赖界面的纯计算和 I/O 移出主线程
- 分散做:合并高频更新,避免大量任务挤在同一帧
我们一个阶段一个阶段地梳理。
Event:减少主线程上的额外工作
Event 阶段,CPU 不只是在处理触摸事件。消息到达后,同步解析大 JSON、读取磁盘、等待一个被其他线程长期占用的锁,或者一次性创建和销毁大量对象,都会让后面的布局和提交继续等待。
在后台准备消息展示数据
以我们项目的公屏聊天为例,每一条消息都会有用户等级图标、用户勋章图标、用户昵称和正文;如果是送礼物的消息,还会有一个礼物小图放在末尾。所以我们把这些内容组装成一个 NSAttributedString,最后交给 YYLabel 显示。
这一步我们做的优化,可以概括成四点:
- 在后台处理消息解析,将 Socket 中的原始数据转换成
RoomMessageModel - 解析消息时,根据模型中的等级、勋章、昵称和正文组装富文本,并把结果记录在
model.attribute中 - 如果是送礼消息,在礼物图准备好后追加到富文本末尾,然后用
YYText计算最终排版尺寸,并记录在model.extraLayout中 - 只有富文本和排版都准备完成的
Model才会进入公屏聊天列表的数据源队列。主线程按固定的节拍刷新,Cell只负责读取attribute和extraLayout
最终 Cell 只需要使用 Model 中已经算好的结果:
swift
self.contentLabel.attributedText = model.attribute;
CGSize size = model.extraLayout.size;
self.contentLabel.frame = CGRectMake(0, 0, size.width + 20, size.height + 10);
这里还有一个可以优化的点:我们之前只缓存了富文本和尺寸,YYLabel 内部仍可能重新生成排版。如果调试时发现这里的耗时仍然比较长,可以在 Model 中直接记录 YYTextLayout 属性,Cell 直接消费即可。
如果严格按照渲染阶段区分,消息解析和模型转换减少的是 Event 阶段的主线程工作,提前测量尺寸减少的是后续 Layout 阶段的工作。
合并和限流高频任务
对于高频消息,可以先进入队列,再按照固定节拍合并刷新,避免每到一条消息就触发一次完整的布局和绘制。消息洪峰时,如果有必要,可以丢掉一些低优先级的消息。比如我们的公屏聊天,1 秒内限流 10 条聊天消息,因为聊天房间在炸房时,主要是追求一种氛围,具体说了什么并不重要。
我们大约每 400 ms 刷新一次列表。
需要注意的是这里限制的是 UI 的提交频率,而不是消息的接收频率,消息是正常接收的,不能随意丢弃。
控制对象的创建和销毁
对象的生命周期也属于这个阶段:
- 低频出现的空态页、分享面板、引导浮层,可以延迟到真正需要的时候再创建
- Cell、播放器或其他创建成本较高的对象,优先复用,而不是反复创建和销毁
- 必须在主线程创建的 UI 对象,可以监听 RunLoop 的状态,在空闲时分散创建,但是要注意避免堆积
- 模型、字符串和排版结果这类纯数据可以放在后台准备,前面也说过了
需要说明的是,后台释放对象的优先级很低。只有在 Instruments 上确实观察到某个大型纯数据对象的 dealloc 过程阻塞了主线程,并且整个对象都允许在后台销毁时,才值得考虑。普通的小对象、包含 UIKit 资源的对象,以及析构线程要求不明确的对象,都不应该这样处理,因为收益不是很大。
如果确实满足这些条件,可以像下面这样去释放它:
swift
NSArray *tmp = self.hugeArray; // 用局部变量接住它
self.hugeArray = nil; // 主线程这根引用断了,但 tmp 还拽着,对象死不了
dispatch_async(bgQueue, ^{
[tmp class]; // 随便调用一下
});
// block 执行完,tmp 这最后一根引用在后台线程消失
// → dealloc 连同几万个元素的释放,全发生在后台
只有确定这里是最后一个强引用时,最终的析构才会从这个位置开始。只要还有缓存、闭包或者其他对象持有它,最后释放仍然会发生在那份引用被断开的地方。
对象在哪个线程断掉最后一个强引用,dealloc 就在哪个线程执行。
Layout:避免重复布局和排版
布局优化的重点不是简单的放弃 Auto Layout,而是避免在一帧内重复计算相同结果。
比如我们前面做的,将富文本的内容记录到 Model 中,Cell 在即将显示的时候只读取结果,而不需要再次测量。
当然,前面说的预排版结果并不是一定不变的。当消息内容、公屏宽度或者字体模式变化时,就需要清除并重新构建。
还有需要说明的是,用 frame 并不天然就比 Auto Layout 快。更常见的第一步是复用已经建立好的约束、只更新必要的 constant,避免反复增删整组约束、递归触发父视图布局,或者在一次更新中多次调用 layoutIfNeeded。除非 Time Profiler 查出确实是约束太复杂,并且布局足够稳定的时候,才值得考虑 frame。Auto Layout 在代码可读性上还是比 frame 好太多。
对于动画部分,前面提到过,Core Animation 动画只需要由 App 提交起点、终点和时间参数,中间帧可以交给 Render Server 计算。当视觉效果允许时,位移、缩放和淡入淡出优先使用 Core Animation 能直接表达的 transform、opacity 动画,而不是自己用 CADisplayLink 一帧一帧地修改约束或者 frame,让 App 每一帧都需要重新布局和提交。
swift
// 提交起点、终点和时间参数,不在 CADisplayLink 中逐帧修改约束
self.cardView.transform = CGAffineTransformIdentity;
self.cardView.alpha = 1.0;
[UIView animateWithDuration:0.25 animations:^{
self.cardView.transform = CGAffineTransformMakeTranslation(0, -12);
self.cardView.alpha = 0.0;
}];
Display:降低绘制成本
进入 Display 后,应用需要为发生变化的图层生成内容。复杂文本、自定义 Core Graphics 绘制和大面积重绘都可能占用主线程。
首先应该减少无效绘制:
- 没有自定义内容就不要留下空的
drawRect: - 内容没有变化就不要重复调用
setNeedsDisplay - 只有局部区域发生变化,不要总是重画整张位图
如果复杂文本或自定义图形的绘制已经成为明确瓶颈,再去考虑异步绘制。可以参考 YYAsyncLayer 和 AsyncDisplayKit。
YYAsyncLayer 的实现主要是以下四步:
- 主线程读取当前状态,生成一份不可变、线程安全的绘制快照
- 后台线程根据快照生成位图,并在适当的绘制节点检查任务是否已经取消
- 回到主线程前,再检验内容版本或
Cell身份 - 只有结果仍然有效时,才把位图设置到对应的
Layer上
异步绘制最难的是防止画错对象。因为从任务进入后台队列,到绘制完成并回到主线程,中间存在时间差,这期间 Cell 可能已经被复用,内容也可能已经发生变化。
YYAsyncLayer 的做法是采用类似版本号的机制,设计一个只加不减的原子计数器,在开始和结束时通过比较版本号进行校验,防止画错对象。
YYAsyncLayer 的处理:
swift
// YYSentinel ------ 版本号
@implementation YYSentinel {
int32_t _value;
}
- (int32_t)value { return _value; }
- (int32_t)increase { return OSAtomicIncrement32(&_value); }
@end
// YYAsyncLayer.m ------ "内容变了"的唯一入口:版本号 +1,在途旧任务全部作废
- (void)setNeedsDisplay {
[self _cancelAsyncDisplay];
[super setNeedsDisplay];
}
- (void)_cancelAsyncDisplay {
[_sentinel increase];
}
// YYAsyncLayer.m ------ 异步分支
- (void)_displayAsync:(BOOL)async {
__strong id<YYAsyncLayerDelegate> delegate = (id)self.delegate;
YYAsyncLayerDisplayTask *task = [delegate newAsyncDisplayTask]; // 主线程打包
// 出发前:拿上目前最新的版本号
YYSentinel *sentinel = _sentinel;
int32_t value = sentinel.value;
BOOL (^isCancelled)(void) = ^BOOL { return value != sentinel.value; };
// 连 size / opaque / scale 都在主线程抓成局部变量
CGSize size = self.bounds.size;
BOOL opaque = self.opaque;
CGFloat scale = self.contentsScale;
// 进后台
dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
// 关口1:开画前
if (isCancelled()) return;
UIGraphicsBeginImageContextWithOptions(size, opaque, scale); // 开画布
CGContextRef context = UIGraphicsGetCurrentContext();
// 关口2:在 display 内部,传进了绘制循环
task.display(context, size, isCancelled);
// 关口3:画完,取图前
if (isCancelled()) {
UIGraphicsEndImageContext();
return;
}
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// 回主线程
dispatch_async(dispatch_get_main_queue(), ^{
// 关口4:上屏前的最后一关
if (isCancelled()) {
if (task.didDisplay) task.didDisplay(self, NO);
} else {
// 挂图
self.contents = (__bridge id)(image.CGImage);
if (task.didDisplay) task.didDisplay(self, YES);
}
});
});
}
可以看到,很多步骤都在检查取消状态,这样可以尽早发现并停止已经过期的任务。
这里还需要注意:Block 捕获对象时,捕获的是一份强引用,并不是把对象深拷贝了一份。如果原对象还会被修改,就应该提前 copy 成不可变的值,或者采用其他方式保证后台读取期间不会发生竞争。
另外,异步绘制并没有降低工作量,反而增加了调度和取消任务,还要确保结果的一致性;重绘期间,也可能同时保留新旧两张位图。需要结合实际场景判断是否使用异步绘制,还是那句话,不要为了用而用。
Prepare:提前准备图片
PNG、JPEG 这些图片文件中存放的是压缩数据,不能直接使用。图片在第一次上屏前需要解码成像素数据。如果一张几千像素的原图最终只显示成几十像素的头像,此时还去解码完整尺寸,就会带来不必要的内存峰值。
对于像素尺寸本来就接近显示尺寸的图片,可以使用 iOS 15 提供的 prepareForDisplay 提前解码;对于明显过大的图片,可以使用 prepareThumbnail 或 ImageIO,按目标显示尺寸生成缩略图。后者的重点在于 在解码路径中降低采样 ,可以通俗理解为先缩尺寸,再去解码。把 imageView.bounds.size 传给 prepareThumbnail,再用 UIGraphicsImageRenderer 画进一个较小的画布里。
swift
NSString *itemID = [item.identifier copy];
CGSize targetSize = item.layout.avatarSize; // 目标显示尺寸(pt),来自已经计算好的布局
cell.representedID = itemID;
cell.avatarView.image = self.placeholderImage;
__weak ChatCell *weakCell = cell;
[rawImage prepareThumbnailOfSize:targetSize
completionHandler:^(UIImage *thumbnail) {
dispatch_async(dispatch_get_main_queue(), ^{
ChatCell *cell = weakCell;
if (!cell || ![cell.representedID isEqual:itemID]) return;
cell.avatarView.image = thumbnail;
});
}];
使用 SDWebImage 时,可以传入 SDWebImageContextImageThumbnailPixelSize,让解码器按目标像素尺寸生成缩略图。这里需要注意的是,参数的单位是像素,需要传入 bounds.size * 屏幕的 scale。
另外,YYWebImage、SDWebImage 默认都会强制解码,所以预先解码这一步可以交给第三方库完成。
还有一种场景:对于实时性要求较低,并且允许在快速滚动时延迟加载的列表,可以在列表滚动时只加载占位内容,等用户停止滚动后,再开始加载实际内容。
Commit:简化需要提交的图层树
布局、绘制和图片准备完成后,应用还需要递归整理图层树,并把这一帧的变化提交给 Render Server。iOS 中每个 UIView 背后都有一个 CALayer,View 的父子关系通常也会形成对应的 Layer 层级。因此,图层树中存在大量没有实际职责的节点,或者一处很小的变化反复触发整棵子树的布局、绘制,都会增加 Commit 的工作。
所以,平时开发的时候,我们要关注视图层级中有没有可以删除的中间层,以及每次更新是否可以只更新真正发生了变化的区域。
避免层级过深
第一,不要为了分组而增加没有实际职责的容器。比如一个中间 View 不负责布局、裁剪、背景、交互或动画,只是把另一个 View 包了一层,就可以考虑直接删除。简单的背景色、边框和圆角也可以直接设置在已有 View 或它的 Layer 上,不必再覆盖一层装饰 View。
第二,保持视图树稳定。Cell 或复杂组件中的子 View 应该在初始化时创建一次,后续只更新内容、位置和显隐状态。不要每次刷新都先删除再重新创建。临时不显示的固定内容可以使用 hidden。数量会变化的标签、头像等内容,可以复用已有 View,只补充或回收变化的部分。
swift
- (void)setupSubviews {
self.iconView = [[UIImageView alloc] init];
self.titleLabel = [[UILabel alloc] init];
self.badgeView = [[UIImageView alloc] init];
[self addSubview:self.iconView];
[self addSubview:self.titleLabel];
[self addSubview:self.badgeView];
}
- (void)updateWithModel:(CardModel *)model {
self.iconView.image = model.icon;
self.titleLabel.text = model.title;
self.badgeView.hidden = !model.showsBadge;
}
第三,共同展示、共同滚动并且不需要分别交互的内容,可以适当合并。例如前面说的公屏消息,其中的等级、勋章、昵称、正文和礼物图,可以组合成一份富文本,交给一个文本控件显示,而不是分别创建多个 UIImageView 和 UILabel。
但如果是按钮、输入框以及需要独立点击或独立动画的内容,继续保留为单独控件通常更合适。
需要注意的是,把多个 View 换成同样数量的 CALayer 并不等于压平图层树,因为这些 sublayer 仍然是需要提交的节点。真正的简化是删除不必要的节点,或者把多个只负责静态展示的元素合成一份绘制内容。
限制变化范围
| 发生的变化 | 处理方式 | 更新范围 |
|---|---|---|
| 修改文字、图片、颜色或透明度 | 直接更新对应 View 或 Layer 的属性 | 对应节点 |
| 子 View 的位置或尺寸需要重新计算 | 对负责这些子 View 的最小容器调用 setNeedsLayout |
该容器的子树 |
| 自绘 View 的全部内容发生变化 | 调用 setNeedsDisplay |
整个 View |
| 自绘 View 中只有固定区域发生变化 | 调用 setNeedsDisplayInRect: |
指定矩形区域 |
例如,一个自定义卡片只有角标的显隐状态改变,并且角标位置固定,就只需要更新 badgeView.hidden,没有必要让整个页面重新布局。如果角标的改变会影响标题宽度,可以让卡片自己重新布局它的子 View。
swift
- (void)setShowsBadge:(BOOL)showsBadge {
if (_showsBadge == showsBadge) return;
_showsBadge = showsBadge;
self.badgeView.hidden = !showsBadge;
[self setNeedsLayout]; // 只要求当前卡片重新布局自己的子 View
}
- (void)layoutSubviews {
[super layoutSubviews];
self.badgeView.frame = [self badgeFrame];
self.titleLabel.frame = [self titleFrameForShowsBadge:self.showsBadge];
}
setNeedsLayout 只记录一次布局请求,系统会在下一次更新周期统一处理。除非代码马上需要读取新的 frame,或者正在执行依赖最终布局结果的动画,否则不要频繁调用 layoutIfNeeded 强制立即布局。必须立即布局时,也应该在能够覆盖相关约束的最小公共父 View 上调用,而不是直接从根 View 开始。
自绘 View
普通的 UILabel、UIImageView 会各自成为视图树中的节点。自绘 View 就是使用 drawRect: 直接把这些内容画出来,不再创建这些子控件。它不是把多个控件放进一个容器,而是用一次绘制替代这些控件。
另外,drawRect: 是由系统调用的,业务代码不应该直接调用它。setNeedsDisplayInRect: 会把指定矩形加入待重绘区域,并在下一次绘制周期统一处理,而不是立即执行。
自绘适合大量重复、主要用于展示且交互简单的内容。它可以减少 View 和 Layer 节点,但也会把一部分工作转移到 Display 阶段。默认情况下,drawRect: 在主线程执行。控件需要独立点击、动画或频繁单独变化时,继续使用 UIKit 提供的控件更合适,确定有必要后再使用自绘。
解决 Render Hitch
如果应用已经按时提交图层树,但 Renders 或 GPU 阶段仍然迟到,问题就不在业务代码是否更快,而在于 Render Server 生成命令和 GPU 执行命令是否及时。
Render Prepare:简化图层效果和结构
Render Server 需要遍历提交过来的图层树,计算动画状态、变换、裁剪、遮罩和阴影,并生成 GPU 可以执行的命令。复杂效果和频繁变化的图层结构都会增加准备成本,有些效果还会同时增加后续的像素处理。
例如,一个卡片阴影的形状已经确定,就可以设置明确的 shadowPath,避免系统反复推断阴影轮廓。简单圆角优先使用 cornerRadius、cornerCurve。只有子内容确实需要被裁剪时才开启相应裁剪,复杂的不规则形状才使用额外的 Mask。圆角本身不等于一定触发离屏渲染,应该结合实际图层内容和工具诊断结果判断。
阴影和内容裁剪同时存在时,可以把职责拆成内外两层:外层画阴影但不裁剪,内层负责圆角和裁剪。
swift
- (void)layoutSubviews {
[super layoutSubviews];
CGFloat radius = 12.0;
self.contentContainer.frame = self.bounds;
self.contentContainer.layer.cornerRadius = radius;
self.contentContainer.layer.cornerCurve = kCACornerCurveContinuous; // iOS 13+
self.contentContainer.layer.masksToBounds = YES;
self.layer.masksToBounds = NO;
self.layer.shadowPath =
[UIBezierPath bezierPathWithRoundedRect:self.bounds
cornerRadius:radius].CGPath;
}
bounds 变化时需要同步更新 shadowPath,所以它放在 layoutSubviews 中。只有内容确实超出圆角边界时,内层才需要 masksToBounds。
稳定的图层结构也应尽量复用,不要为了一个状态变化就反复创建 Mask、Effect View 或整棵 Layer 子树。
离屏渲染重点检查的场景有:
- 阴影
- Mask
- 圆角裁剪
Render Execute:减少 GPU 的像素工作
到了 Render Execute,GPU 真正处理图层和像素。优化的核心可以概括成一句话:减少这一帧需要处理的像素数量和处理次数。
第一,减少不必要的透明混合。如果一个 View 的每个像素都确定不透明,就可以正确设置不透明属性和背景,让 GPU 不必再读取后面的颜色进行混合。但包含透明像素、圆角外区域透明或内容本身半透明时,不能为了性能错误地声明为不透明。
swift
// 仅在这一层的每个像素都确定不透明时这样设置
self.backgroundView.opaque = YES;
self.backgroundView.backgroundColor = UIColor.whiteColor;
self.messageLabel.opaque = YES;
self.messageLabel.backgroundColor = UIColor.whiteColor;
第二,减少过度绘制。一个聊天 Cell 如果同时存在不透明的 Cell 背景、铺满全屏的容器背景、透明蒙层和最终内容,同一区域可能被反复绘制多次。完全看不见的背景层、重复渐变和没有实际作用的全尺寸透明 View 应该删除。用 Color Blended Layers 可以发现这些混合区域,但是否需要优化,还要结合 GPU 耗时判断。
第三,控制离屏渲染。动态阴影、复杂遮罩和模糊等效果可能会先生成中间层,再参与最终合成,增加额外的渲染遍数和内存带宽。使用 shadowPath、更简单的图层效果以及避免不必要的 Mask,可以减少这些离屏渲染行为。仅仅因为圆角就使用后台切图没有太大必要,后台切图的步骤也比较麻烦。
第四,控制纹理的像素尺寸和数量。几十像素的头像没有必要长期持有几千像素的解码位图;这既会增加 App 的 Prepare 和内存压力,也会增加渲染阶段处理大型纹理的成本。
如果页面需要同时绘制大量固定小图标或序列帧,并且测量结果已经表明纹理切换或资源准备是瓶颈,可以进一步考虑合图。多个 Layer 可以共享同一张图,通过 contentsRect 显示各自区域:
swift
layer.contents = (__bridge id)atlasImage.CGImage;
layer.contentsRect = CGRectMake(x, y, width, height); // 0~1 的归一化坐标
不过需要注意的是,contentsRect 只负责选择纹理区域,并不保证普通 Core Animation 场景一定被合并成一次绘制调用。
合图会带来图集重建、边缘采样串色、尺寸上限和大图常驻内存等代价,因此它适合稳定、经常一起出现的小资源,不适合用户头像等持续变化的网络图片。
总结
iOS 绘制一个 UI 元素,流程如下:

这一条链路下来,每一个阶段都有各自的优化方案,当然,不是必须执行:
- Event:后台完成数据解析、模型转换和富文本准备;合并高频更新,复用对象
- Layout:缓存排版结果,保持视图结构稳定,只更新必要的约束和区域
- Display:避免重复绘制和大面积重绘;确认成为瓶颈后再考虑异步绘制
- Prepare:提前解码图片,并按照实际显示尺寸生成缩略图
- Commit:减少不必要的
View和Layer,保持图层树简单、稳定 - Render Prepare:简化阴影、遮罩和裁剪等图层效果,固定阴影可以设置
shadowPath - Render Execute:减少透明混合、过度绘制、离屏渲染和大尺寸纹理
优化的整体思路是:能提前做的提前做,能复用就复用,只更新真正发生变化的部分,并在优化之后重新测量和验证 。最重要的是,判断是否有必要进行优化,过度的优化反而不好。
终于写完了。
参考资料
- Explore UI animation hitches and the render loop
- Find and fix hitches in the commit phase
- Demystify and eliminate hitches in the render phase
- Understanding hitches in your app
- Core Animation Basics
- Improving Animation Performance
- UIImage
- YYAsyncLayer
- SDWebImage