冷启动的最优解决方案

1.背景

前一阵子做一个需求,就是解决我们某个模块启动慢的问题,调查下来发现就是我们核心路径的任务执行时间比较长。我们想到了一个优化的方法,就是在 App 启动的时候就开启一个低优先级的线程去预加载任务,当用户真正用到这个模块的时候,启动的时间就会大大的缩短。

然而在申请 mr 的时候,被基础的同事质疑了,现在已经不允许在启动阶段新增任务了,如果非要新增,就必须发邮件申请。给我的感觉就是,这个 App 并不是你写的,你没办法随心所欲的实现自己的想法(其实体验挺糟糕的),也许团队大了之后就会变成这样吧,后来我们找到了另外一个时机去预加载,避免了在启动阶段增加任务。

在解决这个问题的过程中,我就发现,我们任务启动的代码写得挺糟糕的,这让我想起了之前在做冷启动优化的时候,做的一个启动框架,能够帮我们合理的安排启动任务,并监控每个任务的时间和总体的执行时间,防止劣化。

我将其重新用 kotlin 写了一遍,分享在 github 上,我给它起名为 StartUp 。
https://github.com/bearhuang-omg/startup

2.使用方式

在介绍使用方式之前,需要先了解以下几个类:
几个重要的类:

说明
TaskDirector 任务导演类,会根据任务的互相依赖以及优先级,安排任务的执行先后顺序,也是 sdk 的入口
Priority 定义任务的优先级,有四种优先级,分别是 IO(在 io 线程池上执行),Calculate(在计算线程池上执行),Idle(空闲时执行),Main(主线程执行)
Task 任务类,可以指定优先级,指定所依赖的任务名,注意:任务名不能重复
IDirectorListener 任务执行的生命周期,包括:onStart , onFinished,onError

重新封装之后,提供的接口非常的简单
使用方式:

接口 参数 返回值 备注
addTask task:Task // 任务 TaskDirector 返回TaskDirector,可以链式添加任务
registerListener IDirectorListener 监听任务的执行情况
unRegisterListener IDirectorListener 反注册监听
start 开始任务执行

例子:

//创建任务
val task2 = object:Task() {
    
    
    override fun execute() {
    
    
        Thread.sleep(1000)
    }

    override fun getName(): String {
    
    
        return "tttt2"
    }

    override fun getDepends(): Set<String> {
    
    
        return setOf("tttt1")
    }
}

//创建任务导演
val director = TaskDirector()
//监听任务生命周期
director.registerListener(object : IDirectorListener{
    
    
    override fun onStart() {
    
    
        Log.i(TAG,"director start")
    }

    override fun onFinished(time: Long) {
    
    
        Log.i(TAG,"director finished with time ${
      
      time}")
    }

    override fun onError(code: Int, msg: String) {
    
    
        Log.i(TAG,"errorCode:${
      
      code},msg:${
      
      msg}")
    }
})
//添加任务
director.apply {
    
    
    addTask(task1)
    addTask(task2)
    addTask(task3)
}
//开始执行
director.start()

3.基本原理

我们之前在做冷启动优化的时候,总结了启动阶段的一些痛点:

  1. 代码一团乱麻,无法清晰的知道哪些是必须的,哪些是非必需的;
  2. 任务存在依赖关系的话,如果不加注释,很容易被后面的人修改,导致出错;
  3. 无法准确的知道任务执行的耗时,优化方向比较难确定;

针对这些痛点,我们做了如下的处理:

1.抽象成任务图

我们将启动阶段逻辑相对独立的过程都封装成了一个个单独的 task,并可以指定其优先级和任务依赖关系,若没有依赖则直接挂在根结点上。
比如,目前有ABCDE五个任务,其中A,B均不依赖于任何任务,C依赖于A,D依赖于AB,E依赖于CD。所以生成的任务图便如下所示:
在这里插入图片描述

创建任务依赖也非常的简单,以ABC为例:

//创建任务A
val taskA = object:Task() {
    
    
    override fun execute() {
    
    
    }

    override fun getName(): String {
    
    
        return "A"
    }
}
//创建任务B
val taskB = object:Task() {
    
    
    override fun execute() {
    
    
    }

    override fun getName(): String {
    
    
        return "B"
    }
}
//创建任务C
val taskC = object:Task() {
    
    
    override fun execute() {
    
    
    }

    override fun getName(): String {
    
    
        return "C"
    }

    //依赖任务A和B
    override fun getDepends(): Set<String> {
    
    
        return setOf("A","B")
    }
}

其中 getName 方法不是必须要复写的,若没有复写,框架会自动生成一个唯一的 name。

2.检查是否有环

当任务图生成之后,那自然就会遇到以下两个问题:

  1. 所依赖的任务不在任务图中;
  2. 生成的任务图当中有环;

首先来看第一个问题:
我们在每次调用 addTask 接口之后,框架会将任务保存在任务 map 当中,其中 key 为任务的 name,如果发现所依赖的任务不在 map 当中,则会立即回调生命周期的 onError 接口。

//任务map
private val taskMap = HashMap<String, TaskNode>()
//生命周期onError接口
fun onError(code: Int, msg: String)

再来看第二个问题,
如果任务图中有环,那么任务就会互相依赖,导致任务无法正确的执行。
任务图中有环,可以分成以下两种情况:

1.任务环独立于 Root 节点以外
在这里插入图片描述

2.任务环不独立于 Root 节点以外
在这里插入图片描述

主要的检查流程如下:

  1. 从 Root 结点出发,依次将无依赖的任务添加到队列当中;
  2. 每次从队列当中取出当前任务并将其移出队列,将其子任务的依赖数量减1,若其子任务的依赖数量小于等于0,则将子任务也添加到队列当中;
  3. 重复2,直到队列为空;
  4. 若任务环不独立于 Root 节点,则在遍历的过程中会出现已经将某个任务移出队列,后续的某个子任务又将其添加到队列中,此时可以判断存在环;
  5. 若任务环独立于 Root 节点,则在队列为空之后,仍然有任务没有遍历到。
  6. 若没有出现4,5两种情况,则可判定任务图当中没有环。

代码实现如下:

private fun checkCycle(): Boolean {
    
    
    val tempQueue = ConcurrentLinkedDeque<TaskNode>() //记录当前已经ready的任务
    tempQueue.offer(rootNode)
    val tempMap = HashMap<String, TaskNode>() //当前所有的任务
    val dependsMap = HashMap<String, Int>() //所有任务所依赖的任务数量
    taskMap.forEach {
    
    
        tempMap[it.key] = it.value
        dependsMap[it.key] = it.value.task.getDepends().size
    }
    while (tempQueue.isNotEmpty()) {
    
    
        val node = tempQueue.poll()
        if (!tempMap.containsKey(node.key)) {
    
    
            Log.i(TAG, "task has cycle ${
      
      node.key}")
            directorListener.forEach {
    
    
                it.onError(Constant.HASH_CYCLE, "TASK HAS CYCLE! ${
      
      node.key}")
            }
            return false
        }
        tempMap.remove(node.key)
        if (node.next.isNotEmpty()) {
    
    
            node.next.forEach {
    
    
                if (dependsMap.containsKey(it.key)) {
    
    
                    var dependsCount = dependsMap[it.key]!!
                    dependsCount -= 1
                    dependsMap[it.key] = dependsCount
                    if (dependsCount <= 0) {
    
    
                        tempQueue.offer(it)
                    }
                }
            }
        }
    }
    if (tempMap.isNotEmpty()) {
    
    
        Log.i(TAG, "has cycle,tasks:${
      
      tempMap.keys}")
        directorListener.forEach {
    
    
            it.onError(Constant.HASH_CYCLE, "SEPERATE FROM THE ROOT! ${
      
      tempMap.keys}")
        }
        return false
    }
    return true
}

3.让任务执行在不同的线程池

在任务检查合法之后,便可以愉快的开始执行了,不同的任务会根据其优先级抛到不同的线程池上执行。

when (task.getPriority()) {
    
    
    Priority.Calculate -> calculatePool.submit(task)
    Priority.IO -> ioPool.submit(task)
    Priority.Main -> mainHandler.post(task)
    Priority.Idle -> {
    
    
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    
    
            Looper.getMainLooper().queue.addIdleHandler {
    
    
                task.run()
                return@addIdleHandler false
            }
        } else {
    
    
            ioPool.submit(task)
        }
    }
}

每个任务执行完成之后,会自动的触发其子任务的执行,子任务会判断当前任务的依赖数量,当依赖数量为0时,便可以真正的执行了。
这里其实会有一个并发的问题,例如任务C依赖于A和B,而A和B执行在不同的线程,当A和B执行完成之后,同时触发C执行,可能会导致依赖数量变化不一致,出现问题。之前的解决方案是加锁,现在我将这些调度的任务全都放在 TaskDirector 中的独立线程中执行,避免了并发的问题,而且也无需加锁。

private fun runTaskAfter(name: String) {
    
    
    // TaskDirector的独立线程,避免并发问题
    handler.post {
    
    
        finishedTasks++
        //记录任务执行的时间
        if (timeMonitor.containsKey(name)) {
    
    
            timeMonitor[name] = System.currentTimeMillis() - timeMonitor[name]!!
        }
        //单个任务执行完成之后,触发下一个任务执行
        if (taskMap.containsKey(name) && taskMap[name]!!.next.isNotEmpty()) {
    
    
            taskMap[name]!!.next.forEach {
    
     taskNode ->
                taskNode.start()
            }
            taskMap.remove(name)
        }
        Log.i(TAG, "finished task:${
      
      name},tasksum:${
      
      taskSum},finishedTasks:${
      
      finishedTasks}")
        //所有任务执行完成之后,触发director回调
        if (finishedTasks == taskSum) {
    
    
            val endTime = System.currentTimeMillis()
            if (timeMonitor.containsKey(WHOLE_TASK)) {
    
    
                timeMonitor[WHOLE_TASK] = endTime - timeMonitor[WHOLE_TASK]!!
            }
            Log.i(TAG, "finished All task , time:${
      
      timeMonitor}")
            runDirectorAfter()
        }
    }
}

4.监听防劣化

防劣化是一个很重要的问题,我们辛辛苦苦优化了半天,结果没过几个版本,启动的时间又变慢了,这可太让人头大了。
针对每个task,我们都增加了监听,自动监听每个任务的执行时间,以及所有任务总体的执行时间。在任务执行完成之后,找某个时间进行上报,这样就能时时监控启动过程了。

abstract class Task : Runnable {
    
    

    ......

    final override fun run() {
    
    
        Log.i(TAG,"start ${
      
      getName()}")
        before.forEach {
    
    
            it(getName())
        }
        execute()
        after.forEach {
    
    
            it(getName())
        }
        Log.i(TAG,"end ${
      
      getName()}")
    }

    ......

}

4.总结

冷启动的场景对用户体验影响比较大,有专门的基础侧的同事监控也挺好的,不过我觉得重要的在于疏,而不在于堵,如何能够真正的做到按需加载才是我们所追求的,而不是一刀切,直接搬出大领导,让业务方束手束脚。

猜你喜欢

转载自blog.csdn.net/hbdatouerzi/article/details/125351187