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.抽象成任务图
我们将启动阶段逻辑相对独立的过程都封装成了一个个单独的 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.检查是否有环
当任务图生成之后,那自然就会遇到以下两个问题:
- 所依赖的任务不在任务图中;
- 生成的任务图当中有环;
首先来看第一个问题:
我们在每次调用 addTask 接口之后,框架会将任务保存在任务 map 当中,其中 key 为任务的 name,如果发现所依赖的任务不在 map 当中,则会立即回调生命周期的 onError 接口。
//任务map
private val taskMap = HashMap<String, TaskNode>()
//生命周期onError接口
fun onError(code: Int, msg: String)
再来看第二个问题,
如果任务图中有环,那么任务就会互相依赖,导致任务无法正确的执行。
任务图中有环,可以分成以下两种情况:
1.任务环独立于 Root 节点以外
2.任务环不独立于 Root 节点以外
主要的检查流程如下:
- 从 Root 结点出发,依次将无依赖的任务添加到队列当中;
- 每次从队列当中取出当前任务并将其移出队列,将其子任务的依赖数量减1,若其子任务的依赖数量小于等于0,则将子任务也添加到队列当中;
- 重复2,直到队列为空;
- 若任务环不独立于 Root 节点,则在遍历的过程中会出现已经将某个任务移出队列,后续的某个子任务又将其添加到队列中,此时可以判断存在环;
- 若任务环独立于 Root 节点,则在队列为空之后,仍然有任务没有遍历到。
- 若没有出现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.总结
冷启动的场景对用户体验影响比较大,有专门的基础侧的同事监控也挺好的,不过我觉得重要的在于疏,而不在于堵,如何能够真正的做到按需加载才是我们所追求的,而不是一刀切,直接搬出大领导,让业务方束手束脚。