App冷启动优化
# App 启动优化
📊 学习成本预估 | 难度:⭐⭐⭐⭐⭐(5/5)| 阅读:约 55 分钟 | 实操:4 小时 🔗 前置阅读:卷二·01, 04, 06, 卷三·01 | ➡️ 后续延伸:卷五·02
# 目录介绍
- 01.阅读说明
- 02.贯穿案例
- 03.启动本质定义
- 04.关键路径原理
- 05.度量与采集
- 06.归因决策树
- 07.Android 全链路 ⭐
- 08.iOS 全链路 ⭐
- 09.Web 全链路 ⭐
- 10.跨端框架全链路 ⭐
- 11.嵌入式全链路 ⭐
- 12.跨端启动对照
- 13.治理一层路径 ⭐
- 14.治理二层调度 ⭐
- 15.治理三层资源 ⭐
- 16.治理四层架构 ⭐
- 17.求证实验 ⭐
- 18.实战案例
- 19.防劣化体系
- 20.跨平台速查
- 21.总结与延伸
# 01.阅读说明
- 本文卷归属:卷四 · 业务专项 · 第 1 篇
- 本文目标层级:L2 进阶 → L3 专家 → L4 架构
- 适用平台:Android(主) / iOS / Web / 跨端 / 嵌入式
- 前置阅读:
卷零·01 性能工程总论与第一性原理卷零·03 性能求证实验方法论卷三·03 卡顿捕获与归因(启动期同样有卡顿问题)
- 本文核心命题:
启动是关键路径上的最优化问题——总启动时长 = 关键路径上所有串行任务的耗时之和。 一切优化都是要么缩短关键路径上的某个任务,要么把它从关键路径上移走(异步 / 延迟)。 不在关键路径上的任务,再慢也不影响启动时长——这是 80% 启动优化"动了很多东西却没什么收益"的根源。
# 02.贯穿案例
本案例贯穿全文:§03 看懂物理本质、§04 用关键路径模型识别瓶颈、§07-§11 拆解各端原理、§17 用实验复盘、§13-§16 给出分层闭环。
# 2.1 案例背景
某头部金融 App,主功能为股票行情 + 交易,用户多在开盘瞬间打开。启动慢直接影响开盘交易额:
- 冷启动 P50 4.2 秒、P95 6.8 秒——用户反馈:"开盘前 30 秒打开 App,看到行情已经过了"。
- 业务损失:开盘 30 秒内的交易额比同行少 35%。
- 应用商店投诉:"启动慢"在投诉前三。
- 与竞品对比:友商 P50 1.5 秒、P95 2.8 秒——启动慢 2.5-3 倍。
研发组初步反应:"SDK 太多没办法、用户网络慢导致的。"——这是典型的"外因背锅"。
# 2.2 经验派 6 周折腾(典型反面教材)
| 周次 | 假设 | 措施 | 结果 |
|---|---|---|---|
| 第 1-2 周 | Application 太重 | 把 28 个 SDK 全部异步初始化 | 4.2s → 3.9s(-7%) |
| 第 3 周 | 主页太复杂 | 主页拆 4 块按需加载 | 4.2s → 3.7s |
| 第 4 周 | 启动图太大 | 压缩闪屏 50% | 无变化(图本来就异步加载) |
| 第 5 周 | 网络太慢 | 启动期改 HTTP/2 | -100ms |
| 第 6 周 | 全异步化 | 几乎所有初始化丢线程池 | 反而 +250ms(异步切换开销) |
6 周累积投入,启动只从 4.2s 降到 3.7s。最后一次"全异步化"反向劣化让团队彻底困惑。
复盘:六周折腾错在"经验主义"——凭直觉找耗电源、不区分关键/非关键、相信"异步就是快"。真正的根因是"非关键任务挤进关键路径 + 数据请求串行链",但没人用关键路径模型量化分析过。
# 2.3 方法派 5 天闭环
Day 1(§04 关键路径模型 + §05 度量):
- 用 Tracing 拉出 Application 阶段任务图。
- 关键路径只有 710ms(attachBaseContext + 多语言切换 + 安全验证 + 行情连接 + ContentProvider)。
- 但被 980ms 不必要任务挤到 1850ms(28 个 SDK 都跑主线程)。
→ 找到根因 A:非关键任务挤进主线程。
Day 2(§06 决策树):
- 首屏数据稳态 1100ms。
- 行情连接初始化在主线程?Yes。
- 行情数据请求在 onResume 后?Yes(串行链)。
→ 找到根因 B:数据请求串行。
Day 3-4(§13-§16 四层治理):
- 第 1 层(路径):23 个非关键 SDK 推到 IdleHandler。
- 第 2 层(调度):ContentProvider 整合 + 主进程区分。
- 第 3 层(资源):类预加载 + Baseline Profiles。
- 第 4 层(架构):Application 阶段就预热行情长连 + 触发数据请求,让网络往返与 UI 初始化并行。
Day 5(A/B 灰度)。
# 2.4 上线效果
| 指标 | 经验派 6 周后 | 方法派 5 天后 | 友商 |
|---|---|---|---|
| 启动 P50(中端机) | 3700ms | 1420ms | 1500ms |
| 启动 P95(中端机) | 6200ms | 2150ms | 2800ms |
| Application 阶段时长 | 1850ms | 380ms | - |
| 首屏数据稳态 | 4200ms | 1400ms | - |
| 主进程线程数 | 28 | 12 | - |
| 开盘 30s 交易额(对比同行) | -35% | +7% | 基准 |
| 应用商店"启动慢"差评 | 持续 | 降至前 20 外 | - |
核心洞察:启动优化 80% 收益来自找对关键路径,不是"做更多"。经验派错在没分清主次——一开始就该用 Tracing 拉关键路径。启动优化 = 关键路径瘦身 + 非关键任务移出,不是堆并发。
# 2.5 案例如何串起本文
- §03 启动本质 ▶▶ 启动是任务集 + 依赖图 + 串行预算。
- §04 关键路径原理 ▶▶ Amdahl 定律证明"全异步化"有硬上限。
- §06 决策树 ▶▶ 沿"主线程任务过载 + 数据串行"分支精准命中。
- §07-§11 各端原理 ▶▶ Android ContentProvider 串行 / iOS dyld / Web TTI 各自有"地形特点"。
- §17 求证实验 ▶▶ §17.1 阿姆达尔上限直接破除"无限并发"幻觉。
- §13-§16 四层治理 ▶▶ "路径→调度→资源→架构"四层正是案例落地路径。
# 03.启动本质定义
# 3.1 启动的物理本质
启动 = 把"应用从无到有"过程中的所有必要工作,按依赖关系串行 / 并行地执行,直到用户能看到并交互。
三个不可商量的物理约束:
约束一:启动是有"必要工作集"的
启动不是单一任务,而是一个任务集 T = {t₁, t₂, ..., tₙ},每个任务做一件事(如加载类、初始化 SDK、读配置、建立网络连接、渲染首帧)。任务集分两类:
- 必要任务(critical):直接产出"用户可见可交互"的状态。
- 可选任务(deferrable):当下不需要,但传统实现中放在启动期(如打点埋点、广告拉取、后台同步)。
启动优化的第一原则:严格区分必要与可选,把可选的全部移出启动期。
约束二:任务之间存在依赖图
任务集不是并行池,而是有向无环图(DAG):
t1 (读本地配置) ──┐
├──▶ t4 (初始化 SDK)
t2 (初始化日志) ──┘
├──▶ t5 (建立网络) ──▶ t7 (拉取首屏数据) ──▶ t8 (渲染首屏)
t3 (准备主线程) ──┘
└──▶ t6 (初始化 UI 框架) ──┘
2
3
4
5
6
依赖图决定了"哪些任务必须串行""哪些可以并行"。
约束三:启动有"开始"和"结束"两个明确边界
- 开始:用户点击图标 / 系统拉起应用。
- 结束:用户能看到首屏并能交互(TTFD)。
这两个边界是物理可观测的(屏幕变化 + 输入响应),所以启动是个"有头有尾的 epoch"问题,可以精确度量。
探索性思考:为什么启动慢比"卡顿慢"更难治? 卡顿是"运行中某一帧超预算"——可以孤立分析。启动是"几百个任务 + 复杂依赖"——优化任何单点都不一定让整体快。启动优化的核心难点不是"算法",而是"系统观"——理解任务之间的相互作用、关键路径的演变、并发的边际收益。这就是为什么 §02 案例 经验派"动了很多东西"却没用——他们一直在调点(动作),没在调系统(路径)。
# 3.2 启动的现象与代价
启动慢是用户对一款应用的"第一印象",且印象一旦形成极难翻转:
- 首次冷启动慢:用户感知"是不是没装好?"
- 每次启动黑屏 / 白屏:用户怀疑应用故障。
- 可见但点不动(TTID 已到但 TTFD 未到):用户操作无响应,主观体验比慢更差。
- 启动后不稳定(卡顿、ANR、闪退):往往与启动期的"过度异步并发"或"线程争用"相关。
业务代价(行业实测数据):
- Google Play Console 数据:冷启动 P50 > 5s 的应用,次日留存比 < 2s 的低 15–20%。
- 头部 App 数据:每减少 1 秒冷启动时长,次日留存 +1–3%、首屏转化率 +2–5%。
- iOS 系统在冷启动 > 20s 时直接 watchdog 杀死进程,是硬约束。
- Web:Google Web Vitals 把 LCP > 4s 标记为 Poor,影响 SEO。
- 嵌入式车机:启动 > 30s 在某些国家会触发安规警告。
# 3.3 度量准则
按 卷零·02 §3 USE/RED/APDEX 的统一指标体系,启动属于"一次性请求"类,主要用 RED + APDEX:
请求视角(RED):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| TTID(Time To Initial Display) | 首帧可见时间 | 冷启 < 2s / 热启 < 0.5s |
| TTFD(Time To Full Display) | 首屏完整可交互 | 冷启 < 3s / 热启 < 1s |
| 启动失败率(Errors) | 启动崩溃 / 超时占比 | < 0.1% |
| 启动期卡顿率 | 启动期间帧时长 > 16.67ms 的比例 | < 5% |
用户感知(APDEX):
- Satisfied:TTFD < T_target(如 1.5s)
- Tolerating:T_target ≤ TTFD < 4 × T_target
- Frustrated:TTFD ≥ 4 × T_target
关键约定:
- TTID ≠ TTFD:用户看到首帧但内容未加载完,主观仍是"卡"。优化必须并重,TTFD 才是用户感知的"启动完成"。
- 必须使用 P50 + P99 双轨度量。P99 代表"长尾用户体验",往往揭示低端机 / 弱网下的真实痛点。
# 3.4 行业基准与目标
| 平台 | TTID 良好 | TTID 合格 | TTFD 良好 | TTFD 合格 |
|---|---|---|---|---|
| Android(中端机) | < 1.5s | < 2.5s | < 2.5s | < 4s |
| iOS | < 1.0s | < 2.0s | < 2.0s | < 3s |
| Web(4G) | LCP < 2.5s | LCP < 4s | TTI < 3.8s | TTI < 7.3s |
| 嵌入式 HMI | < 5s | < 10s | < 8s | < 15s |
# 3.5 8 个反直觉问题
带着这些问题阅读:
- 减少启动任务数一定能让启动更快吗?
- 把所有任务都改异步,启动是否就能快到极致?
- CPU 利用率不饱和但启动慢,是哪里的问题?
- 多线程并发启动,开多少线程最快?
- MultiDex 一定会让启动变慢吗?慢多少?
- SharedPreferences / NSUserDefaults 在启动期为什么这么慢?
- 异步化任务之后总耗时是不是必然减少?
- 8 核 CPU 启动时为什么有时只用了 2 核?
▶▶ 回扣 §02 案例:原团队用单一数字"4.2 秒"描述启动——这掩盖了真相。重定义为分阶段:
阶段 时间 状态 attachBaseContext → Application.onCreate 完成 0 → 1850ms ❌ 超出预算 4× Application.onCreate → MainActivity.onCreate 1850 → 2400ms ⚠️ 略慢 MainActivity.onCreate → 首帧 2400 → 3100ms ⚠️ 略慢 首帧 → 首屏数据稳态(行情数据可见) 3100 → 4200ms ❌ 超出预算 2× 单一"4.2s"无法定位优化方向;分阶段后立刻看出 Application 阶段(44%)+ 首屏数据阶段(26%)是双瓶颈。
# 04.关键路径原理
本节回答四个根本问题:①为什么 DAG 总耗时 = 最长路径?②Amdahl 定律的启动版本是什么?③异步化的硬上限怎么算?④为什么 race-to-idle 在启动也成立?
# 4.1 关键路径模型
DAG 的总耗时不是所有任务耗时之和,而是最长路径的耗时(关键路径,Critical Path)。这是 PERT/CPM 项目管理理论的核心,同样适用于启动:
t1 (10ms) ──┐
├─▶ t4 (50ms) ──▶ t8 (100ms) 关键路径:t2 → t4 → t8 = 170ms
t2 (20ms) ──┘
├─▶ t5 (30ms) ──▶ t7 (40ms)
t3 (5ms) ──┘ 非关键路径:t3 → t5 → t7 = 75ms 实际并行
2
3
4
5
关键路径的四条法则(启动优化的"金科玉律"):
- 法则一:总启动时长 = 关键路径耗时(不是任务总和)。
- 法则二:缩短非关键路径上的任务对总耗时无收益(除非它升级成新关键路径)。
- 法则三:缩短关键路径任务,最多缩短到"次关键路径"长度(之后次关键路径成为新瓶颈)。
- 法则四:把任务从关键路径移除(异步 / 延迟)的收益与缩短它一样有效,但成本通常更低。
这就是为什么"看似减少了 20 个启动任务,启动时长只少了 100ms"—— 因为只有关键路径上的任务才贡献。
探索性思考:为什么"次关键路径"是个隐藏陷阱? 当你把 A 关键路径优化掉之后,B 路径自动升级成新关键路径——这意味着收益是"阶梯式"而不是线性。比如优化前关键路径 1850ms、次关键路径 1100ms,把关键路径砍到 600ms 后实际总耗时不是 600ms 而是 1100ms(次关键路径接管)。所以"优化前必须看次关键路径多长"——它定义了你的天花板。
# 4.2 Amdahl 定律的启动版本
设启动可异步化的部分占比为 P(0 ≤ P ≤ 1),无法异步化的串行部分占比为 1 − P,并行加速比为 N,则:
启动加速比 = 1 / [(1 − P) + P/N]
即使 P = 0.8,N = ∞:加速比上限 = 1 / 0.2 = 5×
2
3
这就是反直觉问题 ② 的答案:即使所有可异步化任务都开无限线程并行,启动加速也存在硬上限。要突破这个上限,必须减少串行部分(缩短或删除关键路径任务),而不是单纯堆并发。
| P | S_max | 含义 |
|---|---|---|
| 0.5 | 2× | 即使无限并行最多快一倍 |
| 0.7 | 3.3× | |
| 0.8 | 5× | |
| 0.9 | 10× | |
| 0.95 | 20× |
关键洞察:要让启动加速 10×,可异步化部分必须 ≥ 90%。实际启动中能异步化的占比通常 50–70%,所以加速上限通常 2–3×。
探索性思考:为什么 P 在工程中很少超过 0.8? 因为"必须串行"的部分往往不是"代码不会写",而是业务依赖图本身决定的——网络要先于数据请求、UI 框架要先于 Activity、安全 SDK 要先于其他网络。这些是"语义依赖",不是"实现依赖"。P 的天花板由系统架构决定,不是工程能力。要把 P 提到 0.9,必须重构架构(如懒加载、按需初始化、移除前置依赖),不是堆线程。
# 4.3 关键路径上的三类典型问题
把"关键路径阻塞"映射到具体根因,可以分成三类:
关键路径耗时 = 关键任务自身耗时 + 任务间等待 + 资源竞争
┌──────────────────────────────────────┐
│ A. 任务自身慢 │
│ 特征:单个任务 CPU 高 / IO 长 │
│ 根因:算法差、磁盘 IO、网络等 │
├──────────────────────────────────────┤
│ B. 任务依赖链长 │
│ 特征:单任务都不慢,但串成长链路 │
│ 根因:依赖图设计不合理 │
├──────────────────────────────────────┤
│ C. 资源竞争 / 调度等待 │
│ 特征:CPU 不饱和但任务慢 │
│ 根因:锁、Binder、磁盘竞争、线程不够 │
└──────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
探索性思考:为什么 C 类问题最难调? 因为 CPU 占用看起来"很正常"——总占用 30%、单核也不饱和,但启动就是慢。根因是"等待"——线程在等锁、等 Binder、等磁盘。这种问题在 Trace 上呈现"白条"(off-CPU 状态)。所以启动调优必须看"实际跑了多少 vs 应该跑多少"——Trace 工具的核心价值。
# 4.4 跨平台同构原理
不同平台的启动看起来差异巨大(Android Zygote fork → Application → Activity;iOS dyld → main → AppDelegate;Web HTML 解析 → JS 执行 → 首次渲染;嵌入式 init → 服务初始化 → UI 主循环),但抽象成"任务依赖图 + 关键路径"后完全同构:
通用启动模型:
[系统级初始化] ──▶ [运行时启动] ──▶ [应用初始化] ──▶ [首屏渲染] ──▶ 可交互
│ │ │ │
进程创建 VM/解释器 业务对象 用户可见
2
3
4
5
每个平台都必须有:
| 抽象阶段 | 解决什么问题 |
|---|---|
| 进程 / 上下文创建 | OS 内核为应用分配资源(地址空间、文件描述符、线程) |
| 运行时启动 | VM / 解释器 / 浏览器引擎初始化(GC、JIT、解析器) |
| 应用初始化 | Application 创建、SDK 初始化、配置加载 |
| 首页准备 | 数据加载、UI 框架初始化、首帧渲染 |
正因为它们都长这个样子,启动的物理本质也是同一个:
启动慢不是平台的特性,是"任务集 + 依赖图 + 串行预算"这个组合的必然产物。
跨平台启动阶段对照
| 抽象阶段 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 进程创建 | Zygote fork | exec / dyld 加载 | 浏览器进程创建 Tab | OS 创建任务 |
| 运行时启动 | ART 加载 dex | dyld 链接 + libObjc 启动 | V8 / JS 引擎初始化 | RTOS 调度 |
| 应用初始化 | Application.onCreate | UIApplication 初始化 + AppDelegate | DOMContentLoaded | main() / app_init |
| 首页准备 | Activity.onCreate → onResume | UIViewController.viewDidLoad → viewWillAppear | window.load | UI 任务循环 |
| 可交互完成 | Activity 首帧绘制完成 | viewDidAppear + 主 RunLoop 空闲 | TTI / window.onload + INP | UI 主循环就绪 |
# 4.5 平台差异点矩阵
同构之下,各平台的实现差异主要集中在以下维度:
| 维度 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 进程模型 | Zygote fork(共享 ART) | 每次 fork+exec | 浏览器子进程 | 单进程 / 任务 |
| 运行时 | ART(AOT + JIT) | Native + ObjC RT | V8 / SpiderMonkey | C/C++ 直接执行 |
| 类加载策略 | Multi-DEX + ART 优化 | dylib / framework | JS Bundle 解析 | 静态链接 |
| 首屏可见时机 | 首帧渲染完成 | viewDidAppear | LCP | UI 主循环就绪 |
| 系统级阻塞 | ContentProvider onCreate 串行 | dyld 链接慢 | DOM 解析阻塞 | 设备 init |
| 优化手段 | App Startup / 任务调度 SDK | Static Init 延迟 / dyld3 | Code Splitting / SSR | 优先级 + DMA |
后续 §07-§11 各端全链路章节会逐一展开。
# 05.度量与采集
# 5.1 三类采集方案的本质
所有平台的启动采集方案,本质上只有 3 类,区别在于在启动生命周期的哪一段下钩子:
进程开始 ──▶ 运行时就绪 ──▶ Application 创建 ──▶ Activity 创建 ──▶ 首帧 ──▶ 完整可交互
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
① 应用埋点(手动打点 / 自动插桩)
② 系统级 API
③ 端到端外部测量(高速摄像 + LED 触发)
2
3
4
5
6
① 应用埋点(手动打点 / 自动插桩)
- 核心原理:在启动关键路径上的多个代码点埋下时间戳,结束后差值计算各阶段耗时。
- 物理本质:启动是一个有清晰阶段的进程,在每个阶段边界放一个度量哨兵,事后差值即得各阶段耗时。
- 局限根源:只能从
Application.attachBaseContext开始打点,看不到"图标点击 → fork → dyld → ART 初始化"——这段在中低端机上可能占 1–2s。 - 适用场景:自家代码内部精细监控,配合插桩可获得 ~50 行代码级颗粒度。
② 系统级 API(reportFullyDrawn / MetricKit / Performance API)
- 核心原理:操作系统知道"进程从被创建到 Application/UI 就绪"的完整时序,提供专门 API 直接告诉你各阶段耗时。
- 物理本质:应用层无法看到"进程创建 → 运行时启动"这段,但 OS 自己看得到,把这部分数据通过 API 暴露给你。
- 局限根源:API 颗粒度由系统决定("Application 初始化"打包成一个数字);新版本系统才支持。
- 适用场景:作为应用埋点的"前置补全",两者必须配合使用。
| 平台 | API | 暴露的数据 |
|---|---|---|
| Android | Activity.reportFullyDrawn() 配合 logcat Displayed | TTID + TTFD |
| Android 12+ | ApplicationStartInfo | 进程级启动时序(含 cold/warm/hot 标记) |
| iOS | MetricKit MXAppLaunchMetric | dyld / Process Init / App Init / UI Init / 首帧分阶段 |
| Web | Performance.timing / PerformanceNavigationTiming | navigationStart / DOMContentLoaded / loadEventEnd |
| Web | LCP / FCP / INP | 用户感知关键时刻 |
③ 端到端外部测量(高速摄像 / 屏幕直采)
- 核心原理:用外部设备拍下"用户点击图标 → 屏幕显示首屏"全过程,逐帧分析得出物理端到端延迟。
- 物理本质:软件指标永远是"内部视角",最终用户体验只能由"外部视角"验证。
- 局限根源:门槛高(外部设备 + 自动化分析 + 固定光照);不能线上,仅实验室。
- 适用场景:金标准基线测试 + 校准方案 ②/① 的偏差。
三类方案总览
| 方案 | 关键钩子位置 | 输出粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 应用埋点 | 应用代码内 | 任意精细 | 低 | 高 | ✅ | 看不到运行时启动前 |
| ② 系统级 API | OS 提供 | 阶段级 | 极低 | 中 | ✅(API 受限) | 颗粒度由系统决定 |
| ③ 外部测量 | 屏幕外 | 物理时序 | 无 | 极高 | ❌ | 设备 / 流程门槛 |
方案的"组合定律":没有任何单一方案能 100% 覆盖启动监控。必须组合使用:② 看进程到 Application 之前的"黑盒" + ① 看应用代码内部精细阶段 + ③ 实验室基线校准。
探索性思考:为什么 logcat
Displayed不一定是真相? Android logcat 的Displayed时间戳是"应用首帧绘制完成"——但用户看到的可能是 windowBackground(非真实内容)。比如启动后有 800ms 是 windowBackground,logcat 显示 1.2s 完成,但用户实际感知是 2.0s。软件指标的"完成"和用户感知的"完成"可能差几百 ms——这就是为什么金标准必须是高速摄像(方案 ③)。
# 5.2 各方案的可见盲区
| 阶段 | 方案 ① | 方案 ② | 方案 ③ |
|---|---|---|---|
| 进程创建(fork / dyld) | ❌ | ✅ | ✅ |
| 运行时启动(ART / V8) | ❌ | ✅ | ✅ |
| Application.onCreate | ✅ | ✅ | ✅ |
| 第三方 SDK 内部 | ❌(除非 Hook) | ❌ | ✅(含在总时间内) |
| 首帧渲染 | ✅ | ✅ | ✅ |
| 完整可交互(TTFD) | ✅(应用声明) | ✅(系统判定) | ✅ |
| 触摸响应延迟 | ❌ | 部分 | ✅ |
盲区一:进程创建到 Application 之间:应用埋点最早只能从 Application.attachBaseContext 开始打点。必须用方案 ② 补齐。
盲区二:第三方 SDK 内部:你能埋点 SDK 调用前后,但 SDK 内部如果起 5 个线程每个跑 200ms,应用埋点完全看不到。补救:用 Trace(Systrace / Perfetto / Instruments)。
# 5.3 跨平台采集对照表
| 维度 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 进程级时序 | ApplicationStartInfo(31+) | MetricKit MXAppLaunchMetric | PerformanceNavigationTiming | RTOS trace |
| 应用启动总时长 | logcat Displayed | viewDidAppear 时间戳 | LCP / TTI | 业务埋点 |
| TTID | logcat Displayed | didFinishLaunching 后首帧 | FCP | 首帧渲染 |
| TTFD | Activity.reportFullyDrawn | 自定义 os_signpost | TTI / INP | 业务声明 |
| 阶段拆解 | Trace section | os_signpost interval | User Timing API | RTOS event |
| 自动插桩 | ASM Gradle Plugin | SwiftSyntax | Babel plugin | 编译期 hook |
# 5.4 数据可信度评估
不同采集方案的可信度排序:③ 外部测量 > ② 系统级 API > ① 应用埋点。
| 指标 | ① 应用埋点 | ② 系统级 API | 偏差来源 |
|---|---|---|---|
| Application 之后阶段 | 误差 < 1% | 误差 < 1% | 几乎无 |
| 完整冷启动时长 | 偏低 ~30%(漏前置) | 误差 < 5% | ① 漏掉进程 / 运行时启动 |
| 端到端物理延迟 | 不可测 | 不可测 | 必须用 ③ |
工程实践建议:线上以 ② 为主,① 兜底(精细化诊断);每个版本在实验室用 ③ 做一次端到端基线,校准 ②/① 的偏差系数。
# 06.归因决策树
采集只能告诉我们"启动 3 秒",归因要告诉我们"3 秒里 1.2s 是 SDK 初始化、0.8s 是首页 IO"。
# 6.1 启动归因决策树
启动慢的根因有十几种,决策树把"穷举式排查"压缩成"二分式排查":先二分到阶段,再到根因,平均 2 步定位。
启动 > 目标值
│
├── 阶段 A. 进程 / 运行时启动 > 0.8s ──▶ 包大小、动态库链接
│ ├─ Multi-DEX 慢(详见 §17.3)
│ ├─ 静态初始化代码多
│ └─ dylib / .so 链接多
│
├── 阶段 B. Application 阶段 > 0.5s ────▶ Application.onCreate 重
│ ├─ 同步初始化的 SDK 太多
│ ├─ ContentProvider 串行(Android)
│ ├─ AppDelegate 中的同步操作(iOS)
│ └─ DOMContentLoaded 中阻塞 JS(Web)
│
├── 阶段 C. Activity / 首屏控制器 > 0.3s ─▶ UI 创建慢
│ ├─ 布局过深(详见卷三·渲染篇)
│ ├─ 首屏数据同步加载
│ └─ View Inflate 慢
│
└── 阶段 D. 首帧之后到可交互 > 1s ────────▶ 数据加载慢
├─ 网络请求串行
├─ 缓存未命中
└─ 大列表初始化
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
反直觉问题 ③ 答案:CPU 不饱和但启动慢,几乎一定是 B 类(IO / Binder / 锁等待)或资源竞争。
▶▶ 回扣 §02 案例:金融 App 套用决策树:
- 启动 P50 = 4.2s(中端机预算 1.5s)
- Application 阶段 1850ms(超 4×)→ 必查
- 关键路径? = 710ms
- 主线程实际跑了? = 1850ms(多 1140ms 是不该串行的任务)
- 根因 A:非关键任务挤进主线程
- 首屏 → 数据稳态 1100ms(行情数据)
- 行情连接初始化在主线程? Yes
- 行情数据请求在 onResume 后? Yes(串行链)
- 根因 B:数据预取没在启动并行
# 6.2 关键路径分析法
步骤:
- 建任务清单:列出启动期所有任务及其耗时。
- 建依赖图:标注每个任务依赖什么。
- 求关键路径:DAG 上的最长路径。
- 重排 / 异步化:把非关键任务移出,缩短关键任务。
关键路径求解(简化算法):
对每个任务 t,计算:
EarliestStart(t)= max(EarliestEnd of all predecessors)EarliestEnd(t)= EarliestStart(t) + Duration(t)
最终:TotalTime = max(EarliestEnd of all tasks)。关键路径 = 任何 EarliestEnd = TotalTime 的链路。
工程上的两个简化工具:
- Trace 时序图(Perfetto / Instruments / Chrome Performance)直接展示每个任务的开始 / 结束 / 关系。
- Gantt 图导出(自定义脚本从打点数据生成)便于离线分析。
# 6.3 阶段拆解归因法
按 §6.1 决策树的四阶段,逐阶段定位:
阶段 A:进程 / 运行时
- 工具:Android
ApplicationStartInfo+ Trace;iOS Instruments dyld + libObjc;Web Network panel。 - 典型问题:包体积 > 100MB 慢、静态初始化多、JNI_OnLoad 串行。
阶段 B:Application
- 工具:Trace + 应用埋点。
- 典型问题(按耗时排序):ContentProvider.onCreate 串行(Android 特有);数据库 / SP / NSUserDefaults 同步读;网络初始化(建立 SSL / WebSocket);大量 SDK 同步 init。
阶段 C:Activity / 首屏控制器
- 工具:Choreographer / FrameMetrics(详见卷三·渲染篇)。
- 典型问题:布局过深、ViewStub 滥用、主题 windowBackground 加载大图。
阶段 D:首帧到可交互
- 工具:业务埋点 + 网络监控。
- 典型问题:首页数据网络请求慢、大列表 onBindViewHolder 中同步 IO、异步任务回调到主线程后立即触发卡顿。
# 6.4 任务依赖归因法
反直觉问题 ⑦ 的回应:异步化后总耗时不一定减少。
为什么:
- A 任务依赖 B:A 异步无法跳过 B 的耗时。
- B 占用 CPU:异步只是把 B 移到子线程,CPU 总量不变。
- 异步开销:线程创建、上下文切换本身有成本(详见 §17.2)。
正确的"任务依赖归因"步骤:
- 列出每个任务的真实依赖(不是想当然)。
- 找出"伪依赖"(误以为依赖,实际可解耦)。
- 异步化只对真正无依赖的任务有效。
- 异步化必须验证总耗时减少(A/B 测试),否则可能反向收益。
反直觉问题 ④ 的回应:开多少线程最快?
由 卷二·CPU 篇 §5.2 已证明:CPU bound 任务的最优线程数 = CPU 核数(拐点)。超过后上下文切换开销超过并发收益。
启动期推荐线程数:
- 计算密集任务池:CPU 大核数 + 1(4–6 个)。
- IO 密集任务池:可更大(10–20 个),但要看磁盘并发能力。
- 总线程数不要超过 CPU 总核数 × 2,否则启动期线程争用反而拖慢。
探索性思考:为什么"伪依赖"是隐藏陷阱? 工程师写代码时常说"A 必须在 B 后"——但仔细问"为什么必须?"往往得到"因为代码这样写的"。这不是真依赖,是实现依赖。比如 PushSDK init 调用 LogSDK,看起来依赖;但 PushSDK 完全可以延迟 LogSDK 调用到首次需要时。伪依赖的本质是"代码耦合"——重构后可以解开。这是 P 系数(可异步化部分)能从 0.5 提到 0.8 的最大空间。
# 07.Android 全链路 ⭐
本章把 Android 启动从"用户点击图标"一路拆到"首帧渲染完成",回答四个核心问题:Zygote fork 是什么 / Application 阶段为什么这么重 / ContentProvider 为何串行 / Activity 首帧怎么算"完成"。
# 7.1 进程创建阶段
Android 应用进程不是从零创建,而是 Zygote fork——Android 启动时已经创建一个"模板进程"Zygote,加载好了 ART 运行时和 Framework 类。每次启动应用都从 Zygote fork 出来,省掉了 ART 初始化的几百 ms。
用户点击图标
│
▼
Launcher → Binder → AMS(system_server)
│
▼
AMS 检查目标进程是否存在
│
├─ 存在 → 复用(warm/hot 启动)
│
└─ 不存在 → 通知 Zygote fork
│
▼
fork() 系统调用
│
▼
子进程 = 应用进程(共享父进程地址空间)
│
▼
子进程执行 ActivityThread.main()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键事实:
- fork 几乎是零成本(COW 写时复制),但进程创建后还要做 OS 资源分配(PID、虚拟内存、文件描述符),约 50-100ms。
- 共享 ART:Zygote 已加载的类直接共享(不需要重新加载),这是 Android 启动相对快的最大优势。
- 应用代码看不到这一段——只能用 Android 12+ 的
ApplicationStartInfo拿到。
# 7.2 ART 加载与 DEX 阶段
ActivityThread.main()
│
▼
Looper.prepareMainLooper()
│
▼
ActivityThread.attach()
│
▼
AMS 通过 Binder 通知"绑定应用"
│
▼
handleBindApplication
│
├─ 加载 APK 的 dex(如果不在 Zygote 共享类里)
│
├─ ART 解析 / verify / 编译(AOT 已编译则跳过)
│
├─ 加载 Native 库(System.loadLibrary)
│
▼
ContentProvider.onCreate(所有 Provider 串行)
│
▼
Application.onCreate
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
两个性能要点:
- AOT 编译产物(.oat)让 ART 启动接近 Native——但首次安装后第一次启动仍可能因 ART JIT 编译热路径而较慢(一次性)。
- Multi-DEX 在 Android 5.0+ 几乎无影响(§17.3 实验)——但 Android 4.x 仍是关键瓶颈。
探索性思考:为什么 Baseline Profiles 在 Android 12+ 这么重要? 因为它告诉 ART"哪些类是热路径"——ART 在安装期对这些类做完整 AOT 编译,启动时直接用编译产物。没有 Profile 时 ART 用"启动后慢慢 JIT"策略——首次启动会更慢。Baseline Profiles 让 Google Play 自动收集线上热路径数据反哺,首次启动加速 30%——这是 Android 启动优化领域近年最重要的进展。
# 7.3 ContentProvider 串行陷阱
Android 独有的"启动地雷":每个 ContentProvider 在 Application.onCreate 之前就执行 onCreate()——而且是主线程串行。
handleBindApplication
│
▼
installContentProviders()
│
├─ Provider A.onCreate()(同步等待)
│
├─ Provider B.onCreate()(同步等待)
│
├─ ... 几十个 Provider 串行 ...
│
▼
Application.onCreate()
2
3
4
5
6
7
8
9
10
11
12
13
关键事实:
- 第三方 SDK 喜欢用 ContentProvider 实现"自动初始化"(不用应用调 init)。
- 每个 Provider 执行 50-200ms,几十个加起来就是 1-3s。
- ContentProvider.onCreate 不能异步——是 Framework 强制串行调用。
应对:
- Jetpack App Startup:把所有第三方 ContentProvider 整合到一个
InitializationProvider,内部用 DAG 调度。 - 删除可去除的 SDK ContentProvider:很多 SDK 提供"手动 init"选项。
# 7.4 Application.onCreate 阶段
Application.onCreate
│
▼
一般业务做的事:
├─ 各种 SDK 初始化(统计/推送/广告/日志/崩溃...)
├─ 全局配置加载(SP / 数据库)
├─ 网络框架初始化
├─ 注册 Activity 生命周期监听
├─ 路由表 / DI 容器构建
├─ ...
│
▼
返回,Framework 创建启动 Activity
2
3
4
5
6
7
8
9
10
11
12
13
这一阶段是 Android 启动优化的"主战场"——80% 的可优化空间都在这里。
典型问题:
| 问题 | 占比 | 解决思路 |
|---|---|---|
| 28+ SDK 同步初始化 | 30-50% | 分级 + IdleHandler 推迟 |
| SP 同步读阻塞 | 5-15% | MMKV / DataStore |
| 数据库初始化 | 5-10% | 异步 + 懒加载 |
| Native 库加载(.so) | 5-10% | 按需加载 |
| 网络框架建连 | 5-10% | 预热 + 并行 |
# 7.5 Activity 创建到首帧
Application.onCreate 返回
│
▼
ActivityThread 创建 LauncherActivity
│
▼
Activity.onCreate
│
├─ setContentView(XML 解析 + View Inflate)
├─ findViewById(逐层遍历)
├─ 业务初始化
│
▼
Activity.onStart → onResume
│
▼
ViewRootImpl.performTraversals
│
├─ Measure / Layout / Draw
│
▼
首帧绘制完成 → logcat "Displayed"
│
▼
首屏数据请求 → onBindViewHolder → ...
│
▼
Activity.reportFullyDrawn → TTFD
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
关键差异:TTID(首帧)≠ TTFD(可交互)——首帧可能只是骨架屏 / 占位图,用户看到但点不动。
探索性思考:为什么"白屏"在 Android 上特别常见? 因为 Android 启动时先显示 windowBackground(主题里的占位色 / 图)——直到 Activity 首帧绘制完成才切换。如果 Application 阶段慢 1s,用户就盯着白屏 1s。应对方法:把 windowBackground 设置成与首屏色调一致的图片,让用户视觉上感觉应用已经"开始加载"。这叫"启动屏感知优化"——不缩短启动时长,但缩短感知启动时长。
# 08.iOS 全链路 ⭐
本章把 iOS 启动从"用户点击图标"一路拆到"viewDidAppear",回答:dyld 在做什么 / +load 为什么是禁忌 / Swift 启动比 OC 慢的原因。
# 8.1 iOS 启动五阶段
iOS 启动的官方五阶段模型(Apple WWDC 2019 提出):
┌──────────┬──────────┬─────────┬──────────┬──────────┐
│ dyld │ libObjc │ Static │ UI │ Initial │
│ Linking │ Init │ Init │ Init │ Frame │
└──────────┴──────────┴─────────┴──────────┴──────────┘
│ │ │ │ │
动态链接库 ObjC 类 静态变量 AppDelegate 首帧渲染
加载 + 链接 注册 构造函数 + Window
2
3
4
5
6
7
关键事实:前三阶段(dyld / libObjc / Static Init)应用代码完全无法干预——你只能通过"减少代码量、减少动态库、减少 +load"来缩短。
# 8.2 dyld 链接阶段
exec() 加载主二进制
│
▼
dyld 启动
│
├─ 加载所有依赖的 dylib(递归)
│
├─ Rebase(修正基址)
│
├─ Bind(链接外部符号)
│
├─ ObjC 类注册
│
▼
dyld 完成 → main()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dyld 性能要点:
| 因素 | 影响 | 优化 |
|---|---|---|
| 动态库数量 | 每个 dylib 加载 5-20ms | 合并 + 减少 |
| 符号绑定 | 每个外部符号 1-10us | 减少跨库调用 |
| ObjC 类数 | 每个类注册 1-5us | 减少 OC 类 |
Apple 推荐:动态库 ≤ 6 个(含系统)——超过会被 watchdog 杀死风险高。
# 8.3 +load 与 Static Init 禁忌
OC 的 +load 方法和 C++ 静态变量构造函数都在 dyld 之后、main() 之前执行——完全在主线程:
@implementation MyClass
+ (void)load {
// 启动期就执行,无法异步
[SomeSDK init]; // ❌ 在这里 init SDK 直接拖慢启动
}
@end
2
3
4
5
6
static MyService g_service; // 构造函数在 dyld 后执行
为什么是禁忌:
- 执行时机不可控——所有 +load 串行执行,顺序由 dyld 决定。
- 错误处理困难——抛异常 / 阻塞会让进程直接死。
- 测量困难——Instruments 看到的是"main 之前的黑盒"。
应对:
- 用
+initialize替代+load(懒加载,首次发消息时执行)。 - C++ 全局变量改成函数局部 static(首次调用时构造)。
- 把"Pod 的初始化"挪到 AppDelegate 显式调用。
探索性思考:为什么 Swift 启动比 OC 慢 100-300ms? Swift 标准库 + 运行时本身较大(dyld 加载 20-50ms),且 Swift 类的元数据注册比 OC 类复杂。对纯 Swift 应用,dyld 阶段比纯 OC 应用多 100-300ms。这是不可消除的"语言税"——选 Swift 就要接受。Apple 的应对是:iOS 13+ 的 dyld3 做了显著优化(启动时使用预生成的"closure"跳过部分链接),让 Swift 应用追平 OC。
# 8.4 main() 与 AppDelegate
main()
│
▼
UIApplicationMain
│
▼
UIApplication 初始化 + AppDelegate 创建
│
▼
application(_:didFinishLaunchingWithOptions:)
│
├─ 业务做的事:
│ ├─ 启动 Crashlytics / 各种 SDK
│ ├─ 设置 Window + RootViewController
│ ├─ 加载持久化数据(NSUserDefaults / Core Data)
│ ├─ 注册推送
│ └─ ...
│
▼
返回,UIKit 调用 RootVC.viewDidLoad
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键认知:iOS 的 didFinishLaunching 类似 Android 的 Application.onCreate + Activity.onCreate 合并——这里慢,启动整体就慢。
优化思想:
- 同步初始化 < 5 个(关键路径)。
- 其他 SDK 用
dispatch_async推迟到首帧后。 - 用
os_signpost标注每个阶段,方便 Instruments 分析。
# 8.5 viewDidLoad 到 viewDidAppear
RootVC.viewDidLoad
│
├─ Storyboard / XIB 解析(如果用)
├─ 子视图创建
├─ 数据准备
│
▼
viewWillAppear
│
▼
首次 layoutSubviews + draw
│
▼
viewDidAppear(系统认为"显示完成")
│
▼
首屏数据请求结束 → 业务声明 TTFD
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
iOS 的"启动结束"判定:
- TTID:viewDidAppear 调用时刻。
- TTFD:业务自己用
os_signpost_interval_end标注(首屏数据稳态)。
iOS 启动优化的特殊优势:
- LaunchScreen 自动衔接:iOS 强制 LaunchScreen 与首屏视觉一致——避免 Android 的"白屏"问题。
- dyld3 closure 缓存:iOS 13+ 自动加速二次启动。
- Order Files:编译期指定符号顺序,让 dyld 加载更快。
探索性思考:为什么 iOS 启动天然比 Android 快? 因为 iOS 没有 ContentProvider 这种"启动地雷"——AppDelegate 是唯一的启动入口。这是"严格生态"的好处——Apple 不允许应用偷偷在启动期跑代码(除了 +load,且有限制)。反观 Android 生态——SDK 用 ContentProvider 偷启动、Application 阶段被无数 SDK 挤占——这是"开放生态"的代价。
# 09.Web 全链路 ⭐
本章把 Web 启动从"输入 URL"一路拆到"页面可交互",回答:HTML 解析 vs JS 执行的关系 / 为什么 LCP 不等于 TTI / Service Worker 怎么"秒开"。
# 9.1 Web 启动的物理本质
Web 启动比 Native 复杂——它涉及网络往返:
用户输入 URL
│
▼
DNS 解析 + TCP 握手 + TLS 握手(首次访问)
│
▼
下载 HTML(首字节 TTFB)
│
▼
HTML 解析 → 发现 <script> / <link> / <img>
│
├─ 同步 <script>:阻塞 HTML 解析 → 必须下载 + 执行
│
├─ <link rel="stylesheet">:阻塞渲染(不阻塞解析)
│
└─ <img>:异步加载
│
▼
DOM 解析完成(DOMContentLoaded)
│
▼
CSS 解析完成 → 首次渲染(FCP / First Contentful Paint)
│
▼
主要内容渲染完成(LCP / Largest Contentful Paint)
│
▼
JS 执行完成 + 事件绑定 → 可交互(TTI / Time To Interactive)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
关键差异:Native 启动是"本地任务",Web 启动是"网络任务"。这意味着 Web 启动的物理上限是网络往返延迟——不可能比一次 RTT 更快(除非用缓存)。
# 9.2 关键渲染路径(CRP)
Web 的"关键路径"叫 Critical Rendering Path:
HTML ──▶ DOM
│
├─▶ Render Tree ──▶ Layout ──▶ Paint
│
CSS ──▶ CSSOM
JS(阻塞)──▶ 暂停 DOM 构建
2
3
4
5
6
7
关键事实: