一、事情的开头有点俗套
2023 年 11 月,我一个前同事跳去了一家做家装电商的公司,有天晚上十一点给我发消息,说老板要求「冷启动必须进 1 秒」,他们 App 现在 iPhone 13 上冷启动 1.7s 左右。我问他手上有什么牌,他发过来一份优化排期表,第一行写着「二进制重排 - 预计收益 200ms」。
我当时就笑了。不是笑他不专业,是笑这份排期表我看过太多遍。字节那边讲二进制重排的文章在 iOS 圈子里被转发了得有上万次,很多团队把它当成启动优化的第一优先级,甚至老板拍 KPI 的时候都直接写「做二进制重排」。我自己在三个 App 上从头到尾把这件事做了一遍之后,很想泼一盆冷水。
顺便说一句,他那个「1 秒」的目标本身也有问题。冷启动到底算什么,是从点击图标算,还是从进程 fork 算?要不要包含首屏渲染?这些口径不统一,后面验收的时候一定扯皮。我让他先去把口径定死,这比写代码重要。
二、先把 pre-main 这 400ms 到底花在哪说清楚
你要优化 pre-main,得先知道这段时间是怎么没的。我通常的做法是先用 Xcode 的 App Launch 模板(Product > Profile > App Launch)跑一遍真机,它会给你一个 Pre-Main 的耗时,然后再在 main.m 的第一行打一个时间戳做交叉验证。别信模拟器上的数字,跟我实测的真机差异能到 40%。
一个典型的、挂了 20~30 个 Pod 的中型 App,pre-main 的构成大概是这样:
| 阶段 | 典型耗时 | 说明 |
|---|---|---|
| dyld 加载动态库 | 80~150ms | 每个 embedded framework 都要 mmap + fixup |
| Rebase / Binding | 30~60ms | 指针修正,和二进制体积正相关 |
| ObjC 类注册 + Category 绑定 | 20~40ms | 类越多越慢 |
+load 和静态构造函数 |
20~200ms | 差距最大的地方,我见过 138 个 +load 的 App |
| 各种 SDK 初始化 | 不可控 | 第三方 SDK 是黑盒 |
看这张表你就明白了:+load 和动态库数量才是大头。二进制重排优化的是「哪一段代码先被加载进内存」,它动不了上面这些结构性的东西。我第一次真做这块是 2021 年,在一个金融类 App 上,那个 App 有 41 个 embedded framework,光 dyld 加载就吃掉 137ms。
三、二进制重排的原理和实操(这部分你可以直接抄)
原理其实一句话:iOS arm64 设备的物理内存页是 16KB,代码段按页从磁盘加载。启动路径上访问到的函数如果分散在几十个不同的页里,就要触发几十次 page fault,每次都是微秒级的开销加上磁盘 IO。重排做的事,就是把启动路径上的函数在 Mach-O 里排到一起,让它们尽量落在相邻的、甚至同一个页里。
实操步骤,我用的是基于 clang SanitizerCoverage 的采集方案:
- 在 Debug 配置里给
Other C Flags加上-fsanitize-coverage=func,trace-pc-guard。 - 自己写一个 .m/.c 文件,实现
__sanitizer_cov_trace_pc_guard和__sanitizer_cov_trace_pc_guard_init两个回调,在回调里用__builtin_return_address(0)拿地址,塞进一个无锁队列。不实现这两个函数链接会直接报错。 - 启动到首屏渲染完成之后,把队列里的地址用
dladdr反解成符号名,去重,输出成 order file。 - 在 Release 配置的
Other Linker Flags里加-Wl,-order_file,$(SRCROOT)/xxx.order。
第 3 步有个坑我必须提醒:dladdr 在 strip 过的 release 二进制上反解会失败,所以顺序采集最好在带符号的构建上做,或者你直接把地址 delta 记下来用 atos 离线解析。我在这上面浪费过一整个下午。
还有个坑是签名和布局变化。-Wl,-order_file 会改变最终二进制的文件布局,如果你接了某些做加固、或者自己实现了 Mach-O 完整性校验的 SDK,一定要回归测一遍。我一个朋友的公司就是因为重排后跟加固服务冲突,上线前夜回滚了。
四、实测数据:收益有,但没你想的那么多
我在三台设备上跑过一组对比,同一个 App,Release 构建,冷启动前统一重启手机、清后台、开飞行模式:
| 设备 | 重排前 pre-main | 重排后 pre-main | 冷启动总时长变化 |
|---|---|---|---|
| iPhone SE 2 (A13) | 412ms | 296ms | -78ms |
| iPhone 11 (A13) | 388ms | 281ms | -71ms |
| iPhone 13 (A15) | 265ms | 204ms | -43ms |
看出来了吧,pre-main 的收益挺漂亮,砍了 25%~30%,但落到用户真正感知的「冷启动总时长」上,只有 40~80ms。因为实测里真正的大头在 main 之后——首屏数据请求、布局、图片解码。
这就引出我最想说的观点:如果你的首屏是网络驱动的,那 80ms 的启动优化很可能被网络抖动的方差直接吃掉。同一台 iPhone 13,同一个 App,我连着测了 30 次冷启动,标准差大概 130ms。你辛苦省下来的 80ms,还不如把首屏接口的 RTT 从 210ms 压到 150ms 来得实在。
还有一个更扎心的事实:重排效果会随时间衰减。开发迭代三个月,启动路径上的代码变了 20%,那个 order file 就基本失效了。要么上 CI 定期重新采集(我们那套 Fastlane + 真机的自动化方案跑一次大概 12 分钟),要么就当自己没做过。
五、那到底该先做什么
按性价比排序,我的建议是这样的:
- 清
+load。用nm -m或者写个脚本扫一遍所有二进制,把+load换成+initialize,或者改成显式初始化。我见过一个 App,光某一家埋点 SDK 就贡献了 60ms 的+load时间。 - 砍 embedded framework。合并动态库,或者把纯 Swift 的库改成静态链接(
use_frameworks!换成use_modular_headers!)。这一项经常能省 50~100ms,比重排还划算,而且是一次性的。 - 把第三方 SDK 初始化延后。除了崩溃采集这种必须早启动的,其余埋点、ABTest、推送注册全部丢到首屏渲染之后。这个改动的收益经常是几百毫秒级别的。
- 以上都做完了,再考虑二进制重排。
我知道有人会反驳说这些我们早做完了。那恭喜,你的 pre-main 大概率已经在 200ms 以内了,重排的边际收益会更低——因为页本来就少了,能省下来的 page fault 本来就不多。
六、最后说两句
我不反对做二进制重排,我自己项目里也留着这套流水线。我只是反对把它当成启动优化的第一优先级,这个观念在中文 iOS 圈子里太流行,流行到有点迷信的程度。
真想在启动上拿到收益,第一步永远是先量。打开 App Launch 模板,或者接上 MetricKit 的 MXAppLaunchMetric 看真实用户的 histogrammedTimeToFirstDraw 分布。别凭感觉优化,你的感觉大概率是错的。
哦对,我那个前同事最后没做重排。他们先把某个第三方 SDK 的初始化挪到了首屏之后,冷启动直接掉了 340ms,比重排整条流水线还猛。