上一篇我们画了一张完整的冷启动全景图从 Launcher 点击到 Fully Drawn 的七个阶段都拆开看了一遍。理解全景图是前提但只有全景图是不够的——你知道时间花在了某个阶段但具体是哪行代码、哪个初始化拖慢了整个链路靠肉眼看代码是猜不出来的。我见过太多团队的启动优化方式是这样的凭直觉觉得这个 SDK 初始化肯定慢花两周把它改成懒加载一测——快了 50ms。对一个冷启动 4 秒的 App 来说这 50ms 约等于没优化。真正的瓶颈可能是你根本没注意到的 ContentProvider或者是一个看起来人畜无害的 SharedPreferences 读取。所以今天这篇的核心观点只有一个先度量再优化。用工具说话不要用直觉。我们要讲的是一套组合拳Perfetto 负责看清楚发生了什么Macrobenchmark 负责可重复地量化结果Baseline Profile 负责让量化结果可以落地为优化手段。三个工具配合使用才能形成完整的诊断闭环。一、Perfetto启动链路的显微镜如果说 Systrace 是上一代的启动分析工具那 Perfetto 就是它的全面升级版。Google 在 Android 10 之后逐步把 Systrace 的功能迁移到了 Perfetto现在2026年Systrace 基本可以认为已经退役。Perfetto 的核心优势在三个方面更长的 trace 采集时间不再有 Systrace 的缓冲区限制、更强的 SQL 查询能力可以用 TraceProcessor 对 trace 做结构化分析、以及更好的可视化界面ui.perfetto.dev。1.1 抓取启动 Trace抓取启动 trace 有两种方式命令行和 Android Studio。先看命令行方式因为它更灵活适合 CI 环境。第一步准备一个 Perfetto 配置文件。启动优化场景下我们需要关注的 data source 包括linux.ftrace调度事件、android.logLogcat、linux.process_stats进程内存、以及最关键的android.atrace应用自定义 trace 点。# perfetto_startup.pbtx buffers { size_kb: 65536 fill_policy: RING_BUFFER } data_sources { config { name: linux.ftrace ftrace_config { ftrace_events: sched/sched_switch ftrace_events: power/suspend_resume ftrace_events: sched/sched_wakeup ftrace_events: sched/sched_blocked_reason atrace_categories: am atrace_categories: wm atrace_categories: view atrace_categories: dalvik atrace_categories: binder_driver atrace_apps: com.example.myapp } } } data_sources { config { name: linux.process_stats process_stats_config { scan_all_processes_on_start: true proc_stats_poll_ms: 100 } } } duration_ms: 15000几个关键参数解释一下•atrace_categories里的amActivityManager和wmWindowManager是启动分析的核心它们会记录 Activity 生命周期和窗口绘制的关键时间点•atrace_apps必须填你的包名否则你在代码里手动埋的android.os.Trace调用不会被采集•dalvik类别会记录 GC、JIT 编译等 runtime 事件这些在启动阶段经常是隐藏的性能杀手•duration_ms: 15000采集 15 秒对大多数 App 的冷启动来说足够了然后执行抓取# 先杀掉 App 确保是冷启动 adb shell am force-stop com.example.myapp # 开始采集 adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/startup.pbtx \ 50e6 -- 50ms ORDER BY s.dur DESC LIMIT 20;这条 SQL 通常能立刻揪出最大的瓶颈。我在实际项目中用过无数次它帮我们发现过 Firebase Analytics 的 ContentProvider 在主线程花了 300ms 做初始化、Room 数据库的首次查询因为 schema migration 耗时 200ms、以及一个第三方推送 SDK 在 bindApplication 阶段偷偷做了网络请求。1.4 自定义 Trace 埋点看到代码级别的耗时系统自带的 slice 粒度不够细怎么办自己埋。Android 提供了android.os.TraceAPI它的开销极低纳秒级可以放心在生产环境使用// Application.onCreate() 中 class MyApp : Application() { override fun onCreate() { super.onCreate() Trace.beginSection(MyApp.initNetworkSDK) NetworkSDK.init(this) Trace.endSection() Trace.beginSection(MyApp.initImageLoader) ImageLoader.init(this, config) Trace.endSection() Trace.beginSection(MyApp.initAnalytics) Analytics.init(this) Trace.endSection() Trace.beginSection(MyApp.initPushService) PushService.register(this) Trace.endSection() } }加上这些埋点之后再抓一次 trace你的主线程泳道里就能看到每个 SDK 初始化的精确耗时。不用猜了。一个小技巧如果你用 Kotlin可以写一个扩展函数简化埋点inline fun traceBlock(label: String, block: () - T): T { Trace.beginSection(label) return try { block() } finally { Trace.endSection() } } // 用法 traceBlock(MyApp.initNetworkSDK) { NetworkSDK.init(this) }二、Macrobenchmark让启动测量可重复、可比较Perfetto 告诉你发生了什么但一次 trace 只是一个快照。你做了优化之后怎么确认确实变快了而不是这次手机恰好比较空闲这就需要 Macrobenchmark。Macrobenchmark 是 Jetpack 提供的性能测试框架它的核心价值在于自动化执行多次启动测试排除噪声给出统计学上可信的结果中位数、P90、P99。而且它在测试过程中会自动生成 Perfetto trace你可以同时拿到定量数据和定性分析。2.1 项目配置Macrobenchmark 需要一个独立的 benchmark module不能和 app module 混在一起因为它要作为单独的 APK 安装到设备上通过 Instrumentation 驱动被测 App。// settings.gradle.kts include(:benchmark) // benchmark/build.gradle.kts plugins { id(com.android.test) id(org.jetbrains.kotlin.android) } android { namespace com.example.benchmark compileSdk 35 defaultConfig { minSdk 24 targetSdk 35 testInstrumentationRunner androidx.test.runner.AndroidJUnitRunner } // 必须指向你的 app module targetProjectPath :app // 使用 release 构建类型测试更接近真实用户体验 experimentalProperties[android.experimental.self-instrumenting] true } dependencies { implementation(androidx.benchmark:benchmark-macro-junit4:1.4.0-alpha02) implementation(androidx.test.ext:junit:1.2.1) implementation(androidx.test:runner:1.6.2) }同时你的 app module 需要配置一个benchmark构建类型基于 release但开启 profileable// app/build.gradle.kts android { buildTypes { create(benchmark) { initWith(getByName(release)) signingConfig signingConfigs.getByName(debug) isDebuggable false // 确保 profileable 为 true // 在 AndroidManifest.xml 中配置 } } } // app/src/main/AndroidManifest.xml 中添加 // (放在 标签内)2.2 编写启动 Benchmark核心测试类长这样LargeTest RunWith(AndroidJUnit4::class) class StartupBenchmark { get:Rule val benchmarkRule MacrobenchmarkRule() Test fun startupCold() benchmarkRule.measureRepeated( packageName com.example.myapp, metrics listOf( StartupTimingMetric(), TraceSectionMetric(MyApp.initNetworkSDK), TraceSectionMetric(MyApp.initImageLoader), TraceSectionMetric(MyApp.initAnalytics), TraceSectionMetric(MyApp.initPushService), ), iterations 10, startupMode StartupMode.COLD, setupBlock { // 每次迭代前的准备工作 pressHome() // 可以在这里清除 App 缓存模拟首次启动 // device.executeShellCommand( // pm clear com.example.myapp // ) } ) { // 启动 App 并等待首帧 startActivityAndWait() // 如果你的 App 有 splash 之后的主页面加载 // 可以等待特定 View 出现 // device.wait( // Until.hasObject(By.res(main_content)), // 10_000 // ) } Test fun startupWarm() benchmarkRule.measureRepeated( packageName com.example.myapp, metrics listOf(StartupTimingMetric()), iterations 10, startupMode StartupMode.WARM, setupBlock { pressHome() } ) { startActivityAndWait() } }几个关键点•StartupTimingMetric()会自动计算 TTIDTime To Initial Display和 TTFDTime To Full Display。TTID 对应系统报告的首帧时间TTFD 对应你在代码中调用reportFullyDrawn()的时间点•TraceSectionMetric(MyApp.initNetworkSDK)会自动抓取你用Trace.beginSection()埋的自定义 slice 的耗时——这就是 Perfetto 和 Macrobenchmark 打通的地方•iterations 10是最小建议值少于这个数量统计意义不够。如果你的 CI 时间允许建议 20-30 次• 同时测 COLD 和 WARM 两种模式。COLD 是最差情况进程不存在WARM 是进程存在但 Activity 被销毁的情况。优化策略不同2.3 执行与结果解读在 Android Studio 中直接 Run benchmark 测试或者用命令行./gradlew :benchmark:connectedBenchmarkAndroidTest执行完毕后你会在benchmark/build/outputs/connected_android_test_additional_output/目录下找到结果文件包括 JSON 数据和每次迭代的 Perfetto trace。结果长这样示例数据StartupBenchmark_startupCold timeToInitialDisplayMs min 487.3, median 523.8, max 612.1 timeToFullDisplayMs min 892.1, median 967.4, max 1123.6 MyApp.initNetworkSDK min 12.3, median 14.7, max 18.2 MyApp.initImageLoader min 23.1, median 28.4, max 35.6 MyApp.initAnalytics min 187.2, median 203.8, max 267.3 ← 瓶颈 MyApp.initPushService min 45.3, median 52.1, max 78.9一目了然——Analytics SDK 的初始化占了主线程 200ms是所有 SDK 中最慢的。这就是你应该优先优化的目标第三篇会详细讲怎么把它异步化。三、Baseline Profile从诊断到优化的桥梁Perfetto 帮你看清问题Macrobenchmark 帮你量化问题但它们本身不解决问题。不过 Macrobenchmark 有一个隐藏技能它可以在测试过程中顺便采集 Baseline Profile。Baseline Profile 是什么简单说它是一份启动阶段会用到哪些类和方法的清单。把这份清单打包到 APK 里系统在安装时会提前把这些方法 AOT 编译成机器码避免启动时的 JIT 编译开销。Google 官方数据显示Baseline Profile 通常能带来 15%-30% 的启动速度提升这是一个投入产出比极高的优化手段。3.1 采集 Baseline Profile在 benchmark module 中添加一个 Profile 生成器ExperimentalBaselineProfilesApi RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { get:Rule val rule BaselineProfileRule() Test fun generateStartupProfile() rule.collect( packageName com.example.myapp ) { // 冷启动 pressHome() startActivityAndWait() // 模拟用户首次使用的关键路径 // 因为 Baseline Profile 不仅要覆盖启动 // 还要覆盖用户最可能走到的前几个页面 device.wait( Until.hasObject(By.res(main_content)), 10_000 ) // 如果有底部导航模拟切换几个 Tab device.findObject(By.res(tab_search))?.click() device.waitForIdle() device.findObject(By.res(tab_profile))?.click() device.waitForIdle() } }执行后会在app/src/main/baseline-prof.txt生成 profile 文件。把它 commit 到代码库每次构建 release APK 时 AGP 会自动将其打包。3.2 验证 Baseline Profile 的效果采集完 Profile 之后怎么验证它确实有效回到 Macrobenchmark加一个对照组Test fun startupWithCompilation_None() benchmarkRule.measureRepeated( packageName com.example.myapp, metrics listOf(StartupTimingMetric()), compilationMode CompilationMode.None(), // 无 AOT纯解释执行 iterations 10, startupMode StartupMode.COLD, setupBlock { pressHome() } ) { startActivityAndWait() } Test fun startupWithCompilation_BaselineProfile() benchmarkRule.measureRepeated( packageName com.example.myapp, metrics listOf(StartupTimingMetric()), compilationMode CompilationMode.Partial( baselineProfileMode BaselineProfileMode.Require ), iterations 10, startupMode StartupMode.COLD, setupBlock { pressHome() } ) { startActivityAndWait() } Test fun startupWithCompilation_Full() benchmarkRule.measureRepeated( packageName com.example.myapp, metrics listOf(StartupTimingMetric()), compilationMode CompilationMode.Full(), // 全量 AOT iterations 10, startupMode StartupMode.COLD, setupBlock { pressHome() } ) { startActivityAndWait() }三组对比跑一遍你就能看到 Baseline Profile 到底快了多少。典型结果• None纯解释~650ms• Baseline Profile部分 AOT~490ms• Full全量 AOT~460msBaseline Profile 的效果接近全量 AOT但包体积增量远小于全量编译。这就是它的价值所在——以最小的代价获得最大的收益。四、实战案例定位 ContentProvider 和 Multidex 耗时理论讲完了来看两个真实场景。这两个问题在我经手的项目中出现过多次几乎是启动优化的经典题库。4.1 ContentProvider启动链路上的隐形炸弹先回顾一下上一篇的知识在bindApplication阶段系统会先安装所有在 Manifest 中声明的 ContentProvider然后才调用Application.onCreate()。这意味着 ContentProvider 的onCreate()比你的 Application 代码还早执行。问题在于很多第三方 SDK 用 ContentProvider 做自动初始化利用 Manifest merge 机制你引入依赖就自动注册了 ContentProvider完全无感知。Firebase、WorkManager、LeakCanarydebug 模式、各种广告 SDK 都这么干。怎么发现这个问题先用一条命令看看你的 APK 里到底有多少个 ContentProvider# 查看合并后的 Manifest 中的 ContentProvider aapt2 dump xmltree app-release.apk --file AndroidManifest.xml \ | grep -A2 provider # 或者更直接反编译看 apkanalyzer manifest print app-release.apk \ | grep 然后在 Perfetto trace 中查看bindApplicationslice 的子 slice你会看到每个 ContentProvider 的onCreate()都是一个独立的 slice。或者用 SQLSELECT s.name, s.dur / 1e6 as dur_ms FROM slice s JOIN thread_track tt ON s.track_id tt.id JOIN thread t ON tt.utid t.utid JOIN process p ON t.upid p.upid WHERE p.name com.example.myapp AND t.is_main_thread 1 AND s.name LIKE %ContentProvider% ORDER BY s.dur DESC;解决方案是用App Startup Library替代这些自动注册的 ContentProvider// 1. 在 Manifest 中禁用自动初始化 // 2. 手动控制初始化时机 class MyApp : Application() { override fun onCreate() { super.onCreate() // 延迟到首帧之后再初始化 WorkManager Handler(Looper.getMainLooper()).post { WorkManager.initialize(this, workManagerConfig) } } }在一个实际项目中我们发现 App 有 14 个 ContentProvider其中 11 个来自第三方 SDK它们的onCreate()总耗时超过 400ms。通过移除不必要的 Provider 并延迟初始化这部分耗时降到了 60ms 以内。4.2 Multidex 与 ClassLoader低版本设备的启动噩梦如果你的minSdk 21ClassLoader 在首次加载大量类时的耗时也值得关注。在 Perfetto trace 中dalvik类别的泳道会显示类加载和验证class verification事件。如果你看到大量的VerifyClassslice说明很多类在启动时被首次加载并验证。Baseline Profile 可以缓解这个问题它会提前编译热点方法间接减少类验证的开销但更根本的优化方向是减少启动路径上的类加载数量• 审计启动链路中 import 的类移除不必要的依赖• 将非启动必需的功能模块做成动态加载Dynamic Feature Module 或手动 ClassLoader• 使用 R8 的 startup profile 配置让 R8 在编译时优化启动路径上的代码布局五、把工具链串起来一个完整的诊断流程最后总结一下这三个工具组合使用的标准流程。每次做启动优化我建议按这个顺序走Step 1建立基线用 Macrobenchmark 跑一组启动测试记录当前的 TTID / TTFD 中位数。这是你的基线数据后续所有优化效果都和它对比。Step 2Perfetto 定位瓶颈打开 Macrobenchmark 生成的 Perfetto trace或者手动抓一个用 SQL 找出主线程上 Top 10 耗时 slice。通常前 3 个就占了 80% 的问题。Step 3埋点精准归因对可疑的代码区域加Trace.beginSection()埋点再抓一次 trace 确认精确耗时。在 Macrobenchmark 中用TraceSectionMetric拿到统计数据。Step 4实施优化根据瓶颈类型选择优化策略异步化下一篇讲、延迟加载、移除不必要的初始化、Baseline Profile 等。Step 5验证效果再跑一遍 Macrobenchmark对比基线数据。关注的不只是中位数还有 P90 和 max——优化有时候会降低平均耗时但增加方差这不是好的优化。Step 6CI 集成把 Macrobenchmark 接入 CI每次 PR 自动跑启动测试。如果 TTID 超过阈值就阻断合入。这是防劣化的第一道防线第五篇会展开讲。六、几个容易踩的坑最后列几个我踩过的坑帮你省点时间。坑1用 Debug 包跑 BenchmarkDebug 包默认开启了debuggabletrue这会禁用 JIT 和 AOT 优化启动时间可能是 Release 包的 2-3 倍。你基于 Debug 包的测量结果做优化方向可能完全错误。Macrobenchmark 在检测到 debuggable 包时会直接报错但手动抓 Perfetto trace 时要自己注意。坑2不控制编译状态Android 的 ART 运行时会根据使用情况动态编译代码Profile Guided Compilation。如果你先手动打开过 App 几次再跑 benchmark结果会比首次安装后快很多——因为系统已经根据 usage profile 做了 AOT 编译。Macrobenchmark 的CompilationMode参数就是解决这个问题的但手动测试时容易忽略。坑3设备温度影响手机跑 benchmark 时间长了会发热触发降频。同一组测试的后几次迭代可能比前几次慢 20%-30%。解决办法每组测试之间加冷却间隔或者用恒温测试环境如果你们公司有性能实验室的话。Macrobenchmark 的setupBlock中可以加Thread.sleep()来缓解。坑4忽略 TTFD很多人只看 TTID 不看 TTFD。TTID 只是系统认为首帧画完了但如果你的首页有异步加载的内容几乎所有 App 都有用户看到的可能是一个骨架屏或 loading 状态。真正的用户体验取决于 TTFD内容完全可见的时间。记得在你的首页内容加载完成后调用reportFullyDrawn()。总结启动优化的核心不是技巧是方法论先量化再定位再优化再验证。Perfetto、Macrobenchmark、Baseline Profile 这三个工具组成了一套完整的诊断和优化工具链。掌握了这套工具链你就不再是凭感觉优化而是用数据说话。下一篇我们进入实战优化环节异步初始化框架设计——用拓扑排序干掉启动串行瓶颈。今天用 Perfetto 和 Macrobenchmark 找到的那些慢 SDK 初始化下一篇会教你怎么把它们从主线程赶走并且优雅地处理它们之间的依赖关系。