前言
一张只有几 MB 的照片,可能在解码后占用几十 MB 内存;一个已经使用 async/await 的页面,仍可能阻塞主线程;一套积极的预加载策略,也可能让正在播放的视频更容易卡顿。
这些现象背后,有一个共同问题:应用做了多少工作、这些工作何时发生,以及它们在争夺什么资源。
可靠的性能优化,需要围绕完整的用户行为展开:明确用户正在等待的结果,缩短通往这个结果的路径,同时控制其他工作对 CPU、内存、网络和渲染资源的竞争。
本文从性能测量出发,结合启动、图片列表、SwiftUI 和视频播放等常见场景,讨论如何建立一套能够解释问题、验证收益并持续维护的优化方法。
一、先定义“慢”,再决定优化什么
1. 用用户行为定义指标
“首页很慢”“滚动不流畅”“视频起播慢”,都还不足以指导修改代码。我们需要继续明确:从哪个事件开始,到哪个结果结束,中间包含哪些阶段。
例如,视频场景至少存在三种不同的时间:
- 用户点击或滑到视频,到播放器具备播放条件。
- 用户操作,到首个视频帧具备显示条件。
- 用户操作,到画面开始持续运动。
三个指标回答的问题不同。把 readyToPlay 的时间当成首帧时间,可能得到一组很好看的数字,同时让用户继续面对黑屏。
其他场景也需要清晰的测量口径:
| 场景 | 应当观察的结果 | 常用工具 |
|---|---|---|
| 应用启动 | 首帧出现、业务首屏可交互,分别计时 | App Launch、signpost |
| 列表滚动 | 卡顿的位置、持续时间和对应更新 | Animation Hitches、SwiftUI Instruments |
| 点击无响应 | 主线程在计算,还是在等待 | Time Profiler、Hangs、线程状态 |
| 内存增长 | 峰值、增长来源、重复操作后是否趋于稳定 | Allocations、VM Tracker、Memory Graph |
| 视频播放 | 首帧可显示、媒体时间推进、重缓冲 | 业务埋点、AVFoundation 指标、网络轨迹 |
2. 区分计算瓶颈和等待瓶颈
主线程 CPU 占用不高,也可能发生明显卡顿。它可能正在等待锁、同步 I/O 或跨进程调用。
因此,分析无响应问题时,需要区分两条路径:
1 | 主线程长时间没有完成交互 |
只看 CPU 热点,容易遗漏等待问题;只看某个函数的单次耗时,也可能忽略它被高频调用后的累计成本。
Apple 的 Analyze hangs with Instruments 对这两类问题给出了具体的分析方法。
3. 建立可以比较的基线
在 60 Hz 和 120 Hz 下,一个刷新周期分别约为 16.7 ms 和 8.3 ms。这段时间属于完整的帧流水线,留给业务代码的预算还要更少。
平均 FPS 容易掩盖偶发长停顿,因此还应观察卡顿分布和具体帧轨迹。耗时指标也应同时关注典型体验和长尾体验。
建立基线时,应固定设备、构建配置、数据规模和操作路径,并区分:
- 冷启动、暖启动与后台恢复。
- 首次访问与缓存命中。
- 不同网络条件。
- 挂载分析工具与正常运行。
样本足够时,再比较 P50、P95 等分位数,同时记录失败和用户放弃的比例。只统计成功完成的请求,会低估真实问题。
二、启动优化:缩短首屏的关键路径
1. 找出首屏真正需要等待的工作
一个常见问题是:应用把“准备完整个页面”当成了“允许用户开始使用页面”的前提。
例如,打开商品列表时,程序依次等待账户信息、列表数据、全部缩略图、推荐模块和缓存整理完成,最后才展示内容。每个步骤单独看都合理,组合起来却形成了一条过长的等待路径。
可以先画出实际依赖关系:
1 | 用户打开页面 |
随后逐项检查:
- 首屏是否需要这项工作的结果?
- 它能否与其他独立工作重叠执行?
- 它能否只处理当前可见范围?
- 它能否在首屏之后再开始?
一个有二十条内容的列表,通常没有必要等待二十张图片全部完成才展示文字。图片可以逐步出现;如果某种媒体体验确实要求内容准备完成后才能展示,也应把等待范围限制在首个可见单元。
2. 让结果交付与后续维护分开
缓存写入也可能意外进入首屏关键路径。
例如,图片下载和处理已经完成,程序却继续等待文件写入、目录扫描和缓存淘汰,最后才把图片交给界面。
更合理的流程是:
1 | 下载与处理完成 |
这样能够缩短用户等待,同时保留缓存能力。持久化失败的处理,也可以留在缓存层完成。
Apple 在 Optimizing App Launch 中强调了一个有效的顺序:先减少工作,再安排优先级,最后优化剩余工作的执行效率。
三、并发优化:明确异步代码在哪里执行
1. async 不保证后台执行
async 表示函数可以挂起,await 标记可能发生挂起的位置,它们都不承诺把后续计算移到后台。
例如:
1 | @MainActor |
网络等待期间,主线程可以处理其他任务;请求返回后,expensiveTransform 仍然在 MainActor 隔离范围内同步执行。
如果这里包含大规模排序、数据转换或图片处理,界面仍会受到影响。
在 @MainActor 隔离代码中创建普通 Task {},也可能继承相同的隔离上下文。Apple 的卡顿分析演讲,直接演示了把缩略图生成放进 .task 后,计算仍然阻塞交互的情况。
2. 分别安排 I/O、计算和状态提交
一种清楚的职责划分是:
1 | MainActor:接收输入、协调任务 |
这里需要注意:actor 主要解决共享可变状态的隔离问题。它不会为每个实例分配一条专用后台线程,也不会自动提高计算吞吐量。
包含 await 的 actor 方法还可能发生交错执行,因此请求身份和状态有效性仍然需要检查。
把所有重活都包装成 Task.detached,同样会增加生命周期管理成本。它与原任务之间不会自动具备结构化子任务的取消关系。
实际工程中,可以让异步 I/O 使用系统异步 API,将 CPU 或阻塞工作交给职责明确、并发受控的执行组件。
3. 阅读并发建议时,核对工具链版本
Swift 6.2 调整了可选择的 nonisolated async 执行行为,并提供 @concurrent 来表达并发执行意图。
因此,阅读旧文章时,应核对 Swift 版本与编译设置。判断执行位置,需要结合 actor 隔离和调用边界,不能只看函数是否带有 async。
四、图片优化:控制像素量与解码峰值
1. 文件大小与显示内存是两种成本
JPEG、HEIF 等文件的大小,主要影响传输和存储;图片进入显示流程后的内存成本,主要取决于像素尺寸和像素格式。
以常见的每像素 4 字节格式估算:
1 | 解码内存 ≈ 像素宽度 × 像素高度 × 4 |
这还没有包括压缩数据、处理过程中的临时缓冲区,以及相关渲染资源。不同位深、色彩格式和行对齐,也会改变实际占用。
因此,列表中同时处理几张大图,即使文件都不大,也可能制造明显的内存峰值。
2. 从源下采样,减少不必要的完整解码
下面两种流程,最终都能得到小图:
1 | 流程 A:原图 --> 完整解码 --> 缩小 |
它们的峰值内存却可能差很多。
Apple 的 iOS Memory Deep Dive 解释了这种差异,并展示了使用 ImageIO 下采样的方法。
下面以限制最长像素边为例:
1 | import Foundation |
这段函数本身是同步计算,应当在有并发上限的处理阶段调用。
目标尺寸需要使用像素,并结合屏幕 scale、原图比例以及 aspectFit、aspectFill 等展示方式计算。服务端如果支持尺寸变换,还可以进一步减少传输字节。
3. 分别管理原始数据和解码结果
图片缓存通常需要区分不同表示:
1 | 原始图片字节 --> 磁盘缓存 |
同一张原图的头像、列表缩略图和详情大图,需要不同的像素规格。缓存键应包含资源身份、目标尺寸和处理参数。
成熟图片库已经提供了不少能力。例如 Nuke 的 Performance Guide 涵盖下采样、后台解压、请求合并、分阶段限流和预取。
使用库时,应确认对应 API 的语义:普通 resize 处理器与从源生成缩略图,并不必然具有相同的内存表现。
此外,缓存预算需要配合解码并发预算。即使缓存最终只保留少量图片,同时进行的大量解码,依然可能在结果进入缓存前制造内存峰值。
五、请求复用:同时处理取消和过期结果
1. 共享底层工作,保留独立订阅
图片列表经常出现两张卡片同时需要同一资源的情况。
合理的请求管线可以合并底层工作:
1 | 卡片 A --订阅--+ |
当 A 离屏时,它应退出自己的订阅;只要 B 仍然需要结果,底层任务就有继续执行的价值。
等所有消费者都退出后,再根据缓存策略取消底层工作。Nuke 的共享请求取消机制采用了类似思路。
这里的关键是:取消权需要对应资源的所有权。
2. 取消之后,仍然检查结果归属
刷新场景中,经常出现这样的时序:
1 | 旧请求开始 ------------------ 旧请求返回 |
即使已经调用旧任务的 cancel(),仍然需要处理迟到结果。
Swift 的取消是协作式的。某些同步工作或已经投递的回调,不会立即停止。
因此,可以为每次加载分配请求身份或代次,在发布结果前确认:
- 当前任务没有被取消。
- 结果仍属于当前请求。
- 目标页面、账户或播放会话仍然有效。
旧请求的成功结果不能覆盖新数据,旧请求的错误也不应覆盖新页面的成功状态。
这还会影响并发限制:如果底层操作尚未结束,仅仅发出取消信号就立即归还名额,实际并发可能超过设定上限。调度器需要区分“调用者已经退出”和“资源已经停止占用”。
六、SwiftUI 优化:缩小更新范围,识别渲染瓶颈
1. 把高频状态留在真正需要它的视图
播放进度、下载百分比、滚动位置等状态,会高频变化。如果整个页面都读取这些状态,局部更新就可能扩大成列表重算、卡片重建和重复布局。
可以把依赖范围组织成:
1 | 页面:内容集合、导航、刷新状态 |
真正重要的是状态读取发生在哪里。
如果父视图仍然读取高频进度,再把值传给整个子树,即使把代码拆成多个文件,也没有改变更新边界。提取视图时,应同步缩小输入和实际依赖范围。
2. 保持 body 便宜,保持列表身份稳定
排序、过滤、大规模聚合和昂贵对象创建,更适合在相关数据变化时完成,并复用处理结果。
列表身份也需要稳定。使用每次计算都会变化的 ID,可能让框架把原来的内容当成新内容,重新建立状态和资源生命周期。
Apple 的 Demystify SwiftUI performance 还指出,在 List、Table 的特定场景中,如果 ForEach 每个元素生成的行数不恒定,框架可能需要提前构造更多内容。
把过滤移到数据阶段,通常更容易控制这部分成本。
3. 将界面更新与 GPU 渲染分别分析
界面更新快,也不代表渲染一定快。
Apple 的 Explore UI animation hitches and the render loop 将流水线分为:
1 | 事件处理 --> 提交 --> 渲染准备 --> 渲染执行 --> 显示 |
卡顿可能发生在应用进程,也可能发生在渲染服务或 GPU 阶段。
因此,应先定位卡顿所属阶段,再决定修改状态依赖、布局、图片规格还是视觉效果。
光栅化、drawingGroup() 等手段也需要测量。它们可能节省重复绘制,同时也会增加纹理和重建成本。
七、预加载优化:为当前交互保留资源
1. 预加载的收益需要覆盖它的成本
预加载把未来可能需要的工作提前执行,可以缩短后续等待;同时,它也提前消耗带宽、CPU 和内存。
假设一个视频列表同时准备后续内容、下载头像、解码封面。网络充足时,所有任务一起执行可能很快;弱网下,当前视频却可能因为资源竞争而更容易缓冲。
一个稳健的调度顺序是:
1 | 当前交互所必需的工作 |
仅设置任务优先级通常不够。优先级是调度提示,不能直接保证带宽份额。有效的控制还包括准入限制、并发上限和候选范围。
2. 根据交互状态调整资源预算
| 当前状态 | 网络预取 | 额外解码与资源准备 |
|---|---|---|
| 当前内容正在起播或缓冲 | 减少新预取的准入 | 优先满足当前内容 |
| 用户快速滚动 | 更新候选,减少远端预取 | 避免为短暂候选反复创建资源 |
| 播放稳定、滚动停止 | 逐步恢复 | 准备最可能使用的相邻内容 |
| 页面失活 | 停止不再需要的工作 | 按生命周期保留或释放 |
网络和解码应分别限流。缓存命中虽然不需要下载,仍可能触发磁盘读取和解码;下载并发与解码并发没有理由必须使用同一个数字。
策略恢复也需要稳定条件。资源紧张时可以迅速降低预取,恢复时则等待缓冲或交互状态持续稳定,避免在阈值附近反复启停。
对于快速滚动的列表,可以合并候选变化:保留最新候选,在目标稳定后再创建额外播放器或执行昂贵准备。当前可见内容仍然按正常路径响应。
验证预加载时,除了命中率,还应记录未使用的预取字节、额外内存和当前内容的卡顿情况。
八、视频优化:把播放器和媒体内容放进同一条链路
1. 先拆解起播过程
客户端可以优化请求时机、复用播放器、减少竞争,但起播还取决于清单、初始化数据、码率、关键帧和分片组织。
一条简化链路是:
1 | 用户操作 |
这些阶段存在重叠,不能把所有累计耗时直接相加。
如果实际轨迹表明,起播主要在等待较大的首段媒体数据,应继续检查:
- 是否存在适合当前网络的低码率档位。
- 分片与关键帧是否满足播放和切换要求。
- CDN 响应、传输吞吐和播放器请求行为如何。
- 客户端预取是否在争夺同一条链路。
Apple 的 HLS Authoring Specification 对分片、关键帧和多码率变体有明确要求。
自适应码率需要媒体端提供可切换的档位,播放器无法从一个固定质量的流中选择不存在的低码率版本。
分片长度也需要结合场景权衡。更短的分片会改变请求频率、封装和编码成本,不能简单套用“越短起播越快”。
2. 正确理解缓冲参数和首帧信号
preferredForwardBufferDuration 表达的是偏好的前向缓冲时长,实际行为仍由播放器决定。
降低它可能减少资源需求,也可能增加重缓冲风险,不能把它当成精确的下载上限或稳定生效的起播加速开关。
测量首帧时,AVPlayerLayer.isReadyForDisplay 可以作为“首帧可显示”的信号;还应结合显示层是否绑定、窗口和尺寸是否有效来判断。
媒体时间推进则可以帮助确认播放确实在继续。
这些信号属于应用层观测。需要精确验证屏幕上的实际呈现时,应结合屏幕录制或帧级分析。
九、生命周期优化:让资源按明确的边界退出
1. 为任务、观察者和播放器确定持有者
播放器暂停后,仍可能持有媒体数据、观察者和渲染资源;视图离屏后,异步任务也可能继续运行。
因此,每类资源都应有明确的持有者:
1 | 会话开始 |
例如,从列表进入同一视频的详情页,如果产品希望继续播放,可以移交原播放器,避免重新下载、解码和起播。
离开整个播放会话时,则应按预算释放资源。是否复用,需要同时明确状态归属和释放责任。
2. 控制观测代码自身的开销
看似普通的属性读取,可能触发同步计算或系统交互;日志格式化、资源统计和写盘,也会增加负担。
比较稳妥的做法是:
- 在事件发生处记录必要时间和少量字段。
- 把不可变快照交给后台处理。
- 为采样频率、队列长度和存储量设置上限。
- 给缓存的诊断数据附带采样时间。
- 对尚未获得的数据保留“未知”语义。
这样既能减少观测对体验的干扰,也能避免把旧数据解释成实时状态。
十、验证优化:同时检查行为正确和性能收益
功能测试可以证明取消、刷新、复用和失败恢复符合预期;真机性能数据负责证明等待、卡顿或内存开销确实下降。
一条有价值的回归路径可以包含:
1 | 首次打开 |
在相同条件下,观察首屏与起播时间、卡顿分布、峰值内存、资源保留量,以及网络和计算成本。
预加载更积极之后,还应检查长时间运行时的温度和能耗表现。短时间内更快,可能伴随更高的持续资源消耗。
本地 Instruments 负责解释具体问题;MetricKit 等系统能力,适合补充真实设备上的汇总指标和诊断;业务埋点则用于记录单次用户路径。
三者覆盖的时间范围和采集方式不同,应结合使用。
每次性能改动,都可以留下四项记录:
问题: 用户在哪条路径上遇到什么等待。
证据: 哪个阶段消耗了时间或资源。
修改: 减少、延后、复用了哪些工作,如何限制竞争。
验证: 在什么设备和条件下,哪些指标改善,付出了什么代价。