iOS 启动优化实战:冷启动从 2.83s 压到 1.12s,我把时间花在了哪

🔑 关键词:iOS启动优化,冷启动,MetricKit,二进制重排,dyld

📖 摘要:一个日活两百万的电商 App 冷启动优化全过程拆解。包含 MetricKit、Xcode Organizer、Instruments App Launch 模板的具体用法,以及 pre-main、main 之后、首屏渲染、二进制重排四个阶段各自的真实收益和踩过的坑。

先说个结论:大部分 iOS 团队做启动优化,都在优化一个只占 15% 的部分,然后把 85% 留在原地不动。

图片

前年冬天我接手一个电商 App 的启动优化,日活两百多万,PM 给的 KPI 是冷启动 P90 压到 2 秒以内。线上当时的数据是 2.83s(iPhone 12,iOS 16.4,取 MetricKit 上报的 histogrammedTimeToFirstDraw 的 P90)。六周之后做到 1.12s,达标。

但真正让我记住这个项目的不是结尾那个数字,是我翻 git log 的时候发现的:团队在过去一年做过两轮「启动优化专项」,三十多个 commit,几乎全部集中在 pre-main——把 +load 迁到 +initialize,把 12 个动态库合并成 5 个,删了一批没人用的 Category。这些 commit 加起来省了大约 110ms。而首屏那段又臭又长的同步代码,两年没人碰。

所以这篇不讲「启动优化十大技巧」,只讲时间到底花在哪,哪些地方值得你花两周,哪些地方不值得。

第一步永远是测,不是改

我一般分三层测,缺一层都不踏实。

线上层用 MetricKit。MXMetricManager.shared.add(self) 之后实现 MXMetricManagerSubscriberdidReceive(_ payloads: [MXMetricPayload]),从 payload.applicationLaunchMetrics?.histogrammedTimeToFirstDraw 里拿用户真实的首屏耗时分布。这段大概长这样:

final class LaunchReporter: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for p in payloads {
            guard let m = p.applicationLaunchMetrics else { continue }
            // histogrammedTimeToFirstDraw 的类型是 MXHistogram<UnitDuration>
            print(m.histogrammedTimeToFirstDraw)
        }
    }
}

图片

缺点是慢。系统按天聚合,一般要 24 小时之后才回传,模拟器上还拿不到数据。

中间层是 Xcode Organizer 的 Launch Time 面板,看 P50/P90 的版本趋势,判断你这次改动到底生效没有。

最下面是 Instruments 的 App Launch 模板。Xcode 14 之后这个模板会给你 Dynamic LinkerStatic Initializer 两条 track,直接告诉你每个静态构造函数花了多久,谁在 +load 里干活一眼就看出来了。

不过这里有个我踩过的坑:Instruments 单次测出来的数字,往往比线上 P90 好 30% 以上。原因不神秘,你的测试机没装两百个 App,没开低电量模式,后台没有同步在跑。我现在的习惯是同一个包在同一台真机上连续冷启动 10 次取中位数,比一次精测靠谱得多。低电量模式一定单独测一遍,我见过实测直接翻倍的情况。

pre-main 现在真的没那么多肉了

dyld3(iOS 13 起)引入 launch closure,把符号查找、重定位这些活提前到安装后的首次启动做完,缓存成 closure 文件;iOS 15 的 dyld4 沿用了这个思路。结果就是后续启动里 dyld 干的活少了一大截。

所以你在 iPhone 13 这类机器上测,pre-main 正常应该落在 150–300ms。到 500ms 以上确实有问题值得查;但如果本来就只有 300ms,你花两周把它压到 250ms,换算下来是 2.83s 里的不到 2%。而这 50ms 的代价可能是动态库合并带来的模块耦合、构建时间变长、某个 SDK 的版本冲突。

图片

我看过的真需要治的 pre-main 问题基本就三类。一是静态构造函数里干重活,某 SDK 在 +load 里建数据库连接、解密配置、读文件。二是动态库太多,尤其第三方 SDK 各自带一堆 .framework,每个动态库的加载都要走一遍绑定。三是 Category 里塞 +load,而且不止一个。

这三类解决起来都不难,但总盘子就这么大,别指望在这儿挖出 1 秒。

肉在 main() 之后

main() 返回到第一帧上屏,这段时间在绝大多数项目里都是最肥的。我们那次 1.71s 的总收益里,有 1.17s 是从这一段抠出来的。

常见的大户有几个。

