Rocket-Android启动任务调度框架

1,979 阅读7分钟

随着应用逐渐成熟,启动所执行的任务也越来越多,每次版本迭代就会发现启动耗时又增加了,然后开始找原因,优化任务,下次版本发布重复这一过程,如此反复。

这里有两个问题

  1. 启动时间优化能到什么地步?
  2. 为什么每次版本迭代都会增加启动时间?

我们先从现状谈起。

现状

在Android中,启动一般分为三部分,application(应用入口) -> splash页(展示logo、广告等等) -> 首页。

第一阶段

rocket_process_01

这是一个app开发初期的启动概览图,我们会有若干sdk或者代码需要初始化,所以都塞进application oncreate中,当然了,如果有些sdk必须在子线程中初始化,那我们在后面起一个线程做这个事情,为了保证用户进入首页的时候已经执行完所有任务了,那我们可能会在闪屏页等待,直到异步线程做完...

这里问题比较多,比如异步线程完全可以和主线程并发执行,主线程的任务有些还是可以放子线程做的等等,所以稍有经验的开发者会忍不住重构一下,进入第二阶段。

第二阶段

rocket_process_02

这一阶段,我们把异步线程提前执行,这样保证了主线程子线程的并发,而且,还把一些代码放进了异步线程,主线程的代码会大大减少,这样,主线程执行很久的时间就会大大缩短。

做到这样,似乎已经很不错了,但是我们不能满足,继续思考一下,现在的启动时间短板在哪里?异步线程执行时间太久?主线程执行时间能不能再少?另外,首页绘制时间太久了,闪屏页除了等待似乎没什么作用了...

rocket_process_03

这么一想,好像还有很多优化空间,所以我们再重构一下,进入第三阶段。

第三阶段

在第三阶段,我们希望:

  1. 增加并发量,充分利用cpu,缩短异步线程的执行时间
  2. 首页无需等待所有任务完成
  3. 便于统计监控
  4. 可持续,版本迭代理论上不会显著增加启动时间

那么启动的模型大概长这样:

rocket_process_04

首先,我们先让首页减少布局层级、减少不必要的view,然后去除闪屏页,使用首页的WindowBackground来实现类似效果,最后,代码段都精细化并任务化,并把这些任务托管给框架来安排执行,我给这个框架取个名字,叫做"Rocket"。

rocket_framework_overview.png

Rocket

Rocket的设计以任务为最小单位,每个任务执行在哪个线程、执行的任务具体是什么、任务依赖关系等等都可以让任务自己定制,而Rocket则负责完成这些任务最快且正确地执行。

下面以7个关键问题来说明Rocket的设计和实现。

问题1:什么样的代码段需要创建线程?

启动的任务很多,有些任务耗时,有些任务不耗时,如果我们把所有的代码都细分并且都创建线程,那么可能启动的时候会创建几十甚至上百的线程,创建线程的时间、线程切换的开销或许比任务本身更耗时。

所以对于这个问题,我们需要权衡任务的耗时是不是值得让我们创建线程,这里我没有明确的耗时时间的分界点,毕竟对于不同的app、不同的设备、不同的任务都是不一样的情况。

问题2:使用哪种线程池?

需要事先说明的是,Rocket不会强制使用某种线程池,每个任务的执行线程由任务自己控制。

首先考虑如下两个方法的区别

System.currentTimeMillis()
SystemClock.currentThreadTimeMillis()

大家都知道,我们的代码都执行在线程上,cpu会在不同的线程之间切换执行,所以,SystemClock.currentThreadTimeMillis()就是cpu分配在当前线程的时间片。

然后考虑如下任务类型的区别

  • I/O密集
  • CPU密集

这里举几个例子

I/O密集任务:磁盘文件处理,网络传输(socket) CPU密集:计算任务,比如排序,动画

根据这些例子,实际上我们也可以知道这些任务的特性,I/O密集的任务,线程处于wait状态的时间会比较多,消耗的cpu较少,而CPU密集的任务,线程基本处于执行状态,消耗cpu较多,所以对于I/O密集任务,我们可以创建较多的线程,比如使用Executors.newCachedThreadPool()线程池,而对于CPU密集任务,如果创建了大量线程,那么只会徒增cpu切换的开销,反而增加了执行时间,所以我们一般会使用固定线程数量的线程池,比如Executors.newFixedThreadPool(5)

题外话:既然网络传输属于I/O密集任务,为什么volley等等网络库会使用类似固定数量的线程池呢?

实际上,我的思考有两点,纯属猜测,如有异议,希望不吝赐教:

  1. 网络请求时间比较久,为了避免创建大量线程
  2. 最重要的是,网络请求需要有请求队列策略,比如FIFO,LIFO等等,而使用类似CachedThreadPool是没有等待队列的

所以现在回到问题上来,用哪种线程池?

事实上这也应该根据你的app的实际状况决定,我这里的情况是:任务大多数执行时间短,且处于等待时间较长,也就是说,大部分任务属于I/O密集任务,所以我这里使用CachedThreadPool线程池。

问题3:如何处理任务之间的依赖关系?

理论上,外部任务只需要知道自己依赖其他哪些任务即可,所以Rocket仅仅给任务提供了List<String> dependsOn()接口,用于表示这一点。 而为了保证执行的顺序(比如B依赖A,那么B应该等待A执行完成才开始),我们需要对任务进行排序,实际上,任务间的依赖就是有向图的数据结构,而排序算法也就是有向图的拓扑排序算法。

rocket_graph2

有向图的拓扑排序保证了所有任务,如果B依赖A,那么B必然在A后面,它使用邻接表来存储每个点及邻接点。算法描述:找到所有入度为0的点添加到0入度的队列中,遍历该队列,删除该点(输出),并让邻接点的入度-1,如果邻接点入度为0,那么添加到0入度的队列中,循环,直到没有点为止。如果遍历次数多于点的个数,说明有环。

保证了执行顺序之后,我们只需要逐个执行任务即可。

问题4:首页如何保证任务执行完成?

首先,首页并不需要等待所有的任务完成,而只需要等待它需要的几个任务完成即可,所以Rocket提供了ensureTask接口,表述页面或者代码依赖了哪些任务,这个接口会一直wait,直到任务完成。

问题5:有些任务非常重要,但是很耗时,怎样让它拿到更多的cpu时间片?

A任务非常耗时,同时首页又必须等A任务完成之后才可以进入,在启动任务并发的时候,A只是其中的一个任务,如果没有区分的话,cpu都是一视同仁的,所以,Rocket提供了优先级的接口,可以设置A任务的优先级大于其他任务,那么cpu就会更青睐A。

问题6:如何接入Rocket,需要注意什么?

接入Rocket:QuickStart

需要注意的是,添加的任务应该清楚自己的依赖关系并在dependsOn()接口声明。

问题7:优化效果如何在迭代过程中保持?

接入Rocket之后,这种情况会显著增加启动时间:

首先,首页依赖该任务,同时满足下面的其中一点

  • 任务依赖当前最长任务链中的任务
  • 本身非常耗时,超过了当前最长任务链

其他一般的情况下不会明显增加启动时间。

最后

应用性能优化是一项长期且艰巨的任务,除了启动时间优化,还有很多优化任务等待着我们去解决...

另外,这里有个辅助性能优化的工具github.com/Kyson/Andro…,用于实时查看Android性能数据,希望能有帮助。