Android应用冷启动优化:从测量方法到分层实践

0 阅读21分钟

前言

优化Android 应用启动速度是性能优化中重要的一环,当启动速度过慢时,用户可能不愿意等待,从而直接关闭应用。启动速度这不仅影响应用日活,还会影响应用的留存率。

注意:本文出现的源码基于Android - 15.0.0_r1。另外本文关注主要逻辑,省略部分代码。

本文大纲

image.png

一、启动类型

image.png

Android App启动种类共3种:冷启动,温启动,热启动。
接下来,我们简要介绍这三种启动类型。

1.1 冷启动

  • 条件:应用进程完全不存在(被系统杀死或首次启动)
  • 过程:创建进程 → 初始化应用 → 创建Activity → 渲染界面
  • 特点:耗时最长,涉及完整启动链,是性能优化的主要目标

1.2 温启动

  • 条件:应用进程存在,但Activity被销毁(如系统内存回收)
  • 过程:进程已存在 → 重新创建Activity → 渲染界面
  • 特点:耗时中等,跳过部分初始化,但仍需重建Activity

1.3 热启动

  • 条件:应用进程和Activity都在内存中(如按Home键返回)
  • 过程:直接将Activity从后台带到前台
  • 特点:耗时最短,近乎瞬间,主要涉及界面切换

二、测量指标

要想知道启动速度优化的效果,我们就得知道优化前后冷启动时间,因此接下来看看测量指标。

启动时间分为2种: TTID 和 TTFD。

2.1 初始显示时间 (TTID)

初始显示所用时间 (TTID) 是指显示应用界面的第一帧所用的时间。此指标用于测量应用生成第一帧所用的时间,包括冷启动期间的进程初始化、冷启动或温启动期间的 activity 创建,以及显示第一帧。包括以下事件序列的总经过时间:

  • 启动进程。
  • 初始化对象。
  • 创建并初始化 activity。
  • 膨胀布局。
  • 首次绘制应用。

2.2 完全显示时间 (TTFD)

完全显示时间 (TTFD) 是指应用达到可与用户互动的状态所用的时间。系统会报告显示应用界面的第一帧以及在显示第一帧后异步加载的内容所需的时间。一般情况下,这是从网络或磁盘加载的主要内容(由应用报告)。换句话说,TTFD 包括 TTID 以及应用可供使用所需的时间。

2.3 小结

TTID : 显示应用界面的第一帧所用的时间
TTFD: 应用达到可与用户互动的状态所用的时间 (TTID + 应用可供使用所需的时间)

三、测量方法

看完了测量指标,我们再看看有哪些测量启动时间的方法。

3.1 基础工具:AS日志与ADB命令

3.1.1 Android Studio Logcat命令查看

Android Studio中查看日志,过滤 tag:ActivityTaskManager Displayed image.png

注:这个日志是显式的初始显示所用时间 (TTID)

3.1.2 adb shell am start -W命令

adb shell am start -W 包名/首屏Activity
比如:adb shell am start -W com.xxx.xxx/com.xxx.xxx.MainActivity image.png

  • ThisTime::最后一个Activity启动耗时
  • TotalTime:所有Acitivity启动耗时
  • WaitTime:AMS启动Activity的总耗时

注:ThisTime <= TotalTime < WaitTime

3.2 官方方案:Jetpack Macrobenchmark

谷歌官方的 Jetpack Macrobenchmark 可以用来测量启动时间, 它的使用也是很方便的: New -> New Module -> 选择Benchmark -> 点击Finish即可 image.png

如下是定义一个测试次数为5次的冷启动测试

@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startup() = benchmarkRule.measureRepeated(
        // 被测应用的包名
        packageName = "com.xxx.xxx",
        // 基准测试中提取的主要信息类型
        metrics = listOf(StartupTimingMetric()),
        // 设置测试次数
        iterations = 5,
        // 设置启动模式为
        startupMode = StartupMode.COLD
    ) {
        pressHome()
        startActivityAndWait()
    }
}

如果要测量TTFD,记得在应用页面(不是在Macrobenchmark模块)中调用 ComponentActivity#reportFullyDrawn 方法

测试完成后,会展示简略的测试结果,显示TTID TTFD image.png

而在benchmark/build/outputs/connected_android_test_additional_output/benchmark/connected/设备名 下面会生成测试报告 image.png

打开包名-benchmarkData.json可以看到此次测试的设备信息,每次测试的时间、 最长/短启动时间、P50

{
    // 设备信息
    ... 
    "benchmarks": [
        {
            "name": "startup",
            "params": {},
            "className": "com.xxx.xxx",
            "totalRunTimeNs": 58688817257,
            "metrics": {
                "timeToInitialDisplayMs": {
                    "minimum": 547.887593,
                    "maximum": 641.342603,
                    "median": 583.550673, // P50
                    "coefficientOfVariation": 0.05969737243251711,
                    "runs": [
                        641.342603,
                        612.770907,
                        580.224135,
                        583.550673,
                        547.887593
                    ]
                }
            },
            ...
        }
    ]
}

生成的.perfetto-trace,我们可以在 ui.perfetto.dev/ 打开它,截图如下: image.png

当然 Android Studio 也是可以打开(open->选择要打开的文件)这种类型的文件,截图如下: image.png

