App 启动速度优化:冷启动从 3s 降到 800ms 完整实操

App 启动速度优化:冷启动从 3s 降到 800ms 完整实操

启动优化不是"玄学调参",而是一条时间轴工程:

点图标 → 进程创建 → dyld → Runtime → main → didFinishLaunching → 首屏布局 → 第一帧可交互

3s 的 App,通常不是某一行代码慢,而是"每个阶段都在偷偷加税"。

下面按可落地、可复盘、可防劣化的方式讲。


一、先定义"冷启动时间",不然优化会吵起来

不同团队口径不一样:

  • ❌ 从 maindidFinishLaunching 结束(太窄)
  • ❌ 从点图标到首页网络数据回来(太宽)
  • 从用户点击 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. 二进制重排(后期大招)

原理:启动期调用的函数分散在二进制里 → 缺页中断多。

做法:

  1. Clang 插桩 / hook objc_msgSend 收集启动调用顺序

  2. 生成 order_file.txt

  3. Build Settings → Order File

    Assets/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 件事

  1. 建 Window
  2. 建首屏 VC
  3. 首屏必需的最小配置(如登录态是否已登录)

其他全部:

  • 延后
  • 异步
  • 懒加载
  • 首帧后分批

优化后骨架

复制代码
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. 泛型 + 协议反射

启动路径上的:

  • Mirror
  • Codable 超大嵌套
  • 动态字典转模型

能缓存就缓存,别每次启动解析。

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,

不是你写了某个黑科技,

而是你终于敢把"不该在启动时做的事"挪出去。

相关推荐
Lazionr1 小时前
多态:从多种形态到运行时绑定
开发语言·c++
大黄说说2 小时前
iOS 列表滑动卡顿:UITableView / UICollectionView 深度优化实战
开发语言
于樱花森上飞舞2 小时前
【Redis】哨兵详解
java·开发语言·数据库·redis
Lazionr2 小时前
二叉搜索树:从树形结构到高效查找
开发语言·c++
此生决int2 小时前
深入理解C++系列(21)——异常
开发语言·c++
乌暮2 小时前
Java 抽象类与接口详解:半成品图纸 vs 能力合同
java·开发语言·后端·学习
乐观的Terry2 小时前
ShipDesk 完整使用教程:从新建项目到 SSH 自动发布
开发语言
边境悍匪3 小时前
蜗牛学苑 Java 智能体学习 Day46|贯穿项目 2 思维导图复盘
java·开发语言·vue.js·学习·spring
晴天的雨.9926 小时前
【C++算法】和为s的两个数
开发语言·数据结构·c++·算法