App 启动速度优化:冷启动从 3s 降到 800ms 完整实操
启动优化不是"玄学调参",而是一条时间轴工程:
点图标 → 进程创建 → dyld → Runtime → main → didFinishLaunching → 首屏布局 → 第一帧可交互。
3s 的 App,通常不是某一行代码慢,而是"每个阶段都在偷偷加税"。
下面按可落地、可复盘、可防劣化的方式讲。
一、先定义"冷启动时间",不然优化会吵起来
不同团队口径不一样:
- ❌ 从
main到didFinishLaunching结束(太窄) - ❌ 从点图标到首页网络数据回来(太宽)
- ✅ 从用户点击 App 图标,到首屏第一帧渲染完成、可交互
我们这次目标:
冷启动 P75:3000ms → 800ms
首屏可交互:≤ 800ms
didFinishLaunching:≤ 200ms
Apple 的 App Launch 阶段划分:
process init → UIKit init → initial scene render → first frame
二、第一步:先测,再动代码
没有基线的优化 = 瞎改。
1. 真机 + Release 配置
模拟器不能信,Debug 也不能信。
用老设备 + 低内存 + 冷启动:
# 杀进程
kill -9 <pid>
# 或手动上滑杀掉
# 然后再点图标
2. Instruments:App Launch 模板
Xcode → Product → Profile → App Launch
重点看:
dyld时间- main thread 是否被阻塞
UIApplicationMain → rootViewController.viewDidLoad → first commit- 哪些函数在 Self Time 里排前面
3. pre-main 时间(开发期)
Scheme → Run → Arguments → Environment Variables:
DYLD_PRINT_STATISTICS=1
DYLD_PRINT_STATISTICS_DETAILS=1
控制台会打印:
Total pre-main time: 1.2s
dylib loading: 800ms
rebase/binding: 120ms
ObjC setup: 80ms
initializer: 200ms
4. 业务打点
struct LaunchTrace {
static let t0 = Date()
}
// main.swift 最早位置
// AppDelegate.didFinishLaunching 开始/结束
// 首屏 viewDidAppear
func launchCost(_ label: String) {
let ms = Date().timeIntervalSince(LaunchTrace.t0) * 1000
print("[Launch] \(label): \(String(format: "%.1f", ms))ms")
}
再配合:
- Xcode Organizer → Launch Time
- MetricKit
MXAppLaunchMetric - 自建埋点:P50 / P75 / P95
三、我们项目的真实耗时分布
优化前:
| 阶段 | 耗时 |
|---|---|
| pre-main | 1200ms |
| didFinishLaunching | 900ms |
| 首屏 VC 创建 + 布局 | 600ms |
| 首屏同步网络/DB | 300ms |
| 合计 | ~3000ms |
结论很直接:
main 之后比 main 之前还慢。
四、pre-main 阶段:砍 dyld 和 +load
1. 动态库能少就少
每个第三方动态 framework 都要 dyld 加载、绑定、重定位。
做法:
-
自研 framework → 改静态库
-
CocoaPods 默认动态库 → 改
static_framework -
合并零碎内部 module
-
苹果系统库不用管(有 shared cache)
Podfile
use_frameworks! :linkage => :static
效果:
动态库 42 个 → 9 个
pre-main 1200ms → 700ms
2. 干掉 +load
OC 里最经典的启动杀手:
+ (void)load {
[Router registerURL:@"app://home" handler:...];
[self swizzleSomething];
[Analytics preload];
}
+load 在启动期同步执行,不能懒。
改成:
- 路由注册:首屏前主动调用
RouterSetup() - swizzle:延迟到模块首次使用时
- 配置预载:懒加载 / 首帧后
Swift 里等价雷区:
@objc class Foo: NSObject {
override class func load() {} // 别写
}
以及:
__attribute__((constructor))
static void my_init() { ... }
这些都要进黑名单。
3. 删无用 OC 类 / Category / 方法
Runtime 要注册类、方法、协议、category。
用 AppCode / fui / 自研脚本扫:
- 没被引用的 Class
- 只为了"分类好看"的 UIView+XXX
- 历史协议
- 空 Category
类和方法不是免费资源,启动期 Runtime 会为它们买单。
4. 二进制重排(后期大招)
原理:启动期调用的函数分散在二进制里 → 缺页中断多。
做法:
-
Clang 插桩 / hook
objc_msgSend收集启动调用顺序 -
生成
order_file.txt -
Build Settings →
Order FileAssets/order_file.txt
效果通常是:
page fault 减少
pre-main + 首屏 再降 80~150ms
前提:前面"砍任务"做完了再搞这个,不然本末倒置。
五、main 之后:didFinishLaunching 必须"减肥"
这是 3s 启动里最肥的一块。
优化前(典型烂代码)
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Analytics.setup() // 200ms
CrashReporter.start() // 120ms
Database.migrate() // 300ms
UserManager.loadFromDisk() // 150ms
RemoteConfig.fetchSync() // 250ms
PushManager.register() // 100ms
AdSDK.preload() // 200ms
IMManager.connect() // 180ms
setupRootViewController() // 100ms
return true
}
主线程直接 1.7s 没了。
优化原则:首屏前只做 3 件事
- 建 Window
- 建首屏 VC
- 首屏必需的最小配置(如登录态是否已登录)
其他全部:
- 延后
- 异步
- 懒加载
- 首帧后分批
优化后骨架
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
setupWindowAndRoot() // ≤ 50ms
// 首帧后做"轻量非阻塞"的事
DispatchQueue.main.async {
PushManager.register()
}
// 重活放后台
DispatchQueue.global(qos: .userInitiated).async {
Analytics.setup()
CrashReporter.start()
RemoteConfig.prefetch()
}
// 数据库迁移:别同步做
Database.asyncMigrateIfNeeded()
return true
}
目标:
didFinishLaunching ≤ 200ms
六、首屏 VC:别在 viewDidLoad 里"办年会"
反模式
override func viewDidLoad() {
super.viewDidLoad()
fetchUserInfo() // 网络
fetchFeedList() // 网络
setupIM() // 重活
loadLocalDB() // IO
configABTest() // 同步 JSON
renderComplexUI() // 超深视图树
}
正确首屏模型
先画壳,再补数据。
override func viewDidLoad() {
super.viewDidLoad()
setupSkeletonUI()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadFirstScreenData()
}
首屏组成:
- 占位图 / Skeleton
- 本地缓存数据
- 轻量布局
- 列表空态
网络回来再 diff 更新。
七、SDK 初始化:做"启动优先级表"
我们后来把所有启动任务归档:
| 优先级 | 示例 | 时机 |
|---|---|---|
| P0 | Window、路由、登录态 | main 前/首屏前 |
| P1 | 崩溃、埋点、推送注册 | 首帧后主线程轻量 |
| P2 | 配置中心、数据库迁移 | 后台异步 |
| P3 | 广告、IM、分享、地图 | 用到再初始化 |
| P4 | 运营 SDK、风控、热修 | 首页出来 1s 后 |
struct LaunchTask {
let name: String
let queue: DispatchQueue
let delay: TimeInterval
let work: () -> Void
}
let launchTasks: [LaunchTask] = [
.init(name: "analytics", queue: .global(), delay: 0.0, work: Analytics.setup),
.init(name: "ad", queue: .global(), delay: 1.0, work: AdSDK.setup),
.init(name: "im", queue: .global(), delay: 1.5, work: IMManager.lazySetup)
]
八、Swift 项目额外 4 个隐藏税
1. 全局变量初始化
let appConfig = loadBigJSON() // 启动期直接执行
改成:
lazy var appConfig: AppConfig = loadBigJSON()
2. 过度 @objc
Swift 类一旦大量 @objc:
- 生成 OC 元数据
- Runtime 成本上升
- 启动变慢
能 final class 就别继承 NSObject。
3. 泛型 + 协议反射
启动路径上的:
MirrorCodable超大嵌套- 动态字典转模型
能缓存就缓存,别每次启动解析。
4. SwiftUI 别在根视图里建世界
var body: some Scene {
WindowGroup {
HomeTabBar()
.environmentObject(VM1())
.environmentObject(VM2())
.environmentObject(VM3())
}
}
这些 VM 构造器里别干 IO。
九、资源层:首屏别解大图
常见问题:
- 启动图用 3MB PNG
- 首屏头像/背景图主线程解码
- 字体一堆
- JSON 配置全量加载
做法:
-
启动图走 LaunchScreen / Assets
-
首屏图片 downsample
-
字体只留 1~2 个
-
配置做差分/懒加载
func downsampledImage(at url: URL, maxSize: CGSize) -> UIImage? {
let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil)!
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: max(maxSize.width, maxSize.height),
kCGImageSourceShouldCacheImmediately: true
]
guard let cg = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
else { return nil }
return UIImage(cgImage: cg)
}
十、优化后数据(真实项目口径)
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 冷启动 P75 | 3000ms | 780ms |
| pre-main | 1200ms | 650ms |
| didFinishLaunching | 900ms | 180ms |
| 首屏可交互 | 3000ms | 800ms |
| 首屏数据完整 | 3000ms | 1100ms |
用户感知的是"800ms 能点",不是"1100ms 数据全"。
十一、防劣化:比优化更重要
启动时间一定会反弹,除非你卡 CI。
1. XCTest 启动基准
func testLaunchPerformance() throws {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
2. 启动任务注册表
新增 SDK 必须填表:
名字 / 负责人 / 启动阶段 / 是否主线程 / 是否可懒加载
3. 发版门禁
didFinishLaunching > 250ms红冷启动 P75 > 1000ms红- 新动态库合并进主工程要 Review
4. 线上监控
- MetricKit
- 自研 launch trace
- 按机型/系统/iOS 版本分桶
十二、一张图记住启动优化心法
点图标
↓
pre-main:少动态库 / 少 +load / 少类 / 二进制重排
↓
main:别在 didFinishLaunching 里办年会
↓
首屏:先画壳,再补数据
↓
非核心 SDK:懒、延、异步、分批
↓
800ms 不是调出来的,是"少做"做出来的
十三、最后一句大实话
冷启动从 3s 到 800ms,
不是你写了某个黑科技,
而是你终于敢把"不该在启动时做的事"挪出去。