开篇
性能问题最麻烦的地方不在于「不会优化」,而在于「不知道哪里慢」。没有度量就动手改代码,本质是在猜。iOS 生态给了一套相当完整的度量工具:本地的 Instruments、线上的 MetricKit、Xcode Organizer 的聚合看板,以及 XCTest 里的 measure 基准测试。
本文按「先度量、再定位、后优化、再回归」的闭环组织,覆盖启动时间、卡顿掉帧、内存、渲染、电量与网络六大方向,并给出 Xcode 15/16 + Instruments 15/16 时代可复现的操作姿势。
一、启动时间优化:从 pre-main 到首帧
1.1 冷启动与热启动的界定
先把术语钉死,否则团队里「启动慢」三个字会被讨论成一锅粥。
| 启动类型 | 触发条件 | 是否包含 dyld | 优化空间 |
|---|---|---|---|
| 冷启动(Cold Launch) | 进程不存在,从磁盘加载 Mach-O | 是 | 最大,也最难 |
| 热启动(Warm Launch) | 进程被系统保留在内存 | 否或部分 | 小 |
| 恢复(Resume) | App 从后台切回前台 | 否 | 基本无关启动 |
| 首次启动(First Launch) | 安装后第一次运行 | 是 | 额外含磁盘预热 |
Apple 官方对「启动完成」的定义是 applicationDidFinishLaunching 返回,但用户感知的启动终点其实是首帧绘制完成。做优化时建议以「首帧可见」为北极星指标,而不是只看 didFinishLaunching 的时间戳。
1.2 pre-main 阶段:dyld 与静态初始化
pre-main 阶段发生在你的任何 Swift 代码执行之前,耗时由 dyld(动态链接器)决定。它主要做三件事:
- 加载可执行文件与所有依赖的动态库(dylib);
- 对符号进行重定位与绑定(rebase / bind);
- 运行 C++ 静态构造器与 Objective-C 的
+load方法。
系统会为 App 预加载一部分系统框架(称为 shared cache),但你自己链接的每一个动态库都会带来固定开销。因此第一条优化原则是:减少动态库数量,优先用静态库。
// 反面示例:大量小模块各自编译成动态 framework
// 每个 .framework 都会在 pre-main 阶段被 dyld 单独加载、重定位
// 推荐:用 SwiftPM 的静态库产物,或合并成单个动态库
// 检查当前工程链接了多少动态库(在 Debug 控制台或 lldb 中)
// 也可以在构建产物上直接看:
// otool -L YourApp.app/YourApp
要量化 pre-main 耗时,开启 DYLD_PRINT_STATISTICS 环境变量即可拿到 dyld 的分段耗时:
# Xcode → Edit Scheme → Run → Arguments → Environment Variables
DYLD_PRINT_STATISTICS=1
DYLD_PRINT_STATISTICS_DETAILS=1
# 运行后控制台会输出:
# Total pre-main time: 320.45 milliseconds (100.0%)
# dylib loading time: 180.22 milliseconds (56.2%)
# rebase/binding time: 40.11 milliseconds (12.5%)
# ObjC setup time: 35.60 milliseconds (11.1%)
# initializer time: 64.50 milliseconds (20.1%)
经验阈值:pre-main 超过 400ms 就值得动手,超过 800ms 用户会明显感知。常见的 pre-main 优化手段:
- 用
otool -L列出动态库,把仅内部使用的库改成静态链接; - 清理无用的
+load方法,改用+initialize或显式初始化; - 避免在静态构造器里做 I/O、网络、大数组初始化;
- 用 Xcode 的「Link-Time Optimization」与
-dead_strip减少无用符号。
1.3 main 之后到首帧
main 之后的时间基本由你自己的代码决定,也是优化收益最大的部分。用 Instruments 的 App Launch 模板可以拿到完整时间轴:它会自动注入 os_signpost,把 pre-main、didFinishLaunching、首帧渲染拆开。
import os
// 用 signpost 给自己的关键阶段打点,App Launch 模板会直接渲染出来
let log = OSLog(subsystem: "com.example.app", category: .pointsOfInterest)
func bootstrap() {
os_signpost(.begin, log: log, name: "Bootstrap")
// 只做「渲染首帧必需」的事情
os_signpost(.end, log: log, name: "Bootstrap")
}
这一段的核心原则是延迟一切非必要工作:把统计 SDK 初始化、预加载、数据同步统统挪到首帧之后,用 DispatchQueue.main.async 或 Task.detached 延后执行。
取舍:延后初始化会让「首帧快」但「首屏数据晚到」。正确做法是把首帧所需的最小数据集(例如骨架屏所需的结构)同步准备好,把「锦上添花」的内容异步补上。
二、Instruments 模板实战
Instruments 15/16 已经全面转向「模板 + 时间轴」模型,采样与分析都更直观。下面按排查目标介绍最常用的六个模板。
2.1 Time Profiler:找 CPU 热点
Time Profiler 以固定频率(默认 1ms)对线程栈采样,统计每个函数占用的 CPU 时间。读图的关键动作:
- 勾选 Invert Call Tree(自底向上看热点函数);
- 勾选 Hide System Libraries,先看自己的代码;
- 打开 Call Tree → Separate by Thread,确认热点是否在主线程。
典型发现:主线程里出现 JSONDecoder.decode、DateFormatter.date(from:)、NSCoding 反序列化等同步重活。这些都应该移出主线程或做缓存。
2.2 Allocations 与 Leaks:内存
| 模板 | 作用 | 关键指标 |
|---|---|---|
| Allocations | 追踪对象分配与释放 | Persistent Bytes、# Persistent |
| Leaks | 检测泄漏对象 | Leaks 数量、泄漏对象引用链 |
| VM Tracker | 虚拟内存与 dirty memory | Dirty Size、Resident Size |
Allocations 里要盯的是 Persistent 曲线而不是 Total:Total 高只说明分配频繁(可能被复用池消化),Persistent 持续上涨才是真泄漏。Leaks 模板擅长找循环引用,但对「逻辑上不释放」的对象无能为力,此时回到 Allocations 看 Generation 分析更有效。
2.3 Animation Hitches 与 Core Animation
Animation Hitches 是 iOS 15 之后新增的模板,专门度量掉帧:它会给出 hitch 的持续时间、发生时间与受影响的手势/动画,比老的 Core Animation FPS 计数器精确得多。
Core Animation 模板的经典用途是勾选 Color Offscreen-Rendered Yellow 与 Color Blended Layers:前者把离屏渲染的图层标黄,后者把透明混合的图层标红。大量黄色往往意味着 cornerRadius + masksToBounds、shadow、shouldRasterize 被滥用。
// 离屏渲染的经典触发与规避
final class AvatarView: UIView {
private let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
// 差:cornerRadius + masksToBounds 触发离屏渲染
// layer.cornerRadius = 24
// layer.masksToBounds = true
// 好:用贝塞尔路径预裁剪,避免运行时离屏合成
imageView.layer.cornerRadius = 24
imageView.layer.masksToBounds = true
imageView.layer.shouldRasterize = false
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
2.4 Energy Log 与网络
Energy Log 把电量消耗归因到 CPU、GPU、网络、定位、显示等子系统。最常见的耗电元凶不是计算,而是高频网络唤醒:后台轮询、频繁的小请求、未合并的埋点上报,都会让射频长时间处于高功率状态。对策是把请求合并批量、拉长轮询间隔、用 URLSession 的后台传输。
三、卡顿与掉帧排查
3.1 主线程阻塞
iOS 的渲染流水线要求主线程在 16.67ms(60Hz)或 8.33ms(120Hz ProMotion)内完成「布局 → 绘制 → 提交」。任何超过这个预算的主线程同步操作都会造成掉帧。
排查顺序:先在 Time Profiler 里确认主线程热点,再用 Animation Hitches 定位是哪个交互触发的,最后回到代码定位同步调用点。
3.2 布局开销
Auto Layout 的代价随约束数量与视图层级非线性增长。常见问题:
- 深层嵌套的
UIStackView,每一层都要跑一遍约束求解; UITableViewCell里用systemLayoutSizeFitting反复计算自适应高度;- 在
scrollViewDidScroll里改约束触发重排。
对策是「预计算 + 复用」:用 UICollectionViewCompositionalLayout 的估算尺寸配合缓存的行高,避免滚动中反复求解。
3.3 CADisplayLink 与帧率度量
CADisplayLink 与屏幕刷新同步,是自建帧率监控的基础。iOS 15 起还提供了 CADisplayLink.preferredFrameRateRange 来显式声明期望帧率。
final class FPSMonitor {
private var link: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var frameCount = 0
private(set) var fps: Double = 0
func start() {
let link = CADisplayLink(target: self, selector: #selector(tick))
// iOS 15+:声明期望帧率范围,系统据此做动态调度
link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 120, preferred: 120)
link.add(to: .main, forMode: .common)
self.link = link
}
@objc private func tick(_ link: CADisplayLink) {
guard lastTimestamp != 0 else { lastTimestamp = link.timestamp; return }
frameCount += 1
let elapsed = link.timestamp - lastTimestamp
if elapsed >= 1.0 {
fps = Double(frameCount) / elapsed
frameCount = 0
lastTimestamp = link.timestamp
}
}
func stop() { link?.invalidate(); link = nil }
}
注意:自建 FPS 监控本身会带来开销,且高频回调容易掩盖真实问题。生产环境更推荐直接消费 MetricKit 的
MXMetricPayload,让系统在后台采样。
四、MetricKit 与线上度量闭环
本地 Instruments 只能覆盖开发机上的场景,真正的线上数据要靠 MetricKit。它由系统在后台按天聚合,覆盖崩溃、卡顿、启动、内存、CPU、电量等指标,通过 MXMetricManager 回调。
import MetricKit
final class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
static let shared = MetricsSubscriber()
func register() {
MXMetricManager.shared.add(self)
}
// 每日指标:启动时间、内存峰值、CPU 时间等
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let launch = payload.applicationLaunchMetrics {
let histogram = launch.histogrammedTimeToFirstDraw
// 上报 histogram.bucketEnumerator 到自己的分析后台
print("TTFD buckets: \(histogram.bucketEnumerator)")
}
}
}
// 诊断报告:卡顿(hitch)与崩溃的调用栈
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
payload.hangDiagnostics?.forEach { hang in
print("hang duration: \(hang.hangDuration)")
}
}
}
}
关键点:didReceive 的回调不保证在主线程,也不保证及时(通常在设备充电、空闲时批量投递)。因此不要在里面做重活,把 payload 序列化后交给后台队列上报即可。
Xcode Organizer 的 Crashes / Hangs / Launch / Energy / Disk Writes 面板会聚合同一 App 版本的全网数据,是判断「这次优化到底有没有效果」的最权威依据。
五、SwiftUI 性能剖析
SwiftUI 的性能问题与 UIKit 有本质区别:它不是「视图层级太深」,而是视图更新过于频繁与身份(identity)不稳定。
三条核心原则:
- 缩小
@Published/@Observable的粒度。一个巨大的AppState会让任何字段变化都触发全树刷新。 - 稳定
ForEach的 id。用\.self或索引做 id 会在数据重排时让所有行重建。 - 把重活移出
body。body是纯函数、可能被高频调用,任何DateFormatter、排序、过滤都应该预计算。
import SwiftUI
// 差:每次 body 求值都新建 DateFormatter 并重新排序
struct BadListView: View {
let items: [Item]
var body: some View {
List(items.sorted { $0.date > $1.date }, id: \.self) { item in
Text(DateFormatter.localizedString(from: item.date, dateStyle: .short, timeStyle: .none))
}
}
}
// 好:用稳定的 id、预排序、缓存 formatter
struct GoodListView: View {
let items: [Item]
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .short
return f
}()
private var sortedItems: [Item] {
items.sorted { $0.date > $1.date }
}
var body: some View {
List(sortedItems, id: \.stableID) { item in
Text(Self.formatter.string(from: item.date))
}
}
}
用 Instruments 的 SwiftUI 模板可以直观看到 View.body 的调用次数与耗时,配合 os_signpost 标记「为什么刷新」,能快速定位到是哪次状态变更引发的连锁更新。
六、优化手段与度量闭环
性能优化最大的敌人是「凭感觉改」。推荐固定成闭环流程:
# perf-loop.yaml —— 把度量固化成流水线里的可执行配置
performance_loop:
baseline:
device: "iPhone 15 Pro"
os: "iOS 17.x"
metric: "cold_launch_ms"
runs: 5 # 取中位数,避免抖动
measure:
local: "Instruments App Launch / Time Profiler"
ci: "XCTest measure + XCTMetric (Xcode 15+)"
online: "MetricKit + Xcode Organizer"
gate:
cold_launch_ms: "<= 800"
persistent_bytes_mb: "<= 150"
hitch_rate: "<= 5 per 1000 frames"
regression_rule: "超过基线 10% 即判定回归,阻断合并"
在 CI 里用 XCTest 的 XCTApplicationLaunchMetric 可以做自动化的启动基准:
import XCTest
final class LaunchPerformanceTests: XCTestCase {
func testColdLaunchPerformance() throws {
// Xcode 15+ 的 XCTApplicationLaunchMetric,需要真机
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
注意这类测试必须在真机上跑,模拟器的启动时间没有参考价值。同时要控制变量:同一机型、同一系统版本、关闭调试器、多次取中位数。
七、常见性能坑清单
- 在
viewDidLoad里做同步 I/O:磁盘读、UserDefaults大批量读取、Keychain 访问都会阻塞首帧。 DateFormatter/NumberFormatter反复创建:它们初始化成本极高,务必用静态实例缓存。String拼接用+=:O(n²),改用Array+joined()。- 在
scrollViewDidScroll里做布局计算:每帧调用,成本被放大百倍。 - 图片未降采样:把 4000×3000 的原图直接塞进 100×100 的
UIImageView,解码与显存都浪费。用CGImageSourceCreateThumbnailAtIndex预降采样。 - 离屏渲染滥用:
cornerRadius + masksToBounds、shadow无shadowPath、shouldRasterize过度使用。 - 主线程等待信号量:
DispatchSemaphore.wait()在主线程序列上极易死锁,也必然掉帧。 @Published大对象:一次发布触发整棵 SwiftUI 树重建。- 忽略 ProMotion:120Hz 设备上帧预算是 8.33ms,按 16.67ms 写代码就会掉帧。
- 只在模拟器上测性能:模拟器用 Mac 的 CPU/GPU,结论不可迁移。
结论:先量化,再优化。Instruments 定位本地热点,MetricKit 建立线上基线,XCTest 把基线固化成回归门禁,三者缺一不可。任何一次优化都必须有「优化前 / 优化后」的同条件对比数据,否则等于没做。
相关阅读
- Swift ARC 与内存管理 — 循环引用与内存泄漏的底层机制,配合 Allocations/Leaks 模板阅读效果最好
- SwiftUI 声明式状态管理 — 视图更新与身份机制,是 SwiftUI 性能问题的根源
- iOS 测试体系与持续集成 — 用 XCTest 的 measure 把性能基线接入 CI 门禁
小结
本文把 iOS 性能调优拆成了一条完整的度量闭环:
- 启动时间分 pre-main(dyld 加载、重定位、静态初始化)与 main 到首帧两段,前者靠减少动态库与
+load,后者靠延迟非必要初始化; - Instruments 六个核心模板各司其职——Time Profiler 找 CPU 热点、Allocations/Leaks 看内存、Animation Hitches 量掉帧、Core Animation 看离屏渲染、Energy Log 归因耗电;
- 卡顿的根因几乎都在主线程超预算,布局开销与同步 I/O 是两个高频元凶;
- 线上度量靠 MetricKit 与 Xcode Organizer,它们才是判断优化是否生效的权威依据;
- SwiftUI 性能的关键是缩小状态粒度、稳定视图身份、把重活移出
body; - 最终要把基线固化成 CI 门禁,用
XCTApplicationLaunchMetric等指标阻断性能回归。
性能优化的收益是复利的:一次启动优化、一次掉帧修复,会作用在之后每一次发版的每一个用户身上。但前提永远是同一句话——没有度量,就没有优化。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。