Android启动优化系列 · 第3/5篇
从冷启动8秒到秒开的工程实战
第1篇:Android启动全景图:一次冷启动背后到底发生了什么
第2篇:启动瓶颈定位实战:Perfetto + Macrobenchmark 一套组合拳
第3篇:异步初始化框架设计:用拓扑排序干掉启动串行瓶颈
⏳ 第4篇:首帧渲染优化:从白屏到内容可见的最后一公里
⏳ 第5篇:线上监控与防劣化:让启动优化成果不再回退
上一篇我们用 Perfetto + Macrobenchmark 组合拳定位了启动瓶颈,拿到了一份清清楚楚的火焰图。你大概率会发现一个让人血压升高的事实:Application.onCreate() 里那一堆 SDK 初始化,明明互相之间没有依赖关系,却一个接一个串行执行,活活把 800ms 的初始化拉到了 2.5 秒。
我第一次看到这种 trace 的时候,心态其实挺崩的。十几个 SDK,每个 100~300ms,首尾相连一字排开,像火车车厢一样整整齐齐地趴在主线程上。但凡有两个线程同时跑,启动速度都能快一倍。
问题是------你不能无脑地把所有初始化扔到子线程。有些 SDK 依赖 Context 初始化完成,有些 SDK 之间有先后顺序,有些必须在主线程执行(对,说的就是你,某些广告 SDK)。你需要的不是"全部异步",而是一个能理解依赖关系、自动编排执行顺序的调度框架。
这就是今天要讲的内容。我们先看 Google 官方给的方案 Jetpack App Startup,分析它能做什么、不能做什么,然后从零开始设计一个基于 DAG(有向无环图)的异步初始化调度器。
一、先看官方方案:Jetpack App Startup
Google 在 2020 年推出了 androidx.startup 库,核心目的就是解决一个问题:每个第三方库都注册自己的 ContentProvider,导致启动时 ContentProvider 数量爆炸。
在没有 App Startup 之前,很多库(比如 WorkManager、Firebase、LeakCanary)的初始化方式是在 AndroidManifest 里声明一个自己的 ContentProvider,利用 ContentProvider 在 Application.onCreate() 之前自动执行的特性来完成初始化。这很方便,但问题也很明显:
• 每个 ContentProvider 的实例化和 onCreate() 都是串行执行的
• 12 个 ContentProvider 光实例化就要消耗 100~200ms
• 你作为 App 开发者,根本控制不了这些库的初始化顺序
• Perfetto trace 里看到的那堆 ActivityThread.installContentProviders,大半都来自这些库
1.1 App Startup 的工作原理
App Startup 的思路很直接:用一个统一的 InitializationProvider(也是个 ContentProvider)来代替所有库各自的 ContentProvider。每个需要初始化的组件实现 Initializer<T> 接口,声明自己的依赖关系,App Startup 在启动时做拓扑排序,按正确顺序初始化所有组件。
kotlin
class CrashReportInitializer :
Initializer {
override fun create(
context: Context
): CrashReporter {
// 实际初始化逻辑
return CrashReporter
.init(context)
}
override fun dependencies():
List
>> {
// 声明依赖:先初始化日志
return listOf(
LogInitializer::class.java
)
}
}
然后在 AndroidManifest 里只需要声明一个 Provider:
这样做的好处很明确:12 个 ContentProvider 合并成 1 个,光这一步就能省 80~150ms。App Startup 内部用拓扑排序处理依赖,确保初始化顺序正确。
1.2 App Startup 的致命局限
说实话,App Startup 解决了 ContentProvider 合并的问题,但对于真正的启动优化来说,它有三个致命缺陷:
第一,所有初始化都在主线程串行执行 。 App Startup 的 create() 方法是同步的,没有提供异步执行的能力。即使两个 Initializer 之间没有任何依赖,它们也是一个执行完再执行下一个。这意味着拓扑排序只解决了"顺序正确"的问题,完全没有解决"并行加速"的问题。
第二,没有线程调度能力。 你没法告诉 App Startup"这个初始化可以放到 IO 线程"或者"这个必须在主线程"。所有东西都在主线程,一个 300ms 的网络 SDK 初始化会直接卡住主线程 300ms。
第三,不支持延迟初始化策略 。 虽然 App Startup 提供了 AppInitializer.getInstance(context).initializeComponent() 来手动触发初始化,但这只是"延迟到你手动调用的时刻",不等于"延迟到首帧渲染完成后的空闲时段"。真正的闲时初始化需要配合 IdleHandler 或 postDelayed 策略。
小结:App Startup 适合做 ContentProvider 合并(这本身就是一个有价值的优化),但如果你的 Application.onCreate() 里有十几个需要异步化的初始化任务,App Startup 帮不了你。你需要一个真正的 DAG 调度器。
二、设计一个 DAG 异步初始化调度器
既然 App Startup 不够用,我们自己造一个。目标很明确:
• 支持声明式依赖(A 依赖 B、C,B 依赖 D)
• 自动拓扑排序,检测循环依赖
• 无依赖的任务自动并行
• 每个任务可以指定运行线程(主线程/IO/计算)
• 支持等待点:主线程可以等特定任务完成后再继续
• 支持闲时初始化:非关键任务延迟到首帧之后
2.1 核心数据结构:Task 和 TaskGraph
先定义任务的抽象。每个初始化任务需要描述三件事:自己是谁、依赖谁、在哪个线程跑。
kotlin
abstract class StartupTask {
// 任务唯一标识
abstract val name: String
// 依赖的任务名列表
open fun dependencies():
List = emptyList()
// 运行线程
open fun dispatcher():
TaskDispatcher =
TaskDispatcher.IO
// 是否必须在首帧前完成
open fun mustBeforeFirstFrame():
Boolean = true
// 实际初始化逻辑
abstract suspend fun execute(
context: Context
)
}
enum class TaskDispatcher {
MAIN, // 主线程
IO, // IO 密集型
COMPUTE, // CPU 密集型
IDLE // 闲时执行
}
为什么用 String 而不是 Class 作为依赖标识?因为在大型项目里,初始化任务可能分散在不同的模块中,用类引用会导致模块间产生编译依赖。用字符串名称解耦更干净,代价是需要在运行时做一次校验。
2.2 拓扑排序:BFS 实现
拓扑排序是 DAG 调度器的核心。简单说就是:对于有向无环图,找到一个节点序列,使得每条边 A → B 中 A 一定排在 B 前面。对应到初始化场景,就是被依赖的任务一定先执行。
我用 Kahn 算法(BFS 版本)来实现,因为它有两个好处:一是天然支持检测循环依赖(排序完成后如果还有剩余节点就说明有环),二是每一轮取出的所有入度为 0 的节点天然可以并行执行------这是关键。
scss
class TaskGraph(
private val tasks:
List
) {
// 邻接表: task -> 依赖它的任务列表
private val graph =
mutableMapOf
>()
// 入度表
private val inDegree =
mutableMapOf()
private val taskMap =
tasks.associateBy { it.name }
init {
// 构建图
tasks.forEach { task ->
inDegree[task.name] =
task.dependencies().size
task.dependencies()
.forEach { dep ->
graph
.getOrPut(dep) {
mutableListOf()
}
.add(task.name)
}
}
}
/**
* 返回按层级分组的执行计划
* 同一层的任务可以并行执行
*/
fun resolve():
List> {
val result =
mutableListOf
>()
val degrees =
inDegree.toMutableMap()
val queue =
ArrayDeque()
// 入度为0的节点入队
degrees.filter { it.value == 0 }
.keys
.forEach { queue.add(it) }
var processed = 0
while (queue.isNotEmpty()) {
// 取出当前层所有可执行的
val batch = queue
.toList()
queue.clear()
result.add(
batch.mapNotNull {
taskMap[it]
}
)
processed += batch.size
// 更新后继节点入度
batch.forEach { name ->
graph[name]
?.forEach { next ->
degrees[next] =
(degrees[next] ?: 1) - 1
if (degrees[next] == 0) {
queue.add(next)
}
}
}
}
// 循环依赖检测
if (processed 0 }
.keys
throw IllegalStateException(
"循环依赖: $stuck"
)
}
return result
}
}
这段代码的核心在 resolve() 方法:它返回的不是一个扁平列表,而是一个分层列表。每一层里的任务入度都为 0(即它们的依赖都已执行完毕),可以安全地并行执行。
来看一个具体例子。假设我们有这样的依赖关系:
初始化任务依赖图
Layer 0: Log / Config / DeviceId
↓ 无依赖,三个并行执行
Layer 1: Network(→Log) / CrashReport(→Log)
↓ 依赖 Log 完成,两个并行执行
Layer 2: Analytics(→Network,Config)
↓ 依赖 Network 和 Config 都完成
Layer 3: Push(→Analytics,DeviceId)
串行执行需要 6 个任务 × 平均 150ms = 900ms。用 DAG 调度只需要 4 层 × 150ms = 600ms。如果每层内部再配合多线程,实际耗时取决于每层中最慢的那个任务,通常比 600ms 还要短很多。
2.3 调度器实现:协程 + CountDownLatch 的混合方案
有了拓扑排序的执行计划,下一步是调度执行。这里有个关键决策:用协程还是线程池?
我的选择是协程做调度,CountDownLatch 做同步 。原因是:协程的 Dispatchers 天然支持线程切换,而 CountDownLatch 用来做"主线程等待特定异步任务完成"的等待点,比协程的 Job.join() 更轻量,也不需要主线程跑在协程里。
kotlin
class StartupScheduler(
private val context: Context,
private val tasks:
List
) {
private val scope =
CoroutineScope(
SupervisorJob() +
Dispatchers.Default
)
// 每个任务的完成信号
private val latches =
mutableMapOf()
// 耗时统计
private val timings =
mutableMapOf()
fun start() {
val startTime =
SystemClock.elapsedRealtime()
// 为每个任务创建 latch
tasks.forEach {
latches[it.name] =
CountDownLatch(1)
}
// 所有任务同时提交
// 靠 latch 等待自动形成拓扑序
tasks.forEach { task ->
val dispatcher = when (
task.dispatcher()
) {
TaskDispatcher.MAIN ->
Dispatchers.Main
TaskDispatcher.IO ->
Dispatchers.IO
TaskDispatcher.COMPUTE ->
Dispatchers.Default
TaskDispatcher.IDLE ->
Dispatchers.Default
}
scope.launch(dispatcher) {
// 等待所有依赖完成
task.dependencies()
.forEach { dep ->
latches[dep]
?.await()
}
// 执行任务并计时
val t = SystemClock
.elapsedRealtime()
try {
task.execute(context)
} catch (e: Exception) {
Log.e("Startup",
"${task.name} 失败",
e)
} finally {
timings[task.name] =
SystemClock
.elapsedRealtime() - t
// 通知依赖我的任务
latches[task.name]
?.countDown()
}
}
}
}
/**
* 主线程调用:等待所有关键任务完成
* 只等 mustBeforeFirstFrame=true 的
*/
fun awaitCritical(
timeoutMs: Long = 3000
) {
tasks
.filter {
it.mustBeforeFirstFrame()
}
.forEach {
latches[it.name]
?.await(
timeoutMs,
TimeUnit.MILLISECONDS
)
}
}
/** 打印耗时报告 */
fun report(): String {
return timings.entries
.sortedByDescending {
it.value
}
.joinToString("\n") {
"${it.key}: ${it.value}ms"
}
}
}
注意这个实现有个巧妙的地方:我没有按拓扑排序的层级分批提交任务,而是一次性把所有任务都提交出去,让每个任务自己等待依赖的 latch。这样做的好处是调度更灵活------如果 Layer 0 的 Log 任务 10ms 就完成了,依赖它的 Network 任务会立即开始执行,不需要等 Layer 0 的其他任务(比如 300ms 的 DeviceId)也完成。
换句话说,分层只是逻辑概念,实际调度是事件驱动的------谁的依赖满足了谁就跑。
三、闲时初始化:首帧之后再做的事
并不是所有初始化都需要在 Application.onCreate() 阶段完成。很多 SDK 只在用户触发特定功能时才需要,比如分享 SDK、支付 SDK、地图 SDK。但由于历史原因,它们全被塞到了 onCreate 里。
对这类任务,最佳策略是闲时初始化:等首帧渲染完成、主线程空闲时再执行。
3.1 IdleHandler 方案
Android 的 MessageQueue.IdleHandler 会在主线程消息队列空闲时回调。我们可以利用它来执行非关键初始化:
kotlin
class IdleTaskQueue {
private val queue =
ArrayDeque Unit>()
fun add(task: () -> Unit) {
queue.add(task)
}
fun start() {
Looper.myQueue()
.addIdleHandler {
val task =
queue.pollFirst()
if (task != null) {
val start = SystemClock
.elapsedRealtime()
task()
Log.d("IdleInit",
"闲时任务耗时: " +
"${SystemClock
.elapsedRealtime()
- start}ms")
queue.isNotEmpty()
} else {
false // 队列空了,移除
}
}
}
}
使用方式很简单,在 Activity.onResume() 或 reportFullyDrawn() 之后启动:
scss
// Activity 中
override fun onResume() {
super.onResume()
reportFullyDrawn()
IdleTaskQueue().apply {
add { ShareSDK.init(ctx) }
add { MapSDK.init(ctx) }
add { PaySDK.init(ctx) }
start()
}
}
坑点提示:IdleHandler 的每次回调只处理一个任务。如果某个闲时任务自身耗时超过 16ms,会导致下一帧掉帧。所以对于耗时较长的闲时任务(比如地图 SDK 初始化通常 200ms+),建议在 IdleHandler 回调中把它扔到子线程,而不是直接在主线程执行。
3.2 按需加载 vs 闲时预加载
闲时初始化还有一个策略选择的问题:是"闲时就初始化"还是"用到才初始化"?
我的判断是:高频功能用闲时预加载,低频功能用按需加载。
| 策略 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| 闲时预加载 | 分享、推送、IM | 用户触发时零延迟 | 占用额外内存 |
| 按需加载 | 支付、地图、AR | 不用不占资源 | 首次触发有冷启动感 |
对于按需加载的 SDK,可以用 Kotlin 的 lazy 配合一层封装来实现:
kotlin
object SDKRegistry {
val mapService: MapService
by lazy {
// 首次访问时初始化
MapSDK.init(appContext)
MapSDK.getService()
}
val payService: PayService
by lazy {
PaySDK.init(appContext)
PaySDK.getService()
}
}
四、实战:把 12 个 ContentProvider 合并到 1 个
说了这么多设计,来看一个真实案例。我们的 App 在 Perfetto trace 里可以看到 installContentProviders 阶段耗时 380ms,里面塞了 12 个 ContentProvider。
先看看都有谁:
go
// 用 adb 查看 App 的 Provider
adb shell dumpsys package \
com.example.myapp \
| grep "Provider{"
// 输出(精简):
WorkManagerInitializer
FirebaseInitProvider
LeakCanaryInstaller
FacebookInitProvider
CrashReportProvider
ImageLoaderProvider
RouterInitProvider
PushInitProvider
AnalyticsProvider
ConfigProvider
ABTestProvider
PerformanceProvider
12 个 ContentProvider,每个的 onCreate() 平均耗时 1550ms,光实例化开销加起来就是 180380ms。
4.1 分类处理策略
不是所有 ContentProvider 都能简单移除。我把它们分成三类:
可直接移除(用 App Startup 替代):
WorkManagerInitializer、LeakCanaryInstaller、ImageLoaderProvider、RouterInitProvider
这些库官方支持 App Startup,或者可以改为手动初始化
需要适配(库不支持但可以 hack):
FirebaseInitProvider、FacebookInitProvider、CrashReportProvider
在 Manifest 中用 tools:node="remove" 移除,手动调用初始化 API
不建议动(风险太高):
PushInitProvider(某些推送 SDK 的 ContentProvider 内部注册了 receiver,移除会丢推送)
对这类,保留 ContentProvider 但优化其 onCreate 内部逻辑
4.2 Manifest 移除 + 手动初始化
以 Firebase 为例,移除它自带的 ContentProvider 并改为手动初始化:
然后写一个对应的 StartupTask:
kotlin
class FirebaseTask : StartupTask() {
override val name =
"firebase"
override fun dependencies() =
listOf("log")
override fun dispatcher() =
TaskDispatcher.IO
override suspend fun execute(
context: Context
) {
FirebaseApp.initializeApp(
context
)
}
}
4.3 最终串联:Application.onCreate() 长什么样
整合之后,Application.onCreate() 变成了这样:
scss
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
val scheduler =
StartupScheduler(
this,
listOf(
// 无依赖,可并行
LogTask(),
ConfigTask(),
DeviceIdTask(),
// 依赖 log
FirebaseTask(),
CrashReportTask(),
NetworkTask(),
// 依赖 network+config
AnalyticsTask(),
// 依赖 analytics+id
PushTask(),
)
)
scheduler.start()
// 只等关键路径上的任务
scheduler.awaitCritical()
// Debug 模式打印耗时
if (BuildConfig.DEBUG) {
Log.d("Startup",
scheduler.report())
}
}
}
4.4 优化效果
在 Pixel 7 上用 Macrobenchmark 测了 10 次取中位数:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| ContentProvider 阶段 | 382ms | 48ms | -87% |
| Application.onCreate() | 1240ms | 320ms | -74% |
| TTID(首帧时间) | 2100ms | 890ms | -58% |
ContentProvider 合并直接砍掉 330ms;DAG 并行执行让 onCreate() 从 1240ms 降到 320ms(关键路径上最长的一条链是 Log → Network → Analytics → Push,约 280ms,其他任务都在这条链执行期间并行完成了);加上闲时初始化把非关键 SDK 移出 onCreate,TTID 整体提升 58%。
五、踩坑记录与工程细节
实际落地过程中踩了不少坑,这里记录几个最典型的。
5.1 主线程 Latch.await() 的死锁风险
如果一个任务声明运行在主线程(TaskDispatcher.MAIN),它的依赖又是在子线程执行的,这没问题。但如果主线程任务依赖另一个主线程任务,就会死锁------第一个任务的协程占着主线程,等着被依赖任务的 latch,而被依赖任务也需要主线程来执行,但主线程被占了。
解法是:强制要求主线程任务之间不能有依赖关系 ,在 TaskGraph.resolve() 阶段做校验:
scss
// 校验:主线程任务不能依赖主线程任务
tasks.filter {
it.dispatcher() ==
TaskDispatcher.MAIN
}.forEach { task ->
task.dependencies().forEach { dep ->
val depTask = taskMap[dep]
if (depTask?.dispatcher() ==
TaskDispatcher.MAIN) {
throw IllegalStateException(
"主线程任务
${task.name}
不能依赖主线程任务
${dep}"
)
}
}
}
5.2 某些 SDK 偷偷创建 Handler
把 SDK 初始化移到 IO 线程后,有些 SDK 会崩------因为它们内部 new Handler() 默认绑定当前线程的 Looper,而 IO 线程没有 Looper。
解法是在这类任务的 execute() 里显式传入 mainLooper:
kotlin
override suspend fun execute(
context: Context
) {
// 某些 SDK 需要主线程 Looper
// 但不一定要在主线程执行
if (Looper.myLooper() == null) {
Looper.prepare()
}
SomeSDK.init(context)
}
更好的做法是:如果 SDK 必须在主线程初始化,就老老实实把它标记为 TaskDispatcher.MAIN,不要跟线程模型较劲。
5.3 awaitCritical 的超时策略
线上偶现的情况:某个 IO 任务因为网络抖动或磁盘 IO 卡顿,10 秒还没完成,导致 awaitCritical() 阻塞主线程触发 ANR。
解法是:给 awaitCritical 加上超时,超时后降级继续启动。宁可某个 SDK 没初始化完就进首页(功能降级),也不能让用户看到 ANR 弹窗。
kotlin
fun awaitCritical(
timeoutMs: Long = 3000
) {
val deadline = SystemClock
.elapsedRealtime() + timeoutMs
tasks.filter {
it.mustBeforeFirstFrame()
}.forEach { task ->
val remaining = deadline -
SystemClock.elapsedRealtime()
if (remaining > 0) {
val ok =
latches[task.name]
?.await(
remaining,
TimeUnit.MILLISECONDS
) ?: true
if (!ok) {
Log.w("Startup",
"${task.name}
超时,降级继续")
}
}
}
}
六、完整的初始化策略决策树
最后,给一个我在实际项目中用的决策树,帮你快速判断每个初始化任务应该怎么处理:
初始化策略决策树
这个 SDK 首帧前必须就绪吗?
↓
是 → 放入 DAG 调度器,标记 mustBeforeFirstFrame = true
否 → 继续判断 ↓
↓
用户高频使用(>50% 会话)?
↓
是 → 闲时预加载(IdleHandler 或 postDelayed)
否 → 按需加载(lazy / 首次调用时初始化)
对于必须首帧前就绪的任务,再细分:
必须在主线程执行?
↓
是 → TaskDispatcher.MAIN(尽量精简逻辑,
IO密集 → TaskDispatcher.IO(网络、磁盘读写)
CPU密集 → TaskDispatcher.COMPUTE(加解密、解压缩)
上一篇我们学会了用 Perfetto 看清楚"时间花在哪里",这一篇我们把最大的那块时间------串行初始化------用 DAG 调度器和闲时策略压缩了 58%。但启动优化还没结束。下一篇《首帧渲染优化:从白屏到内容可见的最后一公里》,我们要解决另一个用户感知极强的问题:为什么 Activity 创建完了,屏幕上还是白的?从 Window Background、ViewStub 到 Compose 的首帧渲染管线,把最后这段白屏时间也干掉。