异步初始化框架设计:用拓扑排序干掉启动串行瓶颈

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() 来手动触发初始化,但这只是"延迟到你手动调用的时刻",不等于"延迟到首帧渲染完成后的空闲时段"。真正的闲时初始化需要配合 IdleHandlerpostDelayed 策略。

小结: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 的首帧渲染管线,把最后这段白屏时间也干掉。

相关推荐
君莫啸ོ2 小时前
Android persistent APP调试
android
泥嚎泥嚎2 小时前
【Android】Handler 全面解析
android
Ehtan_Zheng2 小时前
WebView 的现代替代方案:适用于 Jetpack Compose 的 AndroidX Browser
android
朝星2 小时前
Android开发[3]:协程+Flow
android·kotlin
张小潇2 小时前
AOSP15 WMS/AMS系统开发 - WindowManagerService addWindow详解
android
爱吃牛肉的大老虎2 小时前
MySQL优化之系统表分析SQL
android·sql·mysql
Fate_I_C2 小时前
实战案例:用 Kotlin 重写一个 Java Android 工具类
android·java·kotlin
Fate_I_C2 小时前
Kotlin 特有语法糖
android·开发语言·kotlin
Fate_I_C3 小时前
Kotlin 为什么是 Android 开发的首选语言
android·开发语言·kotlin