iOS 启动优化:二进制重排把 pre-main 从 412ms 压到 296ms,但我劝你先别急着做

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

📖 摘要:一份基于三个真实 App 的启动优化记录:拆解 pre-main 耗时构成、二进制重排的完整实操步骤与踩坑点,并给出为何它不该是启动优化第一优先级的独立观点。

一、事情的开头有点俗套

图片

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 的采集方案:

  1. 在 Debug 配置里给 Other C Flags 加上 -fsanitize-coverage=func,trace-pc-guard。
  2. 自己写一个 .m/.c 文件,实现 __sanitizer_cov_trace_pc_guard 和 __sanitizer_cov_trace_pc_guard_init 两个回调,在回调里用 __builtin_return_address(0) 拿地址,塞进一个无锁队列。不实现这两个函数链接会直接报错。
  3. 启动到首屏渲染完成之后,把队列里的地址用 dladdr 反解成符号名,去重,输出成 order file。
  4. 在 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 分钟),要么就当自己没做过。

五、那到底该先做什么

按性价比排序,我的建议是这样的:

  1. 清 +load。用 nm -m 或者写个脚本扫一遍所有二进制,把 +load 换成 +initialize,或者改成显式初始化。我见过一个 App,光某一家埋点 SDK 就贡献了 60ms 的 +load 时间。
  2. 砍 embedded framework。合并动态库,或者把纯 Swift 的库改成静态链接(use_frameworks! 换成 use_modular_headers!)。这一项经常能省 50~100ms,比重排还划算,而且是一次性的。
  3. 把第三方 SDK 初始化延后。除了崩溃采集这种必须早启动的,其余埋点、ABTest、推送注册全部丢到首屏渲染之后。这个改动的收益经常是几百毫秒级别的。
  4. 以上都做完了,再考虑二进制重排。

图片

我知道有人会反驳说这些我们早做完了。那恭喜,你的 pre-main 大概率已经在 200ms 以内了,重排的边际收益会更低——因为页本来就少了,能省下来的 page fault 本来就不多。

六、最后说两句

我不反对做二进制重排,我自己项目里也留着这套流水线。我只是反对把它当成启动优化的第一优先级,这个观念在中文 iOS 圈子里太流行,流行到有点迷信的程度。

真想在启动上拿到收益,第一步永远是先量。打开 App Launch 模板,或者接上 MetricKit 的 MXAppLaunchMetric 看真实用户的 histogrammedTimeToFirstDraw 分布。别凭感觉优化,你的感觉大概率是错的。

哦对,我那个前同事最后没做重排。他们先把某个第三方 SDK 的初始化挪到了首屏之后,冷启动直接掉了 340ms,比重排整条流水线还猛。

🏷️ 标签: