应用程序启动优化新思路 - Kotlin协程

应用程序的启动速度的重要性不言而喻,各种方案层出不穷,为了优化十几毫秒的时间,工程师也是不遗余力。各种框架也是应运而生,Google的Jetpack也包括Startup的项目,对Android应用启动进行优化,一些公司也内部开发一些框架,支持任务初始化的并行执行,来提升应用启动的速度。

启动优化涉及到应用的许多方面,本文探讨的是其中的一个方面,如何简化任务初始化的并行执行逻辑

写在前面

任务初始化框架,一般分几个部分。

  1. 任务定义
  2. 任务依赖管理
  3. 任务并行化执行

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的创建,并建立各个任务的依赖关系。

这两种方式可以按需选择,我们项目中选择了使用第二种方式,原因是我们的任务关系相对简单,并可以在这里统一的地方查看任务依赖关系。但是缺点是必须存在工程依赖。

第一种方式是设计框架进行复用的不二选择,但是对于这种方案,我也建议可以有统一的地方来配置任务之间的依赖关系的配置。第二种方式特别适合最开始的时候,把任务初始化串行执行改为并行,代码稍加改造即可实现。

任务调度

这部分是关键部分,要解决三个问题。

  1. 在Application的onCreate返回前把所有的同步和异步任务都执行完成,并且支持可以启动协程。runBlocking()这个函数是绝佳选择。
  2. 启动任务并行执行,支持在主线程和后台线程进行执行。这部分的解决方案是CoroutineScope.launch()
  3. 任务依赖,假设任务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还是很好用的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容