本文作者: zy
图片来自:unsplash.com
在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿的情况,那么接下来我们就应该思考如何解决这个问题。
背景与现状
在 Android 应用开发的过程中,对于必须要在主线程执行的代码逻辑,可以使用由 Android 系统提供的主线程空闲任务调度器 IdleHandler 来处理。但如果空闲任务调度器执行任务过于耗时,依然会导致 APP 卡顿或者跳帧。另外如果开发者想要移除部分的空闲调度器任务,是无法实现不了的。只能选择全部移除。
分析
为了减少主线程的卡顿,提高主线程资源的利用率,我们通过系统源码了解到页面渲染的部分关键过程。
上图所示,当页面 View 有更新操作时,会通过 Choreographer 去注册一个 VSYNC 信号监听,等待 VSYNC 信号的到来,VSYNC 信号到来后,会执行我们熟知的 measure,layout,draw 方法,然后将视图数据通过 swipeBuffer 移交给屏幕的 DataBuffer 区域,等待进一步处理。在这个过程中,如果绘制操作比较耗时,掺杂了我们的业务逻辑,页面就会变得卡顿,如果每一帧的绘制都是在两个标准的 VSYNC 信号之间完成的,页面操作和展示就会变得非常流畅。分析发现,当一个 VSYNC 信号到来之后,如果页面的绘制能够提前完成,那么主线程会有一段时间的空窗期,如果我们能利用这段空窗期做点事情,那么就可以解决主线程任务过多造成主线程卡顿问题。主线程空窗期示意图如下图所示。
VSYNC 信号到达应用层后,经历 measure,layout,draw 几个阶段,这里统称为 render 阶段,render 阶段结束之后,如果 MessageQueue 没有其它的消息,这时候主线程就会处于空闲状态,等待视图刷新触发下一个 VSYNC 信号的到来。这里我们通过 Choreographer 来监听 VSYNC 信号的到来作为开始标记,以及 render 结束后的信号作为结束标记。结束标记和开始标记之间的时间差就是当前帧率下的主线程实际耗时也就是 render 时长,当前设备标准帧率时长( 图示以 60HZ 的刷新帧率,16.6ms 一帧的周期为基准 )与 render 时长的时间差就是我们可利用的主线程时长,有了这个时长以及 render 结束的触发点,就可以执行我们主线程的任务了。
具体方案
主线程碎片时间管理通过四个模块来实现,分别是帧率耗时监控模块,空闲时间切片模块,耗时任务拆分模块,子任务智能调度模块。 在帧率耗时监控模块,通过 HOOK 系统对象,注入自己的监听回调,来获取当前帧的渲染开始时间点和结束时间时间点。在空闲时间切片模块,生成当前帧可用的空闲时间。利用耗时任务拆分模块获取到可以被调度执行的子任务,最后由子任务智能调度系统负责子任务的调度执行以及记录每个任务在当前 CPU 的执行耗时情况,在初始化的时候通过读取上次 CPU 任务执行耗时的数据生成一个任务耗时记录表,用于给空闲时间切片模块提供时间更加精准的任务匹配,防止出现跳帧的情况。
帧率耗时监控模块
分析模块中,我们阐述了 render 阶段表示的是 View 视图树的计算阶段,包含了视图树的测量,布局,绘制。当完成这些任务之后,将剩下的工作交给系统渲染阶段来处理,系统渲染阶段会负责将视图渲染至屏幕上,这里我们需要关心的就是 render 阶段,这个阶段完成之后,即可认为当前帧的主线程工作完成了,等待接受下一个 VSYNC 信号的到来。在 View 视图树的计算阶段中,由于每一次需要计算页面视图树的复杂程度不一样,因此 VSYNC 中各个刷新周期的 render 阶段耗时也是不一样的,我们就需要监控每一个 VSYNC 信号到来之后 View 视图树计算阶段的耗时。 View 视图树监控( 帧率耗时监控模块 )全流程如下图所示
帧率耗时监控模块执行步骤:
- 步骤一:在应用启动阶段,获取当前进程的系统的 Choreographer 对象
- 步骤二:创建视图帧开始渲染的监听回调,该回调除了首次由开发者手动注入至 Choreographer 对象中,后续的注入均由监听回调自己注入,当监听到渲染开始的回调后,再次将回调自己注入至 Choreographer 对象中,这样就能实现监听每一帧渲染开始的时间点,同时记录帧渲染开始时间
- 步骤三:创建视图帧结束渲染的监听回调,和开始渲染的监听回调注册流程类似,最终也是获取到每一帧渲染结束的时间点,将帧渲染结束时间记录下来
- 步骤四:在监听每一帧渲染结束之后,计算开始时间和结束时间的差值,这就是我们需要的每一帧可用的时间切片
其中 Choreographer 是系统提供用于 View 视图树的计算以及与屏幕交互渲染的类,由 Choreographer 来监听 VSYNC 信号,信号到来之后,就会通知 View 视图树进行计算处理,当处理完成之后,将计算后的数据交给屏幕进行渲染。当前模块利用反射机制向 Choreographer 中注入渲染开始和渲染结束的监控回调,监控代码插入位置如下图所示
帧率的耗时监控就是在 render 阶段,通过插入帧率开始回调监听和帧率结束回调监听来计算得出的。
空闲时间切片
我们可以通过耗时监控模块获取到两个时间戳,分别是 View 视图树计算阶段渲染开始的时间戳和渲染结束的时间戳,我们需要的空闲时间就是两者的差值。View 视图树计算阶段的 render 部分完成之后,视图的绘制就会交给系统进行渲染,而这个渲染的过程是在其他线程和进程进行执行,这样,当前 APP 的主线程就会空闲下来,我们就可以利用这个空闲时间做点其他的时间,这个空闲时间就被称为空闲时间切片
耗时任务拆分
有了主线程可用的空闲时间切片,接下来我们就需要将我们的耗时任务进行一个拆分,如何找到耗时任务呢?这里我们使用 systrace 进行耗时方法采集
上图所示,当前业务有一个 300MS 的主线程耗时逻辑,后面的几个 VSYNC 信号周期都很空闲,我们可以将当前耗时的任务进行拆分切割,然后将拆分后的任务打散至后面空闲的时间切片中延后执行,如图
接下来定义一套数据结构,将拆分的任务当作一个子任务用自定义的数据结构保存起来(要注意内存泄漏的问题,页面销毁后,如果还存在任务未执行,需要把未执行的任务全部清空)
kotlin
class TraceTask(val bucketType: Int = BUCKET_TYPE_PRIORITY_30, val taskId: String = "", private val task: (() -> Unit)) {
fun invokeTask() {
task.invoke()
}
}
到这里,可执行的子任务集就准备好了。
子任务智能调度
空闲时间切片和子任务集生成后,就可以通知任务调度系统进行子任务的执行调度,在空闲时间切片中插入适合当前时间切片执行的任务,如当前空闲时间切片只有 3ms,那么就应该从 3ms 及以下的任务桶中把需要执行的任务选出来,然后执行任务。整个模块的流程图如图所示
子任务智能调度执行步骤:
- 步骤一:由 VSYNC 消息触发的结束监听模块开始执行,获取当前需要添加的子任务,如果没有要添加的子任务就走子任务的执行逻辑,如果存在,就走子任务的数据绑定和子任务添加逻辑
- 步骤二:子任务的数据绑定逻辑,将子任务和页面的生命周期进行绑定,这样做的好处是当页面销毁之后,绑定的子任务会自动删除,防止出现内存泄漏的情况。生命周期绑定之后,还需要绑定该子任务历史执行耗时,该模块是智能任务调度的核心,绑定历史执行耗时信息之后,在取子任务阶段,就可以快速获取到当前时间切片下可执行的任务了
- 步骤三:获取绑定后的子任务,添加到耗时任务表中,使用MAP+链表结构,方便任务的快速获取与增删
- 步骤四:判断当前是否存在子任务,如果存在可执行的子任务,则执行下一步操作,如果不存在可执行的子任务,跳出并结束当前流程。这里的任务查找是查看耗时任务表中是否还有任务元素存在
- 步骤五:判断当前是否为调度超时模式,如果当前非调度超时模式,则获取空闲时间切片剩余可用的时长,通过剩余时长去耗时任务队列中查找当前时长内可用的任务,如果找到可执行任务后,则执行任务,同时减掉当前任务执行时长,获取到更新后的时间切片可用时长,然后回到步骤四继续循环。如果没有找到任务,则结束当前流程
- 步骤六:如果当前为调度超时模式,则忽略剩余切片可用时长,找到耗时任务队列第一个任务元素,获取并执行。
智能任务调度核心
智能任务调度核心主要负责计算出当前任务的实际耗时,这样做的目的是确保任务执行的时长不会超过空闲时间切片的剩余时长,例如:空闲时间切片剩余时长是 6ms ,那么智能任务调度核心就需要负责找出6ms以内能够完成的任务。当前任务第一次的时长是由开发者给出的默认时长( 开发者在自己手机系统上执行后得出的实际任务时长 ),当任务执行一次之后,会将任务在当前系统上的实际耗时保存下来,每条任务会保存最近 5 条数据。后续再取任务时长的时候,会将当前任务的历史执行时长的最大值取出,当作该任务的执行时长保留下来。所有任务执行时长数据会保存在 SD 卡上,在 APP 启动时,子线程进行任务执行时长的数据加载,将数据加载至手机运行内存中,加快后续读取任务时长的速度,在本次任务执行结束之后,需要将获取到新一轮的执行时长更新至内存中,等待页面关闭时,统一将数据写入至 SD 中。 智能任务调度核心时长获取以及保存示意图如图所示
任务队列结构
这里我们我们采用 MAP 表(KEY-VALUE)来存储数据,其中 KEY 为 INT 型,以任务执行耗时作为 KEY,VALUE 为链表结构,链表的增删效率非常高。使用链表的结构来保存当前耗时 KEY 下的所有任务。链表结构如图所示
调度超时模式
空闲时间切片的最大剩余时长不会超过 16.6ms ,在不同机型上,由于机器性能差异,导致各个任务的实际执行耗时可能会超过 16.6ms,在智能任务调度阶段,可能就会出现有个别超时任务一直无法和空闲时间切片的剩余时长匹配上,因此这里会提供一种兜底超时逻辑,当任务队列 1s 内都没有任何任务被调度执行( 60HZ 的情况下,1s 会有 60 次的帧率调用,也就是会有 60 次的任务调度执行),但是队列又不为空,可以说明当前存在异常超时的任务,为了保证所有任务的正常执行,这里会设置一个调度超时模式的标志状态,当进入调度超时模式中后,会上报当前异常任务,由开发者判断当前任务是因为手机性能问题超时,还是任务拆分不合理导致的。而程序也会再次进入判断逻辑中,逻辑判断发现当前处于调度超时模式时,不会检测当前剩余时长,而是直接取 MAP 表中的第一个元素,获取第一个任务并执行。从而保证所有添加的耗时任务,无论是否匹配上,都会得到执行.
总结
通过任务拆分+主线程空闲时间调度的方式,可以有效的利用主线程的空闲时间,让它来合理的帮助我们完成主线程逻辑的执行,而不会对主线程造成拥堵,给用户带来更好的操作体验。
更多岗位,可进入网易招聘官网查看 hr.163.com/