UserDefaults 的首次读取。它本质是个 plist,第一次读要解析整个文件。我们那个项目在 didFinishLaunchingWithOptions 里读了 40 多个 key,其中只有 6 个是首屏真正需要的。改成懒加载、再合并成一个结构体整体存取之后,这一段省了大概 180ms。

Keychain。SecItemCopyMatching 在部分机型上单次调用就要 20–50ms,调用本身会阻塞。启动时查登录态、查 token、查设备指纹,三次就是 100ms 起步。能缓存的尽量缓存到内存或者 UserDefaults 里。

图片

第三方 SDK 的 setup。我见过一个统计 SDK,init 里同步发三个网络请求——不是异步发,是用信号量把 URLSession 包成了同步调用。这种东西接在启动路径上就是灾难,但很多 SDK 的文档不会告诉你。

CoreData / FMDB / WCDB 的 migration。数据量大的时候主线程跑就是几百毫秒,而且它是「某天突然变慢」的,因为用户本地数据攒到一定量级才暴露出来。

我们的做法是建一个 AppLaunchTaskQueue,分三档:immediately(必须在首帧之前,几乎没有)、afterFirstFrame(第一帧上屏后立刻执行)、idleapplicationDidBecomeActive 之后或者 RunLoop 空闲时)。首屏只保留两件事:登录态读取、A/B 实验配置。其他全部后置。

首屏渲染那 400ms

这段容易被忽略,因为它不叫「启动」叫「渲染」,但用户感知上是一回事。

我们做过一个实验:把首页的 List 换成 LazyVStack + ScrollView。原因是 List 在 iOS 16 上会给每个 cell 算一遍高度,而我们的 cell 高度其实是固定的。换完首屏构建从 340ms 降到 190ms。代价是丢掉了 List 的滑动回收优化,但首屏只有 8 个 cell,不亏。

图片这块,UIImage(named:) 是懒解码的,真正解码发生在渲染时。iOS 15 之后可以用 UIImage.preparingForDisplay() 在后台提前解码,比自己去 UIGraphicsImageRenderer 里画一遍干净。首屏图片别超过 3 张,并且要有占位,不然会看到明显的白块跳变。

图片

还有一个反直觉的:launch screen 别删。iOS 会把 launch screen 的展示和首帧衔接起来,删掉之后用户看到的白屏时间反而更长。我们期间有人提过「去掉 launch screen 让启动感觉更快」,被否了,后来看线上数据也确实没变化。

二进制重排还值不值得做

值,但别把它当银弹。

原理不复杂:函数是按需调入内存的,每次缺页中断都有开销。把启动路径上真正用到的函数在二进制里排到一起,减少缺页次数,启动就快了。做法是用 Clang 的 -fsanitize-coverage=func,trace-pc-guard 采集运行时的函数调用顺序,生成 .order 文件,再通过 -Wl,-order_file,$(SRCROOT)/app.order 传给链接器。这套流程最早是字节的同学写文章讲开的,搜「抖音 二进制重排」能找到原文。

两个坑。第一,Swift 有 name mangling,order 文件里的符号名和你代码里写的函数名对不上,得用 nm 导出真实符号再处理。第二,如果你开了 -Os 加全模块优化,编译器可能内联掉一部分函数,把 order 文件里对应的符号弄没,链接直接报错。

我们那次做完,System Trace 里看到的缺页次数从 1200 多降到 700 左右,首帧省了大概 200ms。但如果你的 pre-main 本来就只有 200ms,别折腾这个,收益不会有明显感知。

几件我最后没做的事

图片

没把启动路径全改异步。有同事提议把所有 SDK init 都扔进 dispatch_async,试了一版,闪退率涨了 0.3%,因为有几个页面进得太快,SDK 还没初始化完就去取数据了。启动优化不能拿稳定性换时间。

没上 MMKV。不是它不好,是我们评估完发现 UserDefaults 慢是因为读得太多,不是因为读写本身慢。换存储引擎治不了这个病。

没砍 launch screen。

收益拆解和一句实话

最后的总账大概是这样:pre-main 从 2.83s 到 2.72s,省 110ms;main() 到首帧从 2.72s 到 1.55s,省 1170ms;首屏渲染从 1.55s 到 1.12s,省 430ms。

启动优化最大的成本从来不是技术,是推动团队接受「这些东西不该在启动时做」。技术上能做的翻来覆去就那几样,难的是让业务方同意把某个功能推迟 200ms 再初始化。

如果你手上有个冷启动 2s 左右的 App,我的建议是先花两天只做测量,把所有「主线程上超过 50ms 的同步任务」列成一张表。做完这份表你大概率会有 15 条以上的记录。这张表本身就比任何优化技巧值钱。

🏷️ 标签: