前言

一张只有几 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
2
3
4
5
主线程长时间没有完成交互
|
+--> 持续运行:检查计算、布局、对象创建和调用频率
|
+--> 处于等待:检查锁、同步 I/O 和系统调用

只看 CPU 热点,容易遗漏等待问题;只看某个函数的单次耗时,也可能忽略它被高频调用后的累计成本。

Apple 的 Analyze hangs with Instruments 对这两类问题给出了具体的分析方法。

3. 建立可以比较的基线

在 60 Hz 和 120 Hz 下,一个刷新周期分别约为 16.7 ms 和 8.3 ms。这段时间属于完整的帧流水线,留给业务代码的预算还要更少。

平均 FPS 容易掩盖偶发长停顿,因此还应观察卡顿分布和具体帧轨迹。耗时指标也应同时关注典型体验和长尾体验。

建立基线时,应固定设备、构建配置、数据规模和操作路径,并区分:

  • 冷启动、暖启动与后台恢复。
  • 首次访问与缓存命中。
  • 不同网络条件。
  • 挂载分析工具与正常运行。

样本足够时,再比较 P50、P95 等分位数,同时记录失败和用户放弃的比例。只统计成功完成的请求,会低估真实问题。

二、启动优化:缩短首屏的关键路径

1. 找出首屏真正需要等待的工作

一个常见问题是:应用把“准备完整个页面”当成了“允许用户开始使用页面”的前提。

例如,打开商品列表时,程序依次等待账户信息、列表数据、全部缩略图、推荐模块和缓存整理完成,最后才展示内容。每个步骤单独看都合理,组合起来却形成了一条过长的等待路径。

可以先画出实际依赖关系:

1
2
3
4
5
用户打开页面
|
+--> 必需数据 --> 最小状态整理 --> 首屏布局 --> 用户可以操作
|
+--> 首屏后安排:非可见内容预取、缓存维护、非必要初始化

随后逐项检查:

  1. 首屏是否需要这项工作的结果?
  2. 它能否与其他独立工作重叠执行?
  3. 它能否只处理当前可见范围?
  4. 它能否在首屏之后再开始?

一个有二十条内容的列表,通常没有必要等待二十张图片全部完成才展示文字。图片可以逐步出现;如果某种媒体体验确实要求内容准备完成后才能展示,也应把等待范围限制在首个可见单元。

2. 让结果交付与后续维护分开

缓存写入也可能意外进入首屏关键路径。

例如,图片下载和处理已经完成,程序却继续等待文件写入、目录扫描和缓存淘汰,最后才把图片交给界面。

更合理的流程是:

1
2
3
4
5
下载与处理完成
|
+--> 交付可展示结果
|
+--> 独立安排持久化与缓存维护

这样能够缩短用户等待,同时保留缓存能力。持久化失败的处理,也可以留在缓存层完成。

Apple 在 Optimizing App Launch 中强调了一个有效的顺序:先减少工作,再安排优先级,最后优化剩余工作的执行效率。

三、并发优化:明确异步代码在哪里执行

1. async 不保证后台执行

async 表示函数可以挂起,await 标记可能发生挂起的位置,它们都不承诺把后续计算移到后台。

例如:

1
2
3
4
5
6
7
@MainActor
func reload() async throws {
let data = try await fetchData()

let items = expensiveTransform(data)
self.items = items
}

网络等待期间,主线程可以处理其他任务;请求返回后,expensiveTransform 仍然在 MainActor 隔离范围内同步执行。

如果这里包含大规模排序、数据转换或图片处理,界面仍会受到影响。

在 @MainActor 隔离代码中创建普通 Task {},也可能继承相同的隔离上下文。Apple 的卡顿分析演讲,直接演示了把缩略图生成放进 .task 后,计算仍然阻塞交互的情况。

2. 分别安排 I/O、计算和状态提交

一种清楚的职责划分是:

1
2
3
4
5
6
7
8
9
10
MainActor:接收输入、协调任务
|
v
异步 I/O:等待网络或文件结果
|
v
有并发上限的计算阶段:解码、转换、排序
|
v
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
2
3
解码内存 ≈ 像素宽度 × 像素高度 × 4

4032 × 3024 × 4 ≈ 46.5 MiB

这还没有包括压缩数据、处理过程中的临时缓冲区,以及相关渲染资源。不同位深、色彩格式和行对齐,也会改变实际占用。

因此,列表中同时处理几张大图,即使文件都不大,也可能制造明显的内存峰值。

2. 从源下采样,减少不必要的完整解码

下面两种流程,最终都能得到小图:

1
2
流程 A:原图 --> 完整解码 --> 缩小
流程 B:原图 --> 按目标尺寸生成缩略图

它们的峰值内存却可能差很多。

Apple 的 iOS Memory Deep Dive 解释了这种差异,并展示了使用 ImageIO 下采样的方法。

