Android 性能优化与启动加速

启动速度直接影响用户留存,而冷启动耗时往往被初始化代码悄悄吃掉。本文聚焦启动加速与性能优化:冷启动全链路拆解、启动库延迟初始化、基准配置文件与宏基准测试的落地写法、代码压缩优化与启动追踪,以及内存泄漏、过度绘制与首帧优化。文中给出可量化的指标与测量命令,帮你把启动耗时压到目标线以内并守住不回归。

启动速度是用户对 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安装时预编译关键方法,减少 JITandroidx.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 守住泄漏底线。


十一、常见坑清单

坑现象对策
在主线程读 SharedPreferencescommit() 同步写盘阻塞首帧改用 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 优化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试