Macrobenchmark官方文档: Jetpack Macrobenchmark; Macrobenchmark官方Demo: github.com/android/per…

3.3 自动化测试脚本

3.3.1 开发原因

AS日志与ADB命令测试方便但只能测试一次,多次测试需要重复操作,且无法统计P50, P90等信息,也没有当前设备信息。

而官方的Macrobenchmark呢,笔者发现国内设备(测试华为Y9, iqoo13)上,使用Macrobenchmark无法测试。想使用一种更通用的方式,编写脚本不失为一种好的方式,脚本测试方便且通用。因脚本内容较长,故放在了github上。详见AndroidStartupOptimizationDemo

注:此脚本参考了马哥的脚本,详见 App冷启动优化测试干货分享(一)。笔者做了以下改动:1.合并了app_launch_benchmark.sh 和 app_config.cfg。2.添加了P50, P90, P95和设备信息(如Android系统版本,内存信息等);3.生成start_up_reports文件夹,将启动报告统一放在了此文件夹下;4.多次测试会卡住,添加adb server 重启策略和超时处理

3.3.2 使用方式

  1. 复制脚本到项目根目录下
  2. 修改包名和MainActivity为当前应用
# ============================================
# 内置默认配置(原 app_config.cfg)
# ============================================
DEFAULT_CONFIG_CONTENT=$(cat << 'EOF'
# App启动时间测试配置文件
# 格式:包名|Activity类名|显示名称|启动类型|备注

# 系统应用 - 冷启动测试
# com.android.settings|com.android.settings.Settings|系统设置|cold|冷启动测试
# com.android.dialer|com.android.dialer.main.impl.MainActivity|拨号器|cold|冷启动测试

# 第三方应用
# com.tencent.mm|com.tencent.mm.ui.LauncherUI|微信|cold|社交应用冷启动
# com.taobao.taobao|com.taobao.tao.homepage.MainActivity3|淘宝|warm|电商应用温启动
# 此处添加当前应用信息
  1. git命令行执行当前脚本
# ============================================
# 使用方式
# ============================================
# ./app_launch_benchmark.sh                     # 使用默认配置测试
# ./app_launch_benchmark.sh -n 5      # 测试5次
# ./app_launch_benchmark.sh -c xxx.cfg -n 5 # 自定义配置,测试5次

3.3.3 测试截图

image.png

四、优化方法

4.1 常规优化

4.1.1 收敛ContentProviders

原理

在应用启动流程中,会安装ContentProvider。时序图如下:

image.png

ActivityThread#installContentProviders的代码如下:

    private void handleBindApplication(AppBindData data) {
        ...
        
        // 安装ContentProvider
        installContentProviders(app, data.providers);
        ...
    }

在应用启动的 ActivityThread#handleBindApplication 阶段,系统会调用 installContentProviders。如果 Manifest 中声明了多个 Provider(如 Firebase、Facebook SDK 各自带一个),系统会串行初始化它们,这会占用主线程几十到上百毫秒。

优化方案

使用 Jetpack Startup 将多个 Provider 合并为一个初始化入口

首先要确定当前应用有哪些ContentProvider,使用 AS 打开 Apk/aab里面的Manifest

<?xml version="1.0" encoding="utf-8"?>
<manifest
    <application
        ...
        <provider
            android:name="com.facebook.internal.FacebookInitProvider"
            android:exported="false"
            android:authorities="com.xxx.xxx.FacebookInitProvider" />
        <provider
            android:name="com.google.mlkit.common.internal.MlKitInitProvider"
            android:exported="false"
            android:authorities="com.xxx.xxx.mlkitinitprovider"
            android:initOrder="99" />
        <provider
            android:name="com.google.firebase.provider.FirebaseInitProvider"
            android:exported="false"
            android:authorities="com.xxx.xxx.firebaseinitprovider"
            android:initOrder="100"
            android:directBootAware="true" />
         ...
    </application>
</manifest>

比如当前示例App的Manifest中,未收敛时有3个ContentProvider,

官方提供了Jetpack StartUp, 我们可以用它来收敛ContentProvider,使用方法如下:
首先定义各个启动器

class FirebaseCombinedInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        // 初始化 FirebaseApp, 防止未 initialize 报错
        FirebaseApp.initializeApp(context)

        try {
            // 获取 Firebase Analytics 的 appInstanceId
            FirebaseAnalytics.getInstance(context).appInstanceId
                .addOnCompleteListener { task ->
                    task.result?.let { id ->
                        SpUtil.saveStringData(SpKeyConstants.FIREBASE_ID, id)
                    }
                }
        } catch (e: Exception) {
            Timber.tag("FirebaseInit").e(e, "获取 appInstanceId 失败")
        }
    }

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

class FacebookManualInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        // 使用协程在后台线程激活 App Events,避免阻塞
        CoroutineScope(Dispatchers.IO).launch {
            try {
                AppEventsLogger.activateApp(context as Application)
            } catch (e: Exception) {
                // 忽略初始化失败,避免影响启动
            }
        }
    }

    override fun dependencies(): List<Class<out Initializer<*>?>?> = emptyList()
}

class MlKitManualInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        MlKit.initialize(context)
    }

    override fun dependencies(): List<Class<out Initializer<*>?>?> = emptyList()
}

然后修改Manifest

<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false">
    <!-- Firebase 初始化,无依赖 -->
    <meta-data
        android:name="com.xxx.xxxr.FirebaseCombinedInitializer"
        android:value="androidx.startup" />
    <!-- ML Kit 初始化,无依赖 -->
    <meta-data
        android:name="com.xxx.xxx.MlKitManualInitializer"
        android:value="androidx.startup" />
    <!-- Facebook 初始化,无依赖 -->
    <meta-data
        android:name="com.xxx.xxx.FacebookManualInitializer"
        android:value="androidx.startup" />
</provider>
<provider
    android:name="com.google.firebase.provider.FirebaseInitProvider"
    android:authorities="${applicationId}.firebaseinitprovider"
    tools:node="remove" />
<provider
    android:name="com.facebook.internal.FacebookInitProvider"
    android:authorities="${applicationId}.FacebookInitProvider"
    tools:node="remove" />
<provider
    android:name="com.google.mlkit.common.internal.MlKitInitProvider"
    android:authorities="${applicationId}.mlkitinitprovider"
    tools:node="remove" />

到这里就结束了,实际操作其实不复杂。

验证

最后验证下是否生效了,我们打开收敛后Apk中的Mainfest文件

<manifest
    <application
        ...
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:exported="false"
            android:authorities="com.xxx.androidx-startup">

            <meta-data
                android:name="com.xxx.AppsFlyerInitializer"
                android:value="androidx.startup" />

            <meta-data
                android:name="com.xxx.initializer.FirebaseCombinedInitializer"
                android:value="androidx.startup" />

            <meta-data
                android:name="com.xxx.initializer.MlKitManualInitializer"
                android:value="androidx.startup" />

            <meta-data
                android:name="com.xxx.initializer.FacebookManualInitializer"
                android:value="androidx.startup" />

            ...
        </provider>
        ...
    </application>
</manifest>

可以看到,收敛后Apk的Manifest中,仅有1个ContentProvider

优化效果
时间类型未优化优化后减少幅度
P50465ms436ms-29ms (-6.24%)
P90476ms446ms-30ms (-6.30%)
P95480
451ms-29ms (-6.04%)

详细的测试数据请看AndroidStartupOptimizationDemo/start_up_reports at feature/content-provider

4.1.2 初始化优化与任务调度

时机优化

a.延迟/按需初始化

对于启动非必要的任务,我们可以延迟/按需它的初始化,减少初始化的任务,从而减少应用启动的时间。
比如上面的MlKitManualInitializer,它其实是非启动必需的任务,用户并不是每次使用应用,都需要用到人脸识别,因此我们可以在调用它之前的页面,去初始化它。还可以进一步根据后端返回字段,如果xx接口返回此次需要使用人脸识别,那么我们再初始化它。

b.异步初始化

对于启动必需但不需要同步的任务,我们可以通过异步初始化,这样就不会阻塞主线程,从而减少应用启动的时间。

c.首屏数据加载策略

对于启动后立即需要的首屏数据(如用户信息、配置列表),其加载也属于启动必需但无需阻塞主线程的任务。我们可以进一步优化:

  • 并行请求:将多个独立的数据请求并行发起,而非串行等待。
  • 高效解析:选用性能更优的序列化库(如替换默认的Gson为Kotlin serialization或Moshi),减少数据反序列化的CPU耗时。详见常用 JSON 库性能对比: Gson VS Moshi VS Kotlin Serialization VS ...
  • 缓存优先:对于不要求实时数据的首屏页面,可以先展示缓存页面,后进行网络更新,即优先展示本地缓存数据,让UI快速可交互,后台再更新数据。

这些策略虽不直接影响 TTID,但能显著缩短 TTFD,让用户更快地开始使用应用。

d.优化效果

在示例App中,使用收敛ContentProvider、异步初始化任务和延迟初始化后的优化前后对比如下:

时间类型未优化优化后减少幅度
P50465ms436ms-29ms (-6.23%)
P90476ms445ms-31ms (-6.51%)
P95480
449ms-31ms (-6.46%)

详细的测试数据请看AndroidStartupOptimizationDemo/start_up_reports at feature/init-optimization-strategy

编排初始化任务

在初始化任务时,应用可能会存在依赖的情况,如任务A的初始化完成后,任务B的初始化才能正常执行。我们先画出任务间依赖关系的有向图,先理清任务间的关系,后续使用官方的 StartUp 来编排任务。

举个例子,现有任务a,b,c,d,e,然后 c依赖a,d依赖b,然后 e依赖d,c。
先画出有向无环图,如下:

image.png

之后创建各个任务的启动器

class AInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        Log.d("AInitializer", "create: AInitializer")
    }

    // A无依赖
    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

class BInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        Log.d("BInitializer", "create: BInitializer")
    }

    // B无依赖
    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

class CInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        Log.d("CInitializer", "create: CInitializer")
    }

    // C依赖A
    override fun dependencies(): List<Class<out Initializer<*>>> =
        listOf(AInitializer::class.java)
}

class DInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        Log.d("DInitializer", "create: DInitializer")
    }

    // D依赖B
    override fun dependencies(): List<Class<out Initializer<*>>> =
        listOf(BInitializer::class.java)
}

class EInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        Log.d("EInitializer", "create: EInitializer")
    }

    // E依赖C,D
    override fun dependencies(): List<Class<out Initializer<*>>> =
        listOf(CInitializer::class.java, DInitializer::class.java)
}

后续在manifest中,声明E的启动器即可,StartUp 会自动解析依赖关系

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        ...
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">
            <meta-data
                android:name="com.xxx.xxx.initializer.EInitializer"
                android:value="androidx.startup" />
        </provider>
    </application>

</manifest>

后续运行代码,打印日志如下:
create: AInitializer
create: CInitializer
create: BInitializer
create: DInitializer
create: EInitializer

4.1.3 基准配置文件

基准配置文件是官方推荐使用的一种优化方式,下面先看看它的原理吧。

原理

我们可以通过一个生活中的例子来理解它的工作原理:第一次到朋友家,他让我们帮忙找东西。当没有使用基准配置文件时,就相当于我们不知道东西在哪里,那么只能每个房间、每个位置都翻找一遍;而使用基准配置文件后,就相当于朋友提前给了我们一份文件清单,上面清楚标明了每样东西的位置,我们按图索骥,就能快速找到目标。

这个文件清单,对应的就是基准配置文件(Profile-Guided Optimization,PGO)技术生成的热点代码清单(baseline-prof.txt)。

在技术层面,传统的AOT(提前)编译会试图编译所有代码,这往往导致应用安装缓慢且包体积增大。而PGO则反其道而行之:它通过在真实设备上运行应用,记录下实际使用时的关键执行路径(例如启动过程),从而生成一份针对性的“热点代码清单”。

ART运行时在两个时机使用这份清单:

  1. 安装时:对清单中的方法进行AOT编译
  2. 后台优化:设备空闲时,根据实际使用情况进一步优化

这样既减少了运行时解释/JIT开销,又避免了"全量编译"的弊端。

使用基准配置文件

image.png
在AS中选择 File -> New -> New Module

image.png 选择Baseline Profile Generator,点击Finish。

image.png
之后会生成一个名为 BaselineProfile 的新模块。

image.png 现在点击运行 Generate Baseline Profile for app,提示Error: Target module's selected variant is debuggable. Please use Build Variants tools window to change the variant of Dex.app。就是说需要使用 release 变体(非 debuggable 版本)。

这里就不介绍怎么生成 release 签名文件了,直接看 release 签名配置完成之后。

详细签名步骤,请读者自行查看官方文档:为应用签名

image.png
配置完签名,修改 App 模块的 Build Variant,再次点击图标,运行 Generate Baseline Profile for app

image.png
运行完成之后,我们会在 app/src/xxxRelease/generated 下面看到生成了 baseline-prof.text 和 startup-prof.text 文件

4.1.4 启动配置文件

在上一节中,我们通过基准配置文件知道了“东西”的位置。本小节我们来了解另一个关键的优化文件——启动配置文件,看看它如何在此基础上进行更深层的优化。

原理

我们可以继续用“找东西”的例子来理解它的工作原理:基准配置文件已经告诉我们东西在哪,但它们仍然分散在各个房间。启动配置文件则更进一步,相当于朋友提前把要用的所有东西打包进了几个背包,并把背包放在了门口。这样,你进门就能直接拿到所有必需品,省去了去各个房间的时间。

在技术上,启动配置文件的核心原理是 DEX布局优化,其根本目标是减少应用启动过程中的缺页中断,从而降低耗时的IO操作,最终显著缩短启动时间。

image.png 借用官网的一张图来说明:

  • 左侧:在apk中多个classes.dex,启动代码分散在各个classes.dex,且启动各个类非连续排列,此时就会有不必要的缺页中断,增加了多余的IO操作。
  • 右侧:使用DEX布局优化,将原本分散在各个classes.dex中的启动代码放在了主classes.dex,并且连续排列。减少了缺页中断,也就减少了IO操作。

也就是说通过优化DEX布局,使启动所需的类在dex中连续存储,然后就能顺序读取,减少磁盘寻道时间,并且减少缺页中断,也就减少了IO操作。我们知道IO操作是比较耗时的,当减少了它之后,启动时间自然也就减少了。

使用启动配置文件

使用方式详见4.1.3小节,基准配置文件和启动配置文件会一起生成。

验证启动配置文件

首先修改settings.gradle

pluginManagement {
    ...

    buildscript {
        repositories {
            mavenCentral()
            maven {
                url = uri("https://storage.googleapis.com/r8-releases/raw")
            }
        }
        dependencies {
            classpath("com.android.tools:r8:8.3.6-dev")
        }
    }
}

然后执行如下命令 ./gradlew assembleRelease --info

等编译完成后,查看编译日志,可搜索关键字 Startup DEX image.png

INFO: R8: Startup DEX files contains 3398 classes and 14427 methods of which 7249 (50%) are non-startup methods. Distribution of classes by their number of startup methods:
0: 737 classes
1: 1026 classes
2-3: 1183 classes
4-5: 245 classes
6-10: 139 classes
11+: 68 classes

这表明了编译时,DEX布局已经成功优化了。

优化效果

