
📚 读者点单·端午投票系列 · 第2/10篇
✅ 第1篇:Android 性能治理的「全景图」:从机型分级到指标体系
📖 第2篇:Android 启动优化实战(本篇)
⏳ 第3篇:Compose 与传统 View 混用的 12 个真实坑
你的 App 冷启动超过 2 秒?Google 的数据说,这会让 53% 的用户选择卸载。今天这篇我不讲概念扫盲,直接上实战:一条 Perfetto 命令 + 一张火焰图,教你把 Application.onCreate 里那堆串行初始化拆干净,再用 DAG 拓扑排序把冷启动时间砍到 1 秒以内。最后,我们聊聊怎么用 Macrobenchmark + CI 卡口把优化成果锁住,不让下个版本又涨回去。
📰 今日要闻
• 英特尔将为苹果代工芯片 —— 库克同时透露 iPhone 涨价不可避免,美国科技公司开始限制员工 AI 使用成本
• 霍尔木兹海峡通航恢复预期未能缓解经济冲击 —— 分析师警告战争带来的经济损失已"固化",全球能源供应链修复需数月
• 美股牛市不会因 Warsh 加息而终结 —— 新任美联储主席虽释放鹰派信号,但历史上加息周期中美股仍有上涨空间
• geohot 发文《The Doom Justifies the Valuation》引发 HN 热议 —— 讨论 AI 创业公司估值泡沫与末日叙事的关系
1. 三种启动状态,你真的分得清吗?
上周有个同事问我:“我们 App 启动到底算快还是慢?”我反问他:“你说的是冷启动还是温启动?”他愚了。很多团队优化启动性能的第一个坑,就是连指标定义都没对齐。
这里先把三个概念钉死:
启动类型 | 起点 | 结束点 |
|---|---|---|
Cold 冷启动 | Zygote fork 进程 | TTID 首帧绘制 |
Warm 温启动 | 进程存活,Activity 需重建 | TTID |
Hot 热启动 | Activity 在栈顶,只需 onResume | TTID |
这里有两个细节值得强调:
TTID vs TTFD:你的老板关心的到底是哪个?
TTID(Time To Initial Display)是系统定义的“首帧上屏”,对应 Displayed 日志里那个时间。而 TTFD(Time To Full Display)是“用户可交互”的时间点——如果你首页还有网络请求、RecyclerView 加载等,TTFD 才是用户真正感知的“启动完成”。
我的经验:线上监控看 TTID(因为系统自动打点,稳定),体验优化看 TTFD(因为用户不关心你什么时候画了一个白屏)。
实战经验:许多团队报“冷启动 800ms”,但用户却觉得慢。一查才发现那 800ms 是 TTID,实际 TTFD 已经到 2.5s 了——首屏数据还在网络等待。
2. Perfetto 抽 Trace:一条命令定位启动瓶颈
说实话,我之前一直用 systrace,到 2025 年才彻底切到 Perfetto。两者核心区别就一句话:systrace 只能看内核 atrace 事件,Perfetto 还能看内存、CPU 频率、binder 调用、甚至自定义事件,而且能用 SQL 查询。
实操:一条命令抽冷启动 Trace
先贴我常用的 config 文件:
# startup_trace.cfg
buffers {
size_kb: 65536
fill_policy: RING_BUFFER
}
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events:
"sched/sched_switch"
ftrace_events:
"power/cpu_frequency"
atrace_categories:
"am"
atrace_categories:
"wm"
atrace_categories:
"view"
atrace_categories:
"dalvik"
atrace_apps: "*"
}
}
}
duration_ms: 10000然后一条命令开抽:
# 开始录制
adb shell perfetto \
-c startup_trace.cfg \
-o /data/misc/perfetto-traces/\
startup.perfetto-trace# 强制冷启动
adb shell am force-stop \
com.example.app
adb shell am start-activity \
-W -n com.example.app/\
.MainActivity# 等 10s 后拉取
adb pull /data/misc/\
perfetto-traces/\
startup.perfetto-trace .SQL 查询 Top10 耗时 Slice
拉下来的 Trace 打开 ui.perfetto.dev,点击左下角“Query”页签,直接跑 SQL:
SELECT
s.name,
s.dur / 1000000 AS dur_ms
FROM slice s
JOIN thread_track tt
ON s.track_id = tt.id
JOIN thread t
ON tt.utid = t.utid
WHERE t.name = 'main'
AND s.dur > 0
ORDER BY s.dur DESC
LIMIT 10;这条 SQL 会把主线程上最耗时的 10 个 Slice 按耗时降序排出来。通常你会看到这几个“老面孔”:
• bindApplication —— Application 创建 + ContentProvider 初始化
• activityStart —— Activity 创建 + setContentView
• inflate —— 布局 XML 解析
• Choreographer#doFrame —— 首帧渲染
SmartPerfetto 工具最近更新,支持 5GiB 大 Trace 上传 + Linux glibc 兼容。如果你的 Trace 文件超过 300MB,可以试试这个工具,自动清理 + 浏览器打开,比手动拉本地舒服很多。
3. Application.onCreate 拆解:三类法则
找到瓶颈后,90% 的冷启动问题最终都指向同一个地方:Application.onCreate。这里通常塞了十几个 SDK 的初始化,串行执行,谁也不让谁。
我的拆解思路很简单,把所有初始化任务分三类:
分类 | 定义 | 典型例子 |
|---|---|---|
Must-Sync | 不在主线程同步完成会 Crash | Crash SDK、埋点 SDK、网络库基础配置 |
Can-Delay | 首屏不需要,可以异步或延后 2s | 推送 SDK、分享 SDK、AB 实验平台 |
Lazy-Load | 用到时才加载,永远不放 onCreate | 地图 SDK、音视频播放器、支付 SDK |
具体怎么拆?我每次都会先用 Perfetto 看每个 SDK 的耗时,然后列一张表格跟 PM 对齐:“这个延后 2 秒会影响哪个功能?”得到答案是“不影响”,那就大胆延后。
一个真实案例——我们项目之前的 Application:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// ⬇️ 之前全部串行,耗时 1.2s
CrashSDK.init(this)
NetworkSDK.init(this)
PushSDK.init(this)
ShareSDK.init(this)
MapSDK.init(this)
PaySDK.init(this)
TrackSDK.init(this)
ABTestSDK.init(this)
}
}拆解后:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// Must-Sync: 120ms
CrashSDK.init(this)
TrackSDK.init(this)
NetworkSDK.init(this)// Can-Delay: 异步 IO 线程
Executors
.newSingleThreadExecutor()
.execute {
PushSDK.init(this)
ShareSDK.init(this)
ABTestSDK.init(this)
}// Lazy-Load: 不在这里初始化
// MapSDK -> 地图页首次打开时
// PaySDK -> 下单页首次打开时
}
}就这么一拆,主线程从 1.2s 掉到 120ms。但这只是“手工作坊”的做法,当 SDK 超过 20 个、且有依赖关系时,你需要一个更系统化的方案。
4. DAG 任务编排:拓扑排序干掉串行阻塞
当初始化任务多了,而且之间有依赖关系(比如埋点 SDK 依赖网络库),简单的“丢到子线程”就不够用了。这时候需要 DAG(有向无环图)编排。
Jetpack App Startup vs 自研 DAG
Google 官方提供了 Jetpack App Startup,但说实话它比较轻量,主要解决的是 ContentProvider 滥用问题,不是并行调度。如果你需要的是“多任务并发 + 依赖等待 + 主线程闸门”,得自己写,或者用社区方案(比如 Alpha、Anchors)。
核心思路:
构建任务图(Task + 依赖声明)
↓
拓扑排序:计算每个 Task 的入度
↓
入度=0 的任务立即并发执行
↓
任务完成 → 下游入度-1 → 变为0就触发
↓
主线程闸门等待关键路径完成
来看一个简化版的 DAG 调度器实现:
class StartupTask(
val name: String,
val runOnMain: Boolean,
val deps: List<String>,
val action: () -> Unit
)class TaskScheduler {
private val tasks =
mutableMapOf<
String, StartupTask
>()
private val latch =
CountDownLatch(0)fun add(
task: StartupTask
) {
tasks[task.name] = task
}fun start() {
// 拓扑排序 + 并发调度
val inDegree =
calcInDegree(tasks)
val queue =
ArrayDeque<String>()inDegree.filter {
it.value == 0
}.keys.forEach {
queue.add(it)
}val pool = Executors
.newFixedThreadPool(4)while (queue.isNotEmpty()) {
val name = queue.poll()
val t = tasks[name]!!
if (t.runOnMain) {
t.action()
} else {
pool.execute {
t.action()
onFinish(
name, queue
)
}
}
}
// 等待关键路径
latch.await(
3, TimeUnit.SECONDS
)
}
}实际生产环境会更复杂:你还需要处理“某个任务必须在首屏绘制前完成”的约束,以及“某个任务只能在 IO 线程”的限制。但核心思想就这一个:拓扑排序 + 并发执行 + 关键路径闸门。
5. Baseline Profile:Android 16 上的新红利
讲完代码层面的优化,再说一个“零成本”收益:Baseline Profile。原理很简单——告诉 ART 运行时“这些方法启动时会用到,请提前 AOT 编译”,避免首次运行时的 JIT 开销。
但这里有个细节很多人不知道:baseline-prof.txt 和 startup-prof.txt 是两个不同的文件。
文件 | 作用 | AOT 时机 |
|---|---|---|
baseline-prof.txt | 应用全生命周期热路径 | 安装后空闲时 bg-dex2oat |
startup-prof.txt | 仅启动路径的方法 | 安装时立即编译(更激进) |
在 Android 16 上,新版 ART 对 Profile 引导编译更激进。我们内部测试数据:Pixel 9 Pro 上,有 Baseline Profile vs 没有,冷启动时间差距达到 40%+。这个收益在 Android 13-14 上只有 15-20%,新版本红利非常明显。
生成 Baseline Profile 的标准姿势:
// BaselineProfileGenerator.kt
@RunWith(
AndroidJUnit4::class
)
class StartupProfile {
@get:Rule
val rule =
BaselineProfileRule()@Test
fun generate() {
rule.collect(
packageName = "com.example"
) {
// 启动到首屏可交互
startActivityAndWait()
// 可加更多交互路径
device.waitForIdle()
}
}
}6. 防劣化体系:让优化成果不再回退
说实话,启动优化最痛的不是“优化不下去”,而是“优化下去了,下个版本又涨回来了”。每次都是某个业务同学偷偷在 Application 里加了一行初始化,没人发现,累积两个版本又回到解放前。
解决方案就两招:
6.1 Macrobenchmark CI 卡口
在 CI 里跑 Macrobenchmark,每次 MR 合入前自动测一次冷启动时间。超过基线值 10% 直接 Block:
# ci_startup_check.sh
BASELINE=900 # ms
THRESHOLD=990 # +10%RESULT=$(
adb shell am start-activity \
-W -n com.example.app/\
.MainActivity \
| grep TotalTime \
| awk '{print $2}'
)if [ "$RESULT" -gt \
"$THRESHOLD" ]; then
echo "❌ Startup regression:
${RESULT}ms > ${THRESHOLD}ms"
exit 1
fi
echo "✅ Startup OK: ${RESULT}ms"6.2 线上 P95 监控 + Feature Toggle
线上用 reportFullyDrawn() 打点 TTFD,上报到监控平台。关注 P95 而不是均值(均值会被高端机拉低、掩盖低端机的问题)。发现劣化时,用 Feature Toggle 灰度回滚——先把新加的初始化关掉,确认是它导致的,再决定优化方案。
// 启动任务绑定 Feature Toggle
class AdSdkTask : StartupTask(
name = "ad_sdk",
runOnMain = false,
deps = listOf("network")
) {
override fun run() {
if (!FeatureToggle
.isEnabled(
"ad_sdk_startup"
)
) return
AdSDK.init(appCtx)
}
}经验之谈:我们团队现在的规矩是——任何新增的 Application 初始化代码,必须走 StartupTask 注册 + Feature Toggle 包裹。不然 CR 不给过。这个流程比任何技术手段都有效。
7. 动态微调:启动策略不该一刀切
前面讲的都是「静态」策略——根据分类决定哪些任务延迟、哪些异步。但现实场景里,同一台设备在不同时刻的“健康状态”差异巨大:早上刚开机内存充裕,下午多任务满载时内存已经很紧张。所以更进阶的做法是——启动时采集“当前状态分”,动态决定任务编排策略。
运行时评分模型
核心思路:在 Application.onCreate 的最早期,花 3-5ms 采集一次“设备即时健康分”,用它来决定后续的并发度和加载策略:
data class DeviceHealthScore(
val memoryPressure: Float,
// 0~1,取 availMem/totalMem
val cpuIdle: Float,
// /proc/stat idle 比例
val ioWait: Float,
// /proc/stat iowait
val thermalState: Int,
// PowerManager 温控等级
val hardwareTier: Int
// 第1篇的机型分级
)fun computeScore(
h: DeviceHealthScore
): Float {
// 加权:内存压力最重要
return h.memoryPressure * 0.35f +
h.cpuIdle * 0.25f +
(1f - h.ioWait) * 0.2f +
(1f - h.thermalState
/ 4f) * 0.1f +
h.hardwareTier / 5f * 0.1f
}根据评分动态调整启动策略
有了这个 0~1 的健康分,就可以在 DAG 编排器里做分级决策:
健康分区间 | 策略 | 典型场景 |
|---|---|---|
≥ 0.7 充裕 | 4 线程并发 + 所有 Can-Delay 延后 1s | 早上刚开机、高端机 |
0.4 ~ 0.7 中等 | 2 线程 + Can-Delay 延后 3s + 关闭动画 | 普通使用中的中端机 |
< 0.4 紧张 | 1 线程 + 全部 Lazy-Load + 最简首屏 | 低端机 / 内存很紧 / 过热 |
val score = computeScore(
collectHealth()
)
val config = when {
score >= 0.7f ->
StartupConfig.FULL
score >= 0.4f ->
StartupConfig.MODERATE
else ->
StartupConfig.MINIMAL
}
StartupTaskRunner
.execute(tasks, config)踩坑提醒:collectHealth() 耗时必须控制在 5ms 以内。读 /proc/stat 本身很快(<1ms),但 ActivityManager.getMemoryInfo() 在极端低内存时可能卡 Binder,建议加 5ms timeout 兆底。
这就是“动态微调”的核心思路:不再用“硬编码设备档位”一刀切,而是采集实时状态分 + 硬件档位加权叠加,一张打分表决定当次启动行为。这比第一篇讲的纯硬件分级进化了一层——结合软件运行时上下文,做「时创分 + 硬件分」的双层决策。
写在最后
启动优化不是什么很神秘的黑魔法,它的核心就三步:
• 定位:Perfetto 抽 Trace,SQL 找 Top10 耗时方法
• 治理:三类拆解 + DAG 编排 + Baseline Profile
• 防守:Macrobenchmark CI 卡口 + 线上 P95 监控 + Feature Toggle
每一步都不难,难的是“坚持”。启动优化不是一锤子买卖,是一个持续的工程化体系。只要你把 CI 卡口建好,让每一行新加的初始化代码都“被看见”,启动时间就不会再悄悄涨回来。
下一篇我们聊一个跟启动完全不同的话题:《Compose 与传统 View 混用的 12 个真实坑》。如果你的项目正在从 XML 向 Compose 迁移,那篇会很有共鸣。
📚 读者点单·端午投票系列 · 第2/10篇
基于端午《聊聊学习节奏》评论区读者票选生成的系列文章
✅ 第1篇:Android 性能治理的「全景图」:从机型分级到指标体系
📖 第2篇:Android 启动优化实战(本篇)
⏳ 第3篇:Compose 与传统 View 混用的 12 个真实坑
⏳ 第4篇:Android 内存治理实战
⏳ 第5篇:Token 节省专题:把 AI 编程账单砍 60%