下面以限制最长像素边为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import Foundation
import CoreGraphics
import ImageIO

func downsample(_ data: Data, maxPixelSize: Int) -> CGImage? {
guard maxPixelSize > 0,
let source = CGImageSourceCreateWithData(
data as CFData,
[kCGImageSourceShouldCache: false] as CFDictionary
)
else {
return nil
}

let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceShouldCacheImmediately: true
]

return CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
)
}

这段函数本身是同步计算,应当在有并发上限的处理阶段调用。

目标尺寸需要使用像素,并结合屏幕 scale、原图比例以及 aspectFit、aspectFill 等展示方式计算。服务端如果支持尺寸变换,还可以进一步减少传输字节。

3. 分别管理原始数据和解码结果

图片缓存通常需要区分不同表示:

1
2
原始图片字节 --> 磁盘缓存
目标尺寸图片 --> 解码后的内存缓存

同一张原图的头像、列表缩略图和详情大图,需要不同的像素规格。缓存键应包含资源身份、目标尺寸和处理参数。

成熟图片库已经提供了不少能力。例如 Nuke 的 Performance Guide 涵盖下采样、后台解压、请求合并、分阶段限流和预取。

使用库时,应确认对应 API 的语义:普通 resize 处理器与从源生成缩略图,并不必然具有相同的内存表现。

此外,缓存预算需要配合解码并发预算。即使缓存最终只保留少量图片,同时进行的大量解码,依然可能在结果进入缓存前制造内存峰值。

五、请求复用:同时处理取消和过期结果

1. 共享底层工作,保留独立订阅

图片列表经常出现两张卡片同时需要同一资源的情况。

合理的请求管线可以合并底层工作:

1
2
3
卡片 A --订阅--+
+--> 同一次下载 --> 同一次处理
卡片 B --订阅--+

当 A 离屏时,它应退出自己的订阅;只要 B 仍然需要结果,底层任务就有继续执行的价值。

等所有消费者都退出后,再根据缓存策略取消底层工作。Nuke 的共享请求取消机制采用了类似思路。

这里的关键是:取消权需要对应资源的所有权。

2. 取消之后,仍然检查结果归属

刷新场景中,经常出现这样的时序:

1
2
3
旧请求开始 ------------------ 旧请求返回
|
+--> 用户刷新 --> 新请求返回

即使已经调用旧任务的 cancel(),仍然需要处理迟到结果。

Swift 的取消是协作式的。某些同步工作或已经投递的回调,不会立即停止。

因此,可以为每次加载分配请求身份或代次,在发布结果前确认:

  • 当前任务没有被取消。
  • 结果仍属于当前请求。
  • 目标页面、账户或播放会话仍然有效。

旧请求的成功结果不能覆盖新数据,旧请求的错误也不应覆盖新页面的成功状态。

这还会影响并发限制:如果底层操作尚未结束,仅仅发出取消信号就立即归还名额,实际并发可能超过设定上限。调度器需要区分“调用者已经退出”和“资源已经停止占用”。

六、SwiftUI 优化:缩小更新范围,识别渲染瓶颈

1. 把高频状态留在真正需要它的视图

播放进度、下载百分比、滚动位置等状态,会高频变化。如果整个页面都读取这些状态,局部更新就可能扩大成列表重算、卡片重建和重复布局。

可以把依赖范围组织成:

1
2
3
4
5
6
7
页面:内容集合、导航、刷新状态
|
+--> 卡片:标题、作者、操作状态
|
+--> 媒体区域:播放、静音、首帧状态
|
+--> 进度条:高频进度

真正重要的是状态读取发生在哪里。

如果父视图仍然读取高频进度,再把值传给整个子树,即使把代码拆成多个文件,也没有改变更新边界。提取视图时,应同步缩小输入和实际依赖范围。

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
3
4
5
6
7
8
9
10
当前交互所必需的工作
|
v
当前可见内容
|
v
最可能立即使用的相邻内容
|
v
更远的预取与维护任务

仅设置任务优先级通常不够。优先级是调度提示,不能直接保证带宽份额。有效的控制还包括准入限制、并发上限和候选范围。

2. 根据交互状态调整资源预算

当前状态 网络预取 额外解码与资源准备
当前内容正在起播或缓冲 减少新预取的准入 优先满足当前内容
用户快速滚动 更新候选,减少远端预取 避免为短暂候选反复创建资源
播放稳定、滚动停止 逐步恢复 准备最可能使用的相邻内容
页面失活 停止不再需要的工作 按生命周期保留或释放

网络和解码应分别限流。缓存命中虽然不需要下载,仍可能触发磁盘读取和解码;下载并发与解码并发没有理由必须使用同一个数字。

策略恢复也需要稳定条件。资源紧张时可以迅速降低预取,恢复时则等待缓冲或交互状态持续稳定,避免在阈值附近反复启停。

对于快速滚动的列表,可以合并候选变化:保留最新候选,在目标稳定后再创建额外播放器或执行昂贵准备。当前可见内容仍然按正常路径响应。

验证预加载时,除了命中率,还应记录未使用的预取字节、额外内存和当前内容的卡顿情况。

八、视频优化:把播放器和媒体内容放进同一条链路

1. 先拆解起播过程

客户端可以优化请求时机、复用播放器、减少竞争,但起播还取决于清单、初始化数据、码率、关键帧和分片组织。

一条简化链路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户操作
|
v
获得播放地址 --> 加载清单 --> 获取必要媒体数据
|
v
解复用与解码
|
v
首帧具备显示条件
|
v
持续播放与后续缓冲

这些阶段存在重叠,不能把所有累计耗时直接相加。

如果实际轨迹表明,起播主要在等待较大的首段媒体数据,应继续检查:

  • 是否存在适合当前网络的低码率档位。
  • 分片与关键帧是否满足播放和切换要求。
  • CDN 响应、传输吞吐和播放器请求行为如何。
  • 客户端预取是否在争夺同一条链路。

Apple 的 HLS Authoring Specification 对分片、关键帧和多码率变体有明确要求。

自适应码率需要媒体端提供可切换的档位,播放器无法从一个固定质量的流中选择不存在的低码率版本。

分片长度也需要结合场景权衡。更短的分片会改变请求频率、封装和编码成本,不能简单套用“越短起播越快”。

2. 正确理解缓冲参数和首帧信号

preferredForwardBufferDuration 表达的是偏好的前向缓冲时长,实际行为仍由播放器决定。

降低它可能减少资源需求,也可能增加重缓冲风险,不能把它当成精确的下载上限或稳定生效的起播加速开关。

测量首帧时,AVPlayerLayer.isReadyForDisplay 可以作为“首帧可显示”的信号;还应结合显示层是否绑定、窗口和尺寸是否有效来判断。

媒体时间推进则可以帮助确认播放确实在继续。

这些信号属于应用层观测。需要精确验证屏幕上的实际呈现时,应结合屏幕录制或帧级分析。

九、生命周期优化:让资源按明确的边界退出

1. 为任务、观察者和播放器确定持有者

播放器暂停后,仍可能持有媒体数据、观察者和渲染资源;视图离屏后,异步任务也可能继续运行。

因此,每类资源都应有明确的持有者:

1
2
3
4
5
6
7
8
9
10
11
会话开始
|
+--> 持有任务、播放器、观察者、临时资源
|
页面间延续同一体验
|
+--> 显式移交必要资源,保持原有生命周期
|
会话结束
|
+--> 取消任务、移除观察、解除绑定、清理临时资源

例如,从列表进入同一视频的详情页,如果产品希望继续播放,可以移交原播放器,避免重新下载、解码和起播。

离开整个播放会话时,则应按预算释放资源。是否复用,需要同时明确状态归属和释放责任。

2. 控制观测代码自身的开销

看似普通的属性读取,可能触发同步计算或系统交互;日志格式化、资源统计和写盘,也会增加负担。

比较稳妥的做法是:

  • 在事件发生处记录必要时间和少量字段。
  • 把不可变快照交给后台处理。
  • 为采样频率、队列长度和存储量设置上限。
  • 给缓存的诊断数据附带采样时间。
  • 对尚未获得的数据保留“未知”语义。

这样既能减少观测对体验的干扰,也能避免把旧数据解释成实时状态。

十、验证优化:同时检查行为正确和性能收益

功能测试可以证明取消、刷新、复用和失败恢复符合预期;真机性能数据负责证明等待、卡顿或内存开销确实下降。

一条有价值的回归路径可以包含:

1
2
3
4
5
6
7
首次打开
--> 连续滚动
--> 快速反向
--> 进入详情并返回
--> 切到后台再恢复
--> 刷新
--> 重复整个过程

在相同条件下,观察首屏与起播时间、卡顿分布、峰值内存、资源保留量,以及网络和计算成本。

预加载更积极之后,还应检查长时间运行时的温度和能耗表现。短时间内更快,可能伴随更高的持续资源消耗。

本地 Instruments 负责解释具体问题;MetricKit 等系统能力,适合补充真实设备上的汇总指标和诊断;业务埋点则用于记录单次用户路径。

三者覆盖的时间范围和采集方式不同,应结合使用。

每次性能改动,都可以留下四项记录:

问题: 用户在哪条路径上遇到什么等待。
证据: 哪个阶段消耗了时间或资源。
修改: 减少、延后、复用了哪些工作,如何限制竞争。
验证: 在什么设备和条件下,哪些指标改善,付出了什么代价。

站内搜索

没有找到内容!