因为基准配置文件和启动配置文件是一起生成了,此处就不分开测试这2种文件了。

在同一设备上,测试同一应用,使用3.3小节的脚本,分别测试了200次未使用 和 使用这2种文件优化前后的冷启动时间,数据如下:

时间类型未优化优化后减少幅度
P50465ms428ms-37ms (-7.96%)
P90476ms438ms-38ms (-7.98%)
P95480
447ms-33ms (-6.88%)

详细的测试数据请看StudyH2g/AndroidStartupOptimizationDemo at feature/baseline-startup-profiles

4.2 激进优化

激进优化的效果依赖于设备硬件、系统版本及实时负载,不具备普遍性,并且有一定的兼容性风险与维护成本。建议读者若有意尝试,务必在深入理解其原理的基础上,在目标设备上建立完善的测试与监控机制,谨慎评估其实际收益。

注: 本节中提到的绑定CPU大核、抑制GC等实现思路与代码示例,主要参考了《Android性能优化之道:从底层原理到一线实践》一书中的方案。
想过这小节应该叫激进优化还是进阶优化,但考虑到此小节优化方法不具备普遍性,因此还是选择了激进优化。请读者一定要在充分验证后,再考虑上生产环境,并做好相应的兜底策略。

4.2.1 绑定CPU大核

原理部分就简单说下,读取 /sys/devices/system/cpu/cpu/cpufreq/cpuinfo_max_freq 文件来比较每个核心的最大频率,就可以拿到最高频率的 CPU 核心索引,然后通过 sched_setaffinity 将主线程绑定到 CPU 大核上。

我们就着重看实现部分

/**
 * 获取最高频率的 CPU 核心索引(大核)
 * 通过读取 /sys/devices/system/cpu/cpu/cpufreq/cpuinfo_max_freq 文件来比较每个核心的最大频率
 *
 * @param env JNI 环境
 * @param clazz Java 类对象
 * @return 最高频率核心的索引, 如果获取失败则返回 -1
 */
extern "C" JNIEXPORT jint JNICALL
Java_com_studyh2g_androidstartupoptimizationdemo_jni_JniHelper_getMaxFreqCpuIndex(
        JNIEnv* env,
        jclass clazz) {
    // 统计 CPU 核心数量
    int cores = 0;
    DIR *dir;
    struct dirent *ent;
    if((dir = opendir("/sys/devices/system/cpu")) != NULL) {
        while((ent = readdir(dir)) != NULL) {
            std::string path = ent->d_name;
            // 查找以 "cpu" 开头的目录
            if(path.find("cpu") == 0) {
                bool isCore = true;
                // 检查 "cpu" 后面是否全是数字(排除 cpufreq、cpuidle 等非核心目录)
                for(int i = 3; i < path.length(); i++) {
                    if(path[i] < '0' || path[i] > '9') {
                        isCore = false;
                        break;
                    }
                }
                if(isCore) {
                    cores++;
                }
            }
        }
        closedir(dir);
    }
    // 遍历所有核心,找出最高频率的核心
    int maxFreq = -1;
    int maxFreqCoreIndex = -1;
    if (cores != 0) {
        for (int i = 0; i < cores; i++) {
            // 读取每个核心的最大频率文件
            std::string filename = "/sys/devices/system/cpu/cpu" + std::to_string(i) + "/cpufreq/cpuinfo_max_freq";
            std::ifstream cpuInfoMaxFreqFile(filename);
            if (cpuInfoMaxFreqFile.is_open()) {
                std::string line;
                if (std::getline(cpuInfoMaxFreqFile, line)) {
                    try {
                        int freqBound = std::stoi(line);
                        if (freqBound > maxFreq) {
                            maxFreq = freqBound;
                            maxFreqCoreIndex = i;
                        }
                    } catch (const std::invalid_argument& e) {
                        // 频率值解析失败,跳过
                    }
                }
                cpuInfoMaxFreqFile.close();
            }
        }
    }
    return maxFreqCoreIndex;
}

/**
 * 将当前线程绑定到指定的 CPU 核心
 * 绑定后,当前线程只会在指定的核心上运行,可以利用大核提升性能
 *
 * @param env JNI 环境
 * @param clazz Java 类对象
 * @param coreNum 要绑定的 CPU 核心索引
 */
extern "C" JNIEXPORT void JNICALL
Java_com_studyh2g_androidstartupoptimizationdemo_jni_JniHelper_bindToCore(
        JNIEnv* env,
        jclass clazz,
        jint coreNum) {
    cpu_set_t mask;
    // 清空 CPU 集合
    CPU_ZERO(&mask);
    // 将指定核心加入集合
    CPU_SET(coreNum, &mask);
    // 获取当前线程 ID
    pid_t tid = gettid();
    // 设置线程的 CPU 亲和性(绑定到指定核心)
    if (sched_setaffinity(tid, sizeof(mask), &mask) == -1) {
        // 绑定失败
    }
}

/**
 * 获取当前线程绑定的 CPU 核心列表
 * 返回当前线程允许运行的所有 CPU 核心
 *
 * @param env JNI 环境
 * @param clazz Java 类对象
 * @return CPU 核心列表的字符串,如 "Current CPU affinity: 0 1 2 3 4 5 6 7 "
 */
