应用程序的启动速度的重要性不言而喻,各种方案层出不穷,为了优化十几毫秒的时间,工程师也是不遗余力。各种框架也是应运而生,Google的Jetpack也包括Startup的项目,对Android应用启动进行优化,一些公司也内部开发一些框架,支持任务初始化的并行执行,来提升应用启动的速度。
启动优化涉及到应用的许多方面,本文探讨的是其中的一个方面,如何简化任务初始化的并行执行逻辑。
写在前面
任务初始化框架,一般分几个部分。
- 任务定义
- 任务依赖管理
- 任务并行化执行
Kotlin的协程方案
Kotlin的协程在管理任务依赖和并行化执行方面非常简单高效,在使用这个方案的时候,基本上半天时间,就可以把这个方案在项目中落地。而且只用Kotlin的协程就可以,不用额外引入框架,使用原生的协程方案代码量更小,且使用者完全可控,这个很关键。
下面把这个方案说明一下:
Task的定义
首先我们先简单定义一下Task,作为所有初始化任务的基类。
abstract class Task(val name: String, val mainThread: Boolean) {
// deps表示依赖于的task的列表
val deps: List<String> = mutableListOf()
// depsOn被依赖的task列表,拓扑的排序的时候需要用到
val depsOn: List<String> = mutableListOf()
// 依赖的任务为完成的数量,拓扑的排序的时候需要用到
open var dependTaskCount = 0
// 具体初始化的函数,子类需要重写
abstract fun startUp(context: Context)
}
初始化Task
这部分负责初始化Task,返回Task的列表。
fun collectTasks(): List<Task> = {
// collect tasks
}
这部分有两种实现方案。
- 通过注解的方式来实现Task的定义和收集。
- 手动进行Task的创建,并建立各个任务的依赖关系。
这两种方式可以按需选择,我们项目中选择了使用第二种方式,原因是我们的任务关系相对简单,并可以在这里统一的地方查看任务依赖关系。但是缺点是必须存在工程依赖。
第一种方式是设计框架进行复用的不二选择,但是对于这种方案,我也建议可以有统一的地方来配置任务之间的依赖关系的配置。第二种方式特别适合最开始的时候,把任务初始化串行执行改为并行,代码稍加改造即可实现。
任务调度
这部分是关键部分,要解决三个问题。
- 在Application的onCreate返回前把所有的同步和异步任务都执行完成,并且支持可以启动协程。
runBlocking()
这个函数是绝佳选择。 - 启动任务并行执行,支持在主线程和后台线程进行执行。这部分的解决方案是
CoroutineScope.launch()
- 任务依赖,假设任务A依赖任务B,启动A的时候,必须保证B执行完成。需要执行Job B的
join
。
val jobA = launch {
jobB.join() // 执行taskA之前,先执行jobB的join,保证任务的依赖关系。
// 具体执行TaskA的任务。
taskA().startUp()
}
是不是比线程方案要简单多了。
接下来使用伪代码把整体的逻辑说明一下,紧要的地方有注释。这个函数调用在Application的onCreate()里面调用即可。
fun startUp(context: Context) = runBlocking {
val taskList: List<Task> = collectTasks()
// 建立一个map, 通过name可以获取task
val taskMap = mutableMapOf<String, Task>().apply {
taskList.forEach {
put(it.name, it)
}
}
// 初始化拓扑排序的第一批没有被依赖的task,可以直接执行
val queue: Queue<Task> = LinkedList()
taskList.filter { it.dependTaskCount == 0 }.forEach(queue::add)
//建立一个map, 通过name可以获取协程的Job
val jobMap = mutableMapOf<String, Job>()
while (queue.isNotEmpty()) {
val curTask = queue.poll()!!
// 考虑一下,为什么如果task需要在main thread之中运行的话,dispatcher要设置为EmptyCoroutineContext ?
val dispatcher = if (curTask.isMainThreadTask) EmptyCoroutineContext else Dispatchers.Default
jobMap[curTask.name] = launch(dispatcher) {
for(dep in curTask.deps) {
withContext(context) { // 这句代码很重要,不然会有死锁,想一想为什么?
jobMap[dep]!!.join() // 依赖的任务必须先执行完,因为这个是拓扑排序执行的,所以jobMap[dep]不可能为空
}
}
//依赖已经执行完成,执行自身的任务
curTask.startUp(context)
}
for (taskName in curTask.depsOn) {
//这是一个依赖于当前任务的后续任务
val followTask = taskMap[taskName]!!
//如果这个后续任务所依赖的未开始任务数量为0,则安排这个任务进入队列
followTask.dependTaskCount--
if (followTask.dependTaskCount == 0) {
queue.offer(followTask)
}
}
}
// 这个地方需要判断一下,是否所有的任务都已经被安排执行了,如果还有任务没有被安排,说明任务存在循环依赖,抛出异常。
}
以上代码是为这篇文章现准备的,虽是伪代码,是可以编译通过的,但是没有调试过,可能存在一些小问题,大的思路上没有问题。
其实也有些时候,代码还可以更简单。假设现在有4个任务, A B C D, 其中B依赖于A,C依赖于A,D依赖于B和C,初始化代码可以简单这样写。
fun startUp(context: Context) = runBlocking {
val jobA = launch(taskA.dispather) {
TaskA().startUp()
}
val jobB = launch(taskB.dispather) {
jobA.join()
TaskB().startUp()
}
val jobC = launch(taskC.dispather) {
jobA.join()
TaskC().start()
}
val jobD = launch(taskD.dispather) {
jobB.join()
jobC.join()
TaskD().start()
}
}
不过不要被我带坏,这样写法只在非常特定的场景下,当然这样写的执行效率高,但是如果任务很多很复杂的话,不建议这样写。维护成本略高,除非你觉得你完全可以Hold住。
这个写法特别合适从最初的同步任务初始化改成异步的写法的最初尝试,再逐步重构,最终可以进化成协程版本的异步任务初始化框架。
另外一点需要注意,尽量让长时间的任务尽早安排执行,这样可以最大程度的减少事件的最长路径,因为这个最长路径决定总的执行时间的长短。
额外的好处
这个设计带来的一个额外的好处是,可以在任务的初始化代码里面使用suspend函数。
One More Thing
一个小Tip,作为文章的结尾吧。
如果有一些初始化任务,可以在Application的onCreate函数之后执行,但是可能入口比较多,还要防止重复初始化,管理起来会比较麻烦。但是这些初始化越早越好,在这种情况下,可以在Application的onCreate的最后,启动一个协程(MainThread)来进行。这样不影响主界面的启动时间,任务会在主界面启动之后的消息队列里面立即执行。
override fun onCreate() {
super.onCreate()
startUp(this)
// other code ......
coroutineScope.launch(Dispatchers.Main) {
DelayTask("name", true).startUp(context)
}
}
在一些特定的场景下,这个小Tip还是很好用的。