读者点单·端午投票系列 · 第2/10篇
第1篇:Android 性能治理的「全景图」:从机型分级到指标体系
第2篇:Android 启动优化实战(本篇)
⏳ 第3篇:Compose 与传统 View 混用的 12 个真实坑
你的 App 冷启动超过 2 秒?Google 的数据说,这会让 53% 的用户选择卸载。今天这篇我不讲概念扫盲,直接上实战:一条 Perfetto 命令 + 一张火焰图,教你把 Application.onCreate 里那堆串行初始化拆干净,再用 DAG 拓扑排序把冷启动时间砍到 1 秒以内。最后,我们聊聊怎么用 Macrobenchmark + CI 卡口把优化成果锁住,不让下个版本又涨回去。
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 文件:
yaml
# 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
然后一条命令开抽:
markdown
# 开始录制
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:
vbnet
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:
kotlin
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)
}
}
拆解后:
kotlin
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 调度器实现:
kotlin
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 的标准姿势:
less
// 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:
bash
# 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 灰度回滚------先把新加的初始化关掉,确认是它导致的,再决定优化方案。
kotlin
// 启动任务绑定 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 不给过。这个流程比任何技术手段都有效。
写在最后
启动优化不是什么很神秘的黑魔法,它的核心就三步:
• 定位:Perfetto 抽 Trace,SQL 找 Top10 耗时方法
• 治理:三类拆解 + DAG 编排 + Baseline Profile
• 防守:Macrobenchmark CI 卡口 + 线上 P95 监控 + Feature Toggle
每一步都不难,难的是"坚持"。启动优化不是一锤子买卖,是一个持续的工程化体系。只要你把 CI 卡口建好,让每一行新加的初始化代码都"被看见",启动时间就不会再悄悄涨回来。
下一篇我们聊一个跟启动完全不同的话题:《Compose 与传统 View 混用的 12 个真实坑》。如果你的项目正在从 XML 向 Compose 迁移,那篇会很有共鸣。