extern "C" JNIEXPORT jstring JNICALL
Java_com_studyh2g_androidstartupoptimizationdemo_jni_JniHelper_getBoundCores(
        JNIEnv* env,
        jclass clazz) {
    cpu_set_t mask;
    // 获取当前线程 ID
    pid_t tid = gettid();
    // 获取当前线程绑定的 CPU 核心列表(失败则返回错误)
    if (sched_getaffinity(tid, sizeof(mask), &mask) == -1) {
        return env->NewStringUTF("Failed to get affinity");
    }
    std::string result = "Current CPU affinity: ";
    // 遍历所有可能的 CPU 核心
    for (int i = 0; i < CPU_SETSIZE; i++) {
        // 检查该核心是否在绑定集合中
        if (CPU_ISSET(i, &mask)) {
            result += std::to_string(i) + " ";
        }
    }
    return env->NewStringUTF(result.c_str());
}

然后在启动的Activity#attachBaseContext中调用方法就可以了

/**
 * 在应用启动的最早时机将主线程绑定到 CPU 大核
 *
 * attachBaseContext 是 Activity 生命周期中最早执行的回调,此时主线程还未开始执行繁重的初始化工作。
 * 将主线程绑定到大核可以充分利用大核的高性能,减少启动耗时。
 *
 * @param newBase 新的 Context 基对象
 */
override fun attachBaseContext(newBase: Context?) {
    super.attachBaseContext(newBase)
    // 打印绑定前的 CPU 核心列表(用于验证)
    Log.d(tag, "attachBaseContext before: ${JniHelper.getBoundCores()}")
    // 获取最高频率的 CPU 核心索引(大核)
    val maxFreqCpuIndex = JniHelper.getMaxFreqCpuIndex()
    if (maxFreqCpuIndex != -1) {
        // 将主线程绑定到大核
        JniHelper.bindToCore(maxFreqCpuIndex)
        // 打印绑定后的 CPU 核心列表(用于验证绑定是否成功)
        Log.d(tag, "attachBaseContext after: ${JniHelper.getBoundCores()}")
    }
}

日志如下:
image.png

读者可拉取 feature/aggressive-bind-cpu 分支代码运行看看StudyH2g/AndroidStartupOptimizationDemo at feature/aggressive-bind-cpu; 如对JNI接触较少,可参阅Android JNI入门:从基础注册到集成第三方So库及源码分析

现在主线程已经绑定到了 CPU 大核,但如果主线程一直绑定到 CPU 大核上,那么可能会导致功耗异常和系统调度问题,后续可以找一个恰当的时机解除绑定。

4.2.2 抑制GC

在启动期间抑制垃圾回收(GC)理论上可以减少因GC事件导致的线程停顿(Stop-The-World),从而优化TTID。这通常需要通过 Inline Hook(如使用 ShadowHook 库)拦截ART运行时中触发并发GC的关键函数(如ConcurrentGCTask::Run)来实现。

具体代码如下

static void* g_hook_stub = nullptr;

// Hook 函数
void newFunc(void* self) {
    SHADOWHOOK_STACK_SCOPE();
    // 休眠2秒
    sleep(2);
    // 执行原来的 Run 方法
    SHADOWHOOK_CALL_PREV(newFunc, self);
}

extern "C" JNIEXPORT jboolean JNICALL
Java_com_xxx_xxx_MainActivity_initGCHookNative(
        JNIEnv* env,
        jobject /* this */) {
    if (g_hook_stub != nullptr) {
        return JNI_TRUE;
    }

    // 开启调试日志
    shadowhook_set_debuggable(true);

    // 初始化 ShadowHook
    int ret = shadowhook_init(SHADOWHOOK_MODE_SHARED, true);
    if (ret != 0 && ret != 2) {
        LOGE("ShadowHook init failed: %d", ret);
        return JNI_FALSE;
    }

    LOGI("ShadowHook initialized successfully");

    // 使用 shadowhook_hook_sym_name
    g_hook_stub = shadowhook_hook_sym_name(
            "libart.so",
            "_ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE",
            reinterpret_cast<void*>(&newFunc),
            nullptr
    );

    if (g_hook_stub == nullptr) {
        int error_num = shadowhook_get_errno();
        const char* error_msg = shadowhook_to_errmsg(error_num);
        LOGE("Hook failed: %d - %s", error_num, error_msg);
        return JNI_FALSE;
    }

    int error_num = shadowhook_get_errno();
    if (error_num == 0) {
        LOGI("GC Hook successful!");
        return JNI_TRUE;
    } else if (error_num == 1) {
        LOGI("GC Hook pending (libart.so not loaded yet)");
        return JNI_TRUE;
    }

    return JNI_FALSE;
}

本小节代码请不要用于生产环境,是用于展示一种优化启动速度的方式,请读者一定要在充分测试的情况下,再考虑用抑制 GC 的方式。

ShadowHook详细使用方法请阅读 shadowhook 手册

4.2.3 提高核心线程优先级

Android 5开始,主线程负责测量和布局,渲染则由独立的渲染线程(RenderThread)负责。理论上,提高这两个线程的优先级可以让它们获得更多CPU时间,从而加速启动。

实现原理
  1. 主线程:在 attachBaseContext 中直接设置优先级
  2. 渲染线程:需要先获取其线程ID(TID),然后设置优先级
关键代码实现

1. 提高主线程优先级

override fun attachBaseContext(newBase: Context?) {
    super.attachBaseContext(newBase)
    Process.setThreadPriority(startupPriority)
}

2. 提高渲染线程优先级的两种方案

方案1:主动轮询(更早设置)

/**
 * 方案1:通过多次尝试主动查找 RenderThread 来设置优先级
 * 在 RenderThread 创建之前的任何时机都可能调用,如果找不到就定时重试
 */
private fun trySetupRenderThreadPriority() {
    // 条件:最多尝试x次,且未设置成功
    if (renderThreadSetupAttempt++ < attemptTimes && !renderThreadPrioritySet) {
        try {
            val tid = getRenderThreadTid()
            if (tid != -1) {
                Process.setThreadPriority(tid, startupPriority)
                renderThreadPrioritySet = true
                Log.d(tag, "方案1:成功设置渲染线程优先级: $startupPriority (第${renderThreadSetupAttempt}次尝试)")
                // 成功设置后,可设置一个较晚的兜底恢复(如5秒后),确保系统健康
                handler.postDelayed({ safelyResetPriority() }, 5000)
            } else {
                // 如果没找到,16ms后重试(约一帧时间)
                handler.postDelayed(::trySetupRenderThreadPriority, 16)
                Log.d(tag, "方案1:第${renderThreadSetupAttempt}次尝试:未找到渲染线程,稍后重试")
            }
        } catch (e: Exception) {
            Log.e(tag, "方案1:设置渲染线程优先级时发生异常", e)
        }
    } else if (!renderThreadPrioritySet) {
        Log.w(tag, "方案1:已达到最大尝试次数,未能设置渲染线程优先级")
    }
}
// 在onCreate中调用

方案2:等待首帧绘制后(更可靠)

window.decorView.viewTreeObserver.addOnPreDrawListener {
    val tid = getRenderThreadTid()
    if (tid != -1) {
        Process.setThreadPriority(tid, startupPriority)
    }
}

3. 恢复优先级

private fun restorePriorities() {
    // 恢复主线程
    Process.setThreadPriority(Process.THREAD_PRIORITY_DEFAULT)
    
    // 恢复渲染线程
    val tid = getRenderThreadTid()
    if (tid != -1) {
        Process.setThreadPriority(tid, Process.THREAD_PRIORITY_DEFAULT)
    }
}
// 在首帧绘制完成后调用

4. 获取渲染线程TID的工具函数

import android.os.Process
import java.io.BufferedReader
import java.io.File
import java.io.FileReader

fun getRenderThreadTid() : Int {
   val taskParentDir = File("/proc/" + Process.myPid() + "/task/")
   if (taskParentDir.isDirectory()) {
       val taskFiles = taskParentDir.listFiles()
       taskFiles?.let {
           for (taskFile in it) {
               var br : BufferedReader? = null
               var line: String?
               try {
                   br = BufferedReader(FileReader(taskFile.getPath() + "/stat"), 100)
                   line = br.readLine()
                   if (line.isNotEmpty()) {
                       val param = line.split(" ")
                       if (param.size < 2) {
                           continue
                       }
                       val threadName: String = param[1]
                       if (threadName.contains("RenderThread")) {
                           return param[0].toInt()
                       }
                   }
               } catch (e : Exception) {

               } finally {
                   br?.close()
               }
           }
       }
   }
   return -1
}

再次提醒,使用激进优化中的手段时,请进行充分的测试后,再考虑上生产环境。

详细代码请参阅StudyH2g/AndroidStartupOptimizationDemo at feature/aggressive-thread-priority

4.3 优化用户感官体验

需要说明的是,此种方式不会优化启动速度,只是优化用户感官体验。

4.3.1 主题切换: 优化空白Window

在启动流程中,会显示一个空白窗口。时序图如下:

image.png

ActivityStarter#recycleTask代码如下

    // ActivityStarter#recycleTask
    int recycleTask() {
        ...
        if (mMovedToFront) {
            // 冷启动 mMovedToFront 为 true, 显示空白窗口
            targetTaskTop.showStartingWindow(true /* taskSwitch */);
        } else if (mDoResume) {
            // Make sure the root task and its belonging display are moved to topmost.
            mTargetRootTask.moveToFront("intentActivityFound");
        }
        ...
    }

它的调用是在 Application#onCreate 之前的。我们可以用一张图片来替换掉这个空白Window,从而避免白屏,让用户体验更好。那么下面就详细说明具体做法:

首先定义要显示的图片

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@android:color/white" />
    <item>
        <bitmap
            android:gravity="center"
            android:src="@drawable/ic_xxx" />
    </item>
</layer-list>

再定义启动的主题

<style name="Theme.Launcher" parent="android:Theme.Material.Light.NoActionBar" >
    <item name="android:windowBackground">@drawable/ic_start_xxx</item>
</style>

后续将Manifest中的主题替换为启动主题

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        ...
        android:theme="@style/Theme.Launcher">
        ...
    </application>

</manifest>

最后我们在启动的首个 Activity#onCreate,设置为原本的主题,注意要在 super.onCreate 之前

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // 设置为原本的主题
        setTheme(R.style.Theme_xxx)
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            xxx
        }
    }
}

这样做了之后,这个空白window就会变成我们设置的图片了。

使用主题切换优化空白弹窗效果:
demo (online-video-cutter.com).gif
为了更好的看优化效果,此处延迟了1.5S显示内容

再次提醒,这种方式仅仅优化了空白window,并不会优化启动速度

4.3.2 骨架屏

骨架屏就是正常页面的不带数据显示的空白版本,一般会使用灰色块来显示大体的外形。通过使用骨架屏,我们可以优化用户感官体验,从而提高用户的留存率和日活。

骨架屏的实现根据对视觉还原度的要求,分为两种: 模块式骨架屏、真实UI式骨架屏

模块式骨架屏

侧重于内容模块的布局位置,使用简单的灰色色块构成页面框架。其优势在于实现简单、复用性高,可以快速提升用户感官体验。

modelScreen.gif

真实UI式骨架屏

在内容加载前,显示与最终界面高度一致的视觉样式,包括图标、间距等细节。这种方案能提供丝滑的过渡体验,但实现和维护成本也相应更高。

SkeScreen.gif

模块骨架屏和真实UI骨架屏在实现方式上是相同的,只是实现的细节程度的不同。我们就看看怎么实现Compose模块屏

实现骨架屏

这里介绍下带闪烁效果的骨架屏,直接看代码吧

@Composable
fun shimmerBrush(): Brush {
    // 定义渐变色
    val gradient = listOf(
        Color.LightGray.copy(alpha = 0.6f),
        Color.LightGray.copy(alpha = 0.2f),
        Color.LightGray.copy(alpha = 0.6f)
    )
    // 创建无限平移的动画
    val transition = rememberInfiniteTransition(label = "shimmer")
    val translateAnimation = transition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1000, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ),
        label = "shimmerTranslation"
    )
    // 返回计算好的 Brush
    return Brush.linearGradient(
        colors = gradient,
        start = Offset(x = translateAnimation.value - 500, y = 0f),
        end = Offset(x = translateAnimation.value, y = 100f)
    )
}

上面我们就定义好了一个从左到右的渐变的闪烁效果。然后我们只要在Modifier. drawWithCache使用它就可以了,如下:

@Composable
private fun SkeletonBox() {
    val shimmerBrush = shimmerBrush()

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(62.dp)
            .clip(RoundedCornerShape(60))
            .drawWithCache {
                onDrawBehind { drawRect(brush = shimmerBrush) }
            }
    )
}

效果如下:

Screen_recording_20260130_143741 (online-video-cutter.com).gif

我们现在就已经实现了一个带闪烁效果的按钮了。由于页面布局各异,核心是实现 shimmerBrush 并应用于Modifier。大家可根据自身UI结构复用此方法。

而在页面中肯定会有多个组件,我们可以通过状态提升,共用一个shimmerBrush

private fun SkeletonModel(navController: NavController) {
    // 共用 shimmerBrush
    val shimmerBrush = shimmerBrush()
    Column(modifier = Modifier
        .padding(horizontal = 16.dp)
        .verticalScroll(rememberScrollState())
    ) {
        Spacer(modifier = Modifier.height(12.dp))
        // 1
        ProductBrushItem(shimmerBrush)
        Spacer(modifier = Modifier.height(12.dp))
        // 2
        AmountBrushItem(shimmerBrush)
        Spacer(modifier = Modifier.height(12.dp))
        // 3
        ButtonBrushItem(shimmerBrush)
    }
}

还有一点是,页面中数据加载前后,组件没有变化 或 有明确含义的,可以直接用它们 或 它们代表的含义。比如上面示例 App 中,Choose a loan product文案,按钮下面的 Terms & Service and Privacy Policy都直接用的它们,因为加载数据前后无变化,而min amount 和 max amount则是选择产品的最小/大值的含义。

最后说说选择页面的准则:个人认为应优先选择用户日常使用中,最频繁的那个页面(比如购物软件的首页),而次级的页面,可以先实现模块级骨架屏,后续再考虑改进。

五、总结

优化冷启动, 可以按测量-分析-优化-验证 这样的环节去进行

  • 测量:分清TTID(第一帧)与TTFD(可交互),用可靠工具(如脚本)获取优化前后数据。
  • 分析:弄清楚冷启动涉及到的所有环节,即代码层、编译层、体验层、系统层
  • 优化:分层优化,建议先从收敛Provider、初始化优化和任务调度做起,务必用上基准配置和启动配置;推荐使用感官优化手段;激进方式请慎重评估场景与风险,并进行充分测试后再考虑使用。
  • 验证:对优化前后进行测试,验证优化效果。(在我主导的项目中,通过实践这一闭环,我们将冷启动的P50时间成功降低了24%。)

感谢阅读,希望本文对你有所帮助,如有任何不对的地方,欢迎大家指正

六、参考资料

  1. 应用启动时间
  2. Jetpack Macrobenchmark
  3. App Startup
  4. 《Android性能优化之道:从底层原理到一线实践》
  5. App冷启动优化测试干货分享(一)
  6. Top团队大牛带你玩转Android性能分析与优化
  7. 常用 JSON 库性能对比: Gson VS Moshi VS Kotlin Serialization VS ...