启动速度是用户对 App 的第一印象,也是最难事后补救的性能指标:一旦冷启动超过两秒,日活与留存就会肉眼可见地下滑。更麻烦的是,启动阶段的代码路径横跨系统进程、Application、ContentProvider、Activity 与首帧渲染,任何一处同步 IO 或反射都会直接体现为白屏时间。
本文按「先测量、再优化、最后防回归」的顺序展开:先讲启动流程与三种启动状态,再讲如何建立可复现的基线,然后逐项拆解初始化、代码体积、布局与内存层面的优化手段。
一、启动流程:从 zygote 到首帧
Android 应用进程是被系统孵化出来的,理解这条链路才能定位耗时到底花在哪一段。
冷启动完整链路:
1. 点击图标 → Launcher 通过 Binder 请求 AMS(system_server 进程)
2. AMS 检查进程 → 不存在则请求 zygote fork 出新进程
3. 新进程加载 Application 类 → 调用 Application.attachBaseContext
4. 调用 Application.onCreate → 第三方 SDK、ContentProvider 在此初始化
5. AMS 通过 Binder 通知进程启动 Activity
6. ActivityThread.performLaunchActivity → onCreate / onStart / onResume
7. ViewRootImpl 触发 measure / layout / draw → SurfaceFlinger 合成首帧
关键点是第 4 步:ContentProvider 的 onCreate 早于 Application.onCreate。很多 SDK(Firebase、WorkManager、LeakCanary)通过 ContentProvider 自动初始化,这就是为什么你明明没在 Application 里写初始化代码,启动却依然慢。
| 阶段 | 归属进程 | 典型耗时来源 |
|---|---|---|
| fork 进程与类加载 | 系统 + 应用 | 类数量、MultiDex、verify |
Application 创建 | 应用 | attachBaseContext 中的同步操作 |
| ContentProvider 初始化 | 应用 | 第三方 SDK 自动初始化 |
Application.onCreate | 应用 | SDK init、全局单例构建 |
| Activity 创建与首帧 | 应用 | 布局层级、主题、Compose 组合 |
一句话总结: 启动耗时的三个大头是进程与类加载、ContentProvider 自动初始化、
Application.onCreate里的同步工作;不先把这条链路画出来,优化就只能是盲猜。
二、冷启动、温启动与热启动
系统按进程与 Activity 的存活状态区分三种启动,它们的优化空间完全不同。
| 类型 | 进程状态 | Activity 状态 | 典型耗时 | 优化空间 |
|---|---|---|---|---|
| 冷启动 | 不存在,需新建 | 不存在,需重建 | 数百毫秒到数秒 | 最大,是优化主战场 |
| 温启动 | 已存在 | 被回收,需重建 | 数十到数百毫秒 | 中等,重点在 Activity 与布局 |
| 热启动 | 已存在 | 仍在栈中 | 几十毫秒 | 很小,几乎无需优化 |
判断方法很简单:进程号变化说明是冷启动(Application.onCreate 会重新执行),进程号不变但 Activity 重建是温启动,进程号不变且直接 onRestart 回前台是热启动。实践上只盯冷启动指标,因为它最差、最能反映真实问题;温启动顺带改善即可,为热启动引入复杂度几乎不划算。
一句话总结: 冷启动是唯一值得投入的优化对象;温启动顺带改善即可,热启动几乎没有优化余地。
三、量化指标:am start -W 与 Displayed
优化前必须先能复现、能测量。Android 提供两条最常用的测量路径。
adb shell am start -W -n com.example.app/.MainActivity # TotalTime 812 / WaitTime 845 / ThisTime 812
adb logcat -s ActivityManager | grep "Displayed" # Displayed com.example.app/.MainActivity: +812ms
Displayed 统计的是从进程启动到首帧上屏的时间,是官方推荐的核心指标。am start -W 输出的 TotalTime 含进程创建,ThisTime 只算最后一个 Activity,适合快速对比改动前后。
还要区分两个概念:TTID(Time to Initial Display) 是首帧出现、用户看到界面骨架;TTFD(Time to Full Display) 是内容真正可用。后者需要主动上报:
// 在首屏数据真正渲染完成后调用,上报可交互时间
override fun onResume() {
super.onResume()
lifecycleScope.launch {
viewModel.firstScreenData.first { it != null }
reportFullyDrawn() // API 19+,系统日志会记录 Fully drawn
}
}
| 指标 | 含义 | 采集方式 | 目标 |
|---|---|---|---|
TotalTime | 本次启动总耗时(含进程创建) | am start -W | 参考值 |
Displayed | 进程启动到首帧上屏 | logcat | 冷启动 < 1000ms |
Fully drawn | 到内容可交互 | reportFullyDrawn() | 按业务定义 |
一句话总结:
Displayed是官方核心指标,am start -W用于快速对比改动前后的TotalTime;如果首屏是异步加载,务必用reportFullyDrawn()补上真正可用的终点。
四、Application 与 ContentProvider 的初始化开销
Application.onCreate 与 ContentProvider 的初始化都在主线程、且在首帧之前,是冷启动最大的可控项。
// 反面示例:所有初始化都堆在 onCreate 主线程
class App : Application() {
override fun onCreate() {
super.onCreate()
initCrashReporter() // 读文件 + 网络
initAnalytics() // 读配置 + 反射
initImageLoader() // 建线程池 + 读磁盘缓存目录
initDatabase() // 打开 SQLite,最慢的一项
initPush() // 初始化推送 SDK
}
}
// 正面示例:按「首帧是否需要」分类,只同步首帧必需项
override fun onCreate() {
super.onCreate()
initCrashReporter() // 首帧必需且极快(< 5ms)
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) {
initImageLoader(); initDatabase(); initPush() // 首帧不需要的全部后台化
}
}
| 初始化项 | 首帧是否必需 | 建议时机 |
|---|---|---|
| Crash 上报 | 是(越早越好) | Application.onCreate 同步 |
| 图片加载库 | 否 | 首页 onResume 后或懒加载 |
| 数据库 | 否 | 首次访问时懒初始化 |
| 埋点 SDK | 否 | 首帧后后台线程 |
| 推送 SDK | 否 | 首页可见后延迟数秒 |
排查 ContentProvider 自动初始化,先看合并后的 manifest:执行 ./gradlew :app:processDebugManifest,在 build/intermediates/merged_manifest/ 下搜索 <provider> 标签。常见偷偷初始化的库包括 androidx.startup 的 InitializationProvider、androidx.work 的 WorkManagerInitializer、LeakCanary 的 LeakCanaryFileProvider,确认无用后用 tools:node="remove" 在 manifest 中移除对应节点。
一句话总结: 初始化的判断标准只有一条——首帧渲染需不需要它;不需要的一律挪到后台线程或懒加载,同时用合并 manifest 排查第三方 SDK 的 ContentProvider 偷偷初始化。
五、App Startup 库延迟初始化
手写「后台线程 + 懒加载」容易写出竞态,androidx.startup:startup-runtime:1.1.1 提供了统一的初始化框架,还支持依赖排序。
// 定义一个 Initializer,并声明它依赖的其他 Initializer
class AnalyticsInitializer : Initializer<Analytics> {
override fun create(context: Context): Analytics {
val analytics = Analytics.create(context)
AnalyticsHolder.instance = analytics
return analytics
}
// 声明依赖:WorkManagerInitializer 必须先于本项完成
override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(WorkManagerInitializer::class.java)
}
注册时在 AndroidManifest.xml 中为 InitializationProvider 添加 meta-data,android:name 指向你的 Initializer 类、android:value 固定为 androidx.startup,构建工具会自动生成 provider 节点。延迟初始化只需对目标 Initializer 加上 tools:node="remove",再在合适时机手动调用 AppInitializer.getInstance(context).initializeComponent(AnalyticsInitializer::class.java)。
| 方案 | 控制力 | 依赖排序 | 适用场景 |
|---|---|---|---|
| 手写后台线程 | 高 | 需自己实现 | 少量简单初始化 |
App Startup | 中 | 内建 dependencies() | 多项初始化、需明确顺序 |
| 完全懒加载 | 最高 | 无需 | 首次使用时才需要的组件 |
一句话总结: App Startup 的价值是把「什么时候初始化」和「谁先谁后」变成声明式配置;它不替你省时间,但让延迟初始化的时机可控、顺序可依赖、代码可测试。
六、Baseline Profile 与 Macrobenchmark
Android 5.0 起应用在安装时做 AOT 编译,但 Android 7.0 之后改为「安装时只做部分编译,其余靠 JIT」。Baseline Profile(基线配置文件)就是把启动关键路径上的方法提前 AOT 编译,官方数据可让冷启动提升 20%~30%。
// baselineprofile 模块的 build.gradle.kts
plugins {
alias(libs.plugins.android.test) // com.android.test
alias(libs.plugins.baselineprofile) // androidx.baselineprofile
}
android {
namespace = "com.example.baselineprofile"
compileSdk = 35
defaultConfig { minSdk = 28; targetSdk = 35 }
targetProjectPath = ":app"
}
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.3.4")
}
// 生成 Baseline Profile:用 BaselineProfileRule 模拟真实启动路径
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.app") {
pressHome()
startActivityAndWait() // 启动主界面并等待首帧
device.findObject(By.text("详情")).click() // 覆盖更多关键页面
device.waitForIdle()
}
}
生成命令为 ./gradlew :baselineprofile:generateBaselineProfile,产物 baseline-prof.txt 放入 app/src/main/。接下来用 Macrobenchmark 在真机上测量启动耗时:
// Macrobenchmark:输出 TTID 与 TTFD,用于防回归
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10, // 至少 10 次取中位数
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(),
) {
pressHome()
startActivityAndWait()
}
}
| 概念 | 作用 | 依赖 |
|---|---|---|
| Baseline Profile | 安装时预编译关键方法,减少 JIT | androidx.baselineprofile 1.3.x |
| Startup Profile | 只覆盖启动路径,文件更小 | AGP 内置 |
| Macrobenchmark | 真机测量 TTID/TTFD 与帧率 | benchmark-macro-junit4 1.3.x |
CompilationMode | 控制测量时的编译状态 | None / Partial / Full |
一句话总结: Baseline Profile 解决「装完第一次启动慢」,Macrobenchmark 解决「优化有没有效果、有没有回归」;两者必须成对使用,否则既不知道优化了什么,也守不住成果。
七、R8 优化
R8 负责压缩、混淆与优化三段工作,启动相关的收益主要来自类与方法数量的减少以及内联。
android {
buildTypes {
release {
isMinifyEnabled = true // 开启 R8 代码压缩与混淆
isShrinkResources = true // 移除未引用的资源
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
-repackageclasses 'com.example.app.internal' # 类重打包到同一包,减少 dex 中的包名重复
-allowaccessmodification # 放宽访问修饰符,提升内联与优化空间
| 开关 | 作用 | 代价 |
|---|---|---|
-repackageclasses | 所有类归入同一包,提升加载效率 | 栈轨迹中的包名失真,需 mapping 还原 |
-allowaccessmodification | 放宽可见性,让 R8 能内联跨类调用 | 反射访问可能失效,需 keep 规则兜底 |
isShrinkResources | 移除未引用资源,减小包体 | 动态引用资源需 keep |
proguard-android-optimize.txt | 官方优化规则集 | 无 |
R8 与启动的关系可以概括为:类越少,类加载与验证越快,进程启动阶段耗时下降;方法越少,dex 更小、映射更快,首帧前的类解析更快;内联越多,调用链越短。注意 R8 的收益在未做 Baseline Profile 时更明显,两者叠加时需重新测量,避免重复优化同一段路径。
一句话总结: R8 对启动的贡献是让要加载的类和方法变少、调用链变短;
-repackageclasses与-allowaccessmodification是收益最高的两个开关,代价是必须保留 mapping 文件用于崩溃还原。
八、Startup Tracing 与 Perfetto
测量到「启动 812ms」之后,下一步是知道这 812ms 花在了哪几段代码上。
// 用 Trace 埋点标记启动关键区段
class App : Application() {
override fun onCreate() {
super.onCreate()
Trace.beginSection("App_onCreate")
initCrashReporter()
Trace.endSection()
Trace.beginSection("initImageLoader")
initImageLoader()
Trace.endSection()
}
}
adb shell perfetto -o /data/misc/perfetto-traces/trace -t 10s \
sched freq idle am wm gfx view binder_driver hal dalvik input res memory # 抓系统级 trace
adb pull /data/misc/perfetto-traces/trace ./startup.pftrace # 拉到本地
Perfetto 里的分析路径是固定的:先找到进程创建时间点,再看 Application.onCreate 对应你埋的 Trace 名称区段,然后看 bindApplication 到 activityStart 之间的空隙,最后看首帧对应的 Choreographer#doFrame 与 SurfaceFlinger 合成,检查主线程是否有超过 16ms 的长任务或 IO 等待。老教程里的 systrace.py 已并入 Perfetto,不再单独维护。
| 工具 | 层级 | 用途 |
|---|---|---|
Trace.beginSection | 应用代码 | 标记自定义区段 |
| Perfetto | 系统 | 系统调用、调度、渲染全链路 |
am start -W | 命令行 | 快速对比总耗时 |
| Macrobenchmark | 真机测试 | 防回归的自动化基线 |
| JankStats | 应用运行时 | 线上帧卡顿监控 |
一句话总结:
am start -W告诉你慢了多少,Perfetto 告诉你慢在哪一段;先用自定义Trace区段把应用代码切成可识别的块,再在 Perfetto 里对着时间线找长任务。
九、布局与过度绘制
首帧耗时里,measure / layout / draw 是与代码体积无关、纯 UI 结构的开销。
<!-- 反面示例:四层嵌套 LinearLayout,measure 次数随深度增长 -->
<LinearLayout android:orientation="vertical">
<LinearLayout android:orientation="horizontal">
<LinearLayout android:orientation="vertical"><TextView /><TextView /></LinearLayout>
<ImageView />
</LinearLayout>
</LinearLayout>
<!-- 正面示例:改用 ConstraintLayout,把四层压成一层 -->
消除冷启动白屏最省事的手段是给启动主题设置 windowBackground 与 postSplashScreenTheme,让系统先画一张品牌色占位图,等 Activity 首帧就绪后再切换到正常主题。这与前文提到的「首帧只做最少的事」是同一个思路:先给用户看到东西,再让内容到位。
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/brand</item>
<item name="postSplashScreenTheme">@style/Theme.App</item>
</style>
| 问题 | 现象 | 对策 |
|---|---|---|
| 层级过深 | measure 次数随深度增长 | 扁平化,优先 ConstraintLayout |
| 过度绘制 | 同一像素被绘制多次 | 移除多余背景,开启 GPU 过度绘制调试 |
| 启动期主题切换 | 冷启动先显示白屏再切换主题 | 用 windowBackground 主题占位 |
| 布局中加载图片 | 首帧被 IO 阻塞 | 用占位图,图片异步加载 |
ViewStub 缺失 | 首屏不用的区块也参与测量 | 非首屏内容用 ViewStub 或条件组合 |
一句话总结: 布局层面的启动优化是减少测量次数、减少绘制层数、消除白屏三件事;层级扁平化的收益是确定性的,而
windowBackground占位是消除白屏感成本最低的手段。
十、内存与 Compose 首帧
启动期的内存问题(频繁 GC、大对象分配)会以卡顿的形式表现出来,而 Compose 的首帧又比 View 体系多一层组合开销。
// Compose 首帧优化:把非首屏可见的组合延迟到首帧之后
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
if (state == null) {
HomeSkeleton() // 首帧只渲染骨架,避免首帧等待数据
return
}
HomeContent(state!!)
}
相关依赖:debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14") 只在 debug 构建引入内存泄漏检测,implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") 提供 collectAsStateWithLifecycle。
| 手段 | 作用 | 注意 |
|---|---|---|
onTrimMemory 回调 | 系统内存紧张时释放缓存 | 区分 TRIM_MEMORY_UI_HIDDEN 与 RUNNING_CRITICAL |
| LeakCanary | 检测 Activity 泄漏 | 仅 debug 依赖,避免 release 引入 |
derivedStateOf | 缩小重组范围 | 只在真正需要派生时使用 |
collectAsStateWithLifecycle | 后台停止收集,省电省内存 | 需 lifecycle-runtime-compose |
| 避免首帧大对象 | 减少 GC 次数 | 大数据集分页加载 |
Compose 的重组机制与状态读取位置直接决定了首帧与滚动时的开销,这部分细节可以进一步参考 Compose 状态与重组机制 ;而启动期的内存分配与 GC 行为,可以用 JFR 与 JMC 性能分析 中介绍的采样思路做交叉验证。
一句话总结: 内存与 Compose 首帧优化的共同点是首帧只做最少的事——骨架先行、数据后到、状态收敛,配合 LeakCanary 守住泄漏底线。
十一、常见坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 在主线程读 SharedPreferences | commit() 同步写盘阻塞首帧 | 改用 apply(),或迁 DataStore |
| 主线程 IO | 启动白屏时间随机波动 | 全部挪到 Dispatchers.IO |
| 忽略 ContentProvider 初始化 | 没写代码却依然慢 | 查合并 manifest,tools:node="remove" |
| Baseline Profile 未随版本更新 | 优化效果随迭代衰减 | CI 中重新生成并提交 |
| 只测一次就下结论 | 数据噪声大,误判优化效果 | Macrobenchmark 跑 10 次以上取中位数 |
| R8 未保留 mapping | 崩溃栈无法还原 | 上传 mapping 到崩溃平台 |
| 为启动优化牺牲功能 | 首屏数据缺失、体验下降 | 区分「延迟」与「删除」 |
| 温/热启动一并强优化 | 复杂度上升、收益极小 | 只针对冷启动优化 |
一句话总结: 启动优化最常见的失败不是优化手段不对,而是没有基线、没有回归检测、把延迟当删除;先建立可复现的测量,再谈优化。
小结
| 主题 | 关键结论 |
|---|---|
| 启动链路 | 进程创建 → Application → ContentProvider → Activity → 首帧 |
| 启动类型 | 冷启动是唯一值得优化的对象 |
| 核心指标 | Displayed 为首,reportFullyDrawn 补可交互时间 |
| 初始化 | 只保留首帧必需项,其余后台或懒加载 |
| App Startup | 声明式控制初始化时机与依赖顺序 |
| Baseline Profile | 解决首次启动慢,需随版本更新 |
| Macrobenchmark | 真机防回归,迭代 10 次以上 |
| R8 与追踪 | 类更少调用链更短,Perfetto 定位到区段 |
| 布局与内存 | 扁平化、消白屏、骨架先行、状态收敛 |
一句话记住:启动优化是一条「测量 → 定位 → 优化 → 防回归」的闭环,缺了任何一环都会退化成一次性调优。先让 Displayed 可见,再用 Perfetto 找到长任务,用 App Startup 与 Baseline Profile 把首帧路径压到最短,最后用 Macrobenchmark 把它钉在 CI 里。当工程规模继续变大,模块拆分与构建方式的调整会直接影响类加载与启动耗时,这部分可参考 Android 多模块架构与 Gradle 优化
。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。