读者点单·02|Android 启动优化实战:Trace 抓取→Application 编排→冷启动全流程拆解

读者点单·端午投票系列 · 第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.txtstartup-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 迁移,那篇会很有共鸣。

相关推荐
Coffeeee2 小时前
帮你快速理解AI Agent之我想招个Android实习生
android·人工智能·agent
恋猫de小郭4 小时前
苹果 AirPods 协议,Android 也可以使用完整版 AirPods 能力
android·前端·flutter
黄林晴4 小时前
告别无效重建:Gradle 9.6.0 解决 CI 构建缓存失效痛点告别无效重建:Gradle 9.6.0 解决 CI 建筑缓存失效痛点
android·gradle
张风捷特烈4 小时前
Flutter 类库大揭秘#01 | path_provider架构与设计
android·flutter
_阿南_13 小时前
Android文件读写和分享总结
android
通玄1 天前
Jetpack Compose 入门系列(六):Navigation 3 页面导航
android
rocpp1 天前
Android 多语言切换实战:从 Context 到 Android 13 应用语言适配
android·kotlin
释然小师弟1 天前
Android开发十年:反思与回顾
android·后端·嵌入式
黄林晴1 天前
用了这么久 Koin Scope,原来一直都用错了?
android·kotlin