读者点单·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.txt 和 startup-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 迁移,那篇会很有共鸣。

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui