APM 页面加载耗时校准
千诺2022-03-14

在最新的 APM 自动化页面加载耗时计算中,剔除了对用户页面加载体验无效的元素,聚焦页面加载体验中的核心元素,既给了业务相对的自由度,又达到了一定的加载体感准确性。

背景

APM 的全称叫做 Application Performance Monitor,属于应用性能监控部分。在手淘的 APM 中有一项特殊的数据,叫做页面可视耗时,不同于业界常规的技术视角阐述数据,我们更倾向于从用户体验交互进行阐述。

以 Activity 实现的页面为例,在页面加载过程中,用户关心的是从点击开始到最后页面完全呈现,直至用户可以进行交互的一个过程,而不是单纯 Activity 的 Lifecycle 耗时,更不是技术视角的一个阶段数据。

更逼近用户体验的数据是我们在页面加载上的追求。接下来我将从淘宝 APM 如何从用户角度实现可视计算的历史进行介绍,并深入详解淘宝对页面加载耗时校准所做一些努力与改变。

从0到1:可视计算

页面可视耗时可以拆解成起点与终点,起点争议相对较少,使用的是 Activity onCreate 时间、 Fragment onFragmentPreAttached 时间以及页面跳转时间,下面主要针对可视终点进行讨论:

为什么要引入可视计算?

在 APM 中,对于页面打开耗时统计,常见有三类:Lifecycle、首帧、自定义埋点。

监控 Lifecycle 是 Android 天然的一套监控耗时的方案,奈何生命周期的耗时只是页面加载过程中的一个阶段,具有一定的技术需求观测性,但是正如文章开头所言,它其实不是我们追求的用户体验上的页面加载时长。

从 Android API 19 之后,Android 在系统 Log 中增加了 Display 的 Log 信息,它是 Activity 完成首帧的时间,可以看得出来,它也是 Google 比较重视的一个点,但它还是页面加载过程中的一个分阶段监控点,不是我们的目标结束点。

除此之外,Google 还为我们提供了 reportFullyDrawn 接口,当业务认为渲染完成时进行调用。因为业务各自不一的实现往往导致首帧距离真正的页面加载完成有相当一段距离。当然不只是 Google 意识到这一点,FaceBook 也在这方面使用上做了些有趣的尝试。

reportFullyDrawn 接口与我们常说的业务埋点其实是一回事,这样的方式统计出来的数据是相对不错与精准的,但是仍会面临几个问题:

业务埋点带来了标准的不统一

每一个页面的实现都是不一样的,每一个业务方对自己的可视终点有不一样的定义。有的人认为页面第一张图片出来了就是可视,有些人认为页面数据请求回来了就是可视,有些人认为第一帧上屏就是可视,一千个人心中有一千个哈姆雷特,这也导致埋出来的数据往往千差万别,甚至会出现肉眼看见更慢的页面数据更好的情况。

框架复杂,在现有基础上改动成本有些过高

当前淘宝首页会有 N 多个模版,并且模版间还存在组合关系。淘宝首页是根据模版下发进行渲染数据,如果说对模版改造有些不太现实。同时,现阶段的淘宝,业界中能见到的跨端框架,在淘宝都能看到影子,让所有框架改造一个统一的可视标准,让所有业务方手动打入可视时间有一些不切实际。

业务与框架侧都不具有全局视角,导致埋点不准确

往往业务方将数据请求回来交给容器渲染时就认为可视了,可却忽略了容器处理数据需要时间,数据上屏需要时间,容器将图片 URL 交给图片库进行加载亦需要时间。对于业务方而言,上诉耗时,对他是透明并不感知的,对于框架而言,只是进行一次渲染,并不知是页面加载还是刷新页面。

基于上诉问题,我们将解决方案放在业务无入侵、统一可视终点标准上,并最终提出了 8060 算法。

可视算法初步实现

8060 可视算法规则很简单,主要是将屏幕范围内的 View,对 X、Y 轴进行投影,当覆盖 X 轴长度的 80%、Y 轴的 60% 就认为是可视。

为了将可视计算性能进一步提升,我们决定异步计算可视终点

异步计算可视是 APM 中比较巧妙的实现,因为可视算法涉及到 UI,Android 开发同学都知道,对 UI 的操作不是必须在主线程中实现,只需要保证操作的线程和 UI 的创建线程保持一致即可,那么 Activity 界面的 UI 正常情况下都是在主线程创建的,这是否意味着可视算法只能在主线程中执行呢?答案显然是不一定。其实 Android 系统需要保证的是 UI 的状态,通过对线程的保护,可以很好的保证 UI 状态的一致性。

如果多线程对 UI 进行写操作,那么会导致 UI 的状态被破坏;但是如果是多线程读操作呢,多线程读 UI 的状态并不会破坏 UI 的状态,只会导致一个问题,即在读的过程中读到一个不稳定的状态,导致程序异常,比如说 NPE,APM 在尝试异步 UI 可视算法的起初,就遇到了 NPE 的一些问题。不过这到不是一个很严重的问题,因为当你读到了一个不稳定的状态时,恰恰说明 UI 的状态还不稳定,意味着 UI 在变化。抛弃本次计算,重新开始计算,直到有一次完整的页面计算。这个算法其实来源于读写锁的设计思路,如果读不会导致数据变化,那么在 UI 线程在读操作的时候异步线程也可以同时的读 UI,并不会对 UI 的状态导致破坏,程序依旧能正常运行。异步线程只需要保护好自己的运行状态,能正确处理一些异常即可。

可视算法初步演变

算法就此而止步了吗?其实并没有,我们认识到元素是有差异的,不能同一标准而视之,同时我们在计算方式也在做新的尝试。

在手淘中,又孵化出另一种计算方式:将所有合格 View 的面积累加起来,计算出来的面积占整个屏幕的面积占比就是加载比率,当加载比率超过 80%,就认为页面可视了。

针对 View 不再是无差别计算,我们有了更细的规则:

  1. View 在全部或者部分在屏幕范围内,且 Visibility 必须为 View.VISIBLE
  2. 只针对 View 进行计算,ViewGroup 不在计算范围之列,且不是 ViewStub
  3. 如果是 ImageView,Drawable 必须为 BitmapDrawable、NinePatchDrawable、AnimationDrawable、PictureDrawable
  4. 如果是 TextView,必须要有文字
  5. 如果是 EditText,判断是否已经聚焦,如果聚焦,整个页面直接加载完成
  6. 其他 View 默认加载完成

从1到2:Native页面校准

经过 APM 前期的发展,基础的页面加载耗时已经有了,但是准确性上还有待提高。

为了更为接近用户体验,我们决定对所有核心链路上的页面加载耗时进行校准(这里的用户体验终点即页面呈现完成)。其中心是对页面视图的理解:什么样的视图是用户关心的?什么视图是影响用户体感加载耗时的?

从这个角度上,校准的核心动作就呼之欲出了:提取视觉有用的有效元素出来计算,剔除用户不能感知的无效元素

算法的迭代

算法的迭代主要内容是解决在计算时,提取视觉有用的有效元素和剔除用户不能感知的无效元素问题

何为有效元素?我们认为在一个页面上,最重要的是上面展示给用户的图文信息,除此之外,就是外加的一些操作元素,主要包括按钮和输入元素。当这些元素出现并占满屏幕大部分的时候,我们就可以认为,我们内容已经完整呈现给用户了。

通过观察发现,我们还发现页面上很多元素并不是一直是无效或者有效的,比如说常规页面需要上屏的一张图片,图片库需要先加载默认图,等到图片库网络请求完成后才上屏,只有上屏了的图片才是有效元素,这一部分可以跟标准图片库打通,并在手淘中自定义图片库中推行 APM 标准即可(少数)。

除此之外的元素,我们都将其默认视作无效元素,之前的算法,全部认为是有效的,所以才会导致可视终点过于靠前。例如在手淘首页,下发的模版中会产生大量无效控件,例如透明控件、底色控件。将不能识别的元素默认视作无效这一举动无疑会将计算的成功率拉低,但是会提高我们数据的准确性。

同时基于双端能力对齐的想法,并没有在 View 面积之和的计算上进行修改,而是基于X、Y 轴投影的算法上进行修正:
将所有的合格有效 View 对 XY 轴分别进行投影,同时将有无效标记的 View 对 XY 轴分别进行投影,当合格的投影减去无效 View 投影,分别覆盖 Y 轴的 80%,X 轴的 60%,那么页面加载完成

利用此算法进行首页校准前后的终点对比图

现在投影算法已经是 APM 里面的推荐算法。

支持 View 打标

计算时候,会识别有效元素和无效元素的标记,这个标记是什么?在手淘中,哪些又做了这些标记呢?

页面元素铺满屏幕不等于加载完成。我们发现很多页面为了追求更好的用户体验,会生成鱼骨图、页面打底图、图片打底图,同时页面为了追求更为完美的展示效果,自定义 View 也是页面上的常规操作。为了支持各种各样的 case,APM 采用 View tag 的方式进行解决,APM 中提供三个 tag 进行选择:

     /**
     * 当前状态是无效的View,但是仅仅表示当前状态,有可能变成有效,例如 ImageView
     */
    String APM_VIEW_VALID = "valid_view";
    /**
     * 当前状态是有效的View
     */
    String APM_VIEW_INVALID = "invalid_view";
    /**
     * 需要完全忽略的无用 View,这个 View 完全是计算的噪点,例如鱼骨图
     */
    String APM_VIEW_IGNORE = "ignore_view";

在手淘中,需要打标的主要包括:鱼骨图、自定义图片库、自定义View(继承于 ImageView、TextView 不需要打标)。而这一部分需要打标的 View 出现频次较低,改动较少,故而在成本较低情况下,可以大大拉高准确度。


这个变动毫无疑问是巨大的,也给 APM 在与业务低耦合的情况下,带来了更大的灵活性,例如,详情打开后会有一个全局遮罩的鱼骨图,对鱼骨图打标后,然后配合图片库打标,详情的页面加载准确性大幅提升

通过线下测试可以发现,详情的鱼骨图遮罩时间较长,在遮罩过程中会进行页面加载,如果不对鱼骨图进行打标,那么 APM 计算出来的加载时长与用户体感加载时长差距较大。下图是详情页面校准前后的线上数据,我们可以发现 7 月到 8 月线上数据有一个非常明显的数据跃迁:

通过给 View 打上是否合法的 tag,除了给 APM 带来了灵活性,也给业务带来了更大的自主性。对于高度复杂的业务来说,依旧存在自定义页面的结束时间的诉求,但是这种自定义结束时间并不是直接给 APM 塞一个时间戳,而是在自身页面加载逻辑完成之时,给 View 一个标记,标记此 View 完成了加载。例如,对于直播来说,他们需要的页面加载耗时,并不是直播页面的首帧,而是等待指定的直播小组件加载完成才算完成。如此高度定制的需求只需要做两步就可以完成:

  1. 在创建 View 树的时候,在根 View 上打上非法标记
  2. 当直播小组件加载完成,将根 View 上的非法标记改成合法标记

这是线上数据,APM 在校准上线后,发现更为符合业务方诉求与预期:

设置页面加载阈值

是否任意一个页面都适合同一加载阈值呢?答案当然不是的。

页面的布局、机型的适配直接影响着页面加载的比例,所以页面加载的上限在不同的页面中,也应该是不同的。在线下测试中发现,有些页面加载在某些手机上勉强了达到 80% 的阈值,有些手机上一直达不到,造成大量数据计算不成功。

基于以上考虑,APM 提供了设置单个页面加载阈值的口子,通过配合校准的各种改造,页面的加载时长准确性大幅度提高,同时 Android 的计算成功率也飙升。

errorCode = 0 表示计算成功,由 12% 上升到 78%

自定义页面根 View

APM 计算的根结点默认是页面上的 DecorView,往下遍历的根结点是否是合理的呢?存在修改的可能吗?是存在的,自定义页面根 View 可以更细粒度的控制页面。

对于页面的理解一定是 Activity 或者 Fragment 吗?不是的,我们既然讨论的时候用户体感加载时长,那么我们应该更多的从用户视角去考虑这件事情。例如,逛逛页面上有两个 tab,基于用户角度,我们更愿意理解成两个页面,推荐页面和关注页面。

以逛逛关注页面为例,点击关注 tab 时候创建了页面,整个关注页面也只是整个页面的一部分,下图为校准后的效果:

在 APM 中提出了 Page (页面)的概念,每一个 Page 有一个对应的 pageRootView,使用 pageRootView 来进行页面加载比率计算。

当我们仔细去观察页面的 View 树结构时,还发现自定义页面根结点带来了更大的灵活性,对于异常 case 也有了更多的处理手段,这里举一个我们在校准过程中遇到的例子,逛逛首页加载校准:

逛逛的首页是一个 Fragment,按道理说可以直接进行度量,但是在校准的时候发现:逛逛首页(推荐)有一个全局背景图。当尝试使用打标解决的时候才发现,逛逛业务定义了自身框架的 DSL,整个页面使用的是 DSL 进行编写(类 RN 原理),所有 ImageView 都是相同的 View,并没有任何特殊性,在端侧根本不感知这个背景图。这就意味着打无效标的办法不能用了。

那么还有其他低成本的办法吗?当然有的,查看逛逛首页的布局,发现全局背景图在整棵 View 树非常靠上的位置,与真正有效的 View 节点并没有关联,那么只需要将有效的子 View 树的根结点作为计算的起点就可以了。

注:在原始 APM 计算逻辑中,是使用整棵 View 树的根节点来进行向下遍历计算的

最后的方案:更换逛逛首页的页面根结点(pageRootView)。

下面是逛逛首页的校准效果,如果没有进行校准,那么当背景图出现的时候就是页面加载的结束点,而且二刷也没完。通过打标解决了二刷问题,通过修正页面根 View 解决背景图问题,由于页面 ImageView 存在动画,所以加载完成后会有一个渐显的动画,当前 APM 认为这个渐显动画不影响可视点:

从1到2:H5 的标准

在 H5 等场景下,WebView 的进度条并不一定代表真实的加载进度,导致 APM 的页面加载无痕算法在 H5 场景下准度很差。为此,我们引入 JSTracker (前端框架层)进行计算,与此同时,在 Android 中,UC 内核自主计算的可视时间也被引入其中。

UC 内核计算的可视时间原理又是什么呢?在页面加载的过程中,记录所有的渲染帧,在页面加载结束之后,回溯检查每一帧,图片渲染面积首次达到最大值的那一帧记为可视时间,而 JSTracker 计算原理类似,不同的是,JSTracker 是在前端框架层进行计算。

前端自定义终点

除了 JsTracker/T2 的页面加载终点之外,还有没有其他办法来体现自身业务的自定义页面加载终点呢?有的。APM 与前端框架定下了此规范。

对于前端 H5 来说,如果需要自定义页面终点,首先需要通过一种方式告诉容器,自身是需要自定义终点的,告诉的方式就是:增加 APM 规定的 HEADER 标签。

容器读取到了这个 HEADER 标签,表明当前 H5 页面遵守规范,需要自定义终点,将会通过 APM 提供的 JSBridge 提供页面加载的终点。此外,APM 也提供埋入参数和阶段数据的 JSBridge。

在大促会场场景下,使用此方案支持了性能优化结果产出,其产出的结果中,除了页面可视之外,还包括秒开率,系统耗时,H5 容器框架耗时,前端耗时等指标,结合 AB 产出对比数据结果,同时结合设备分级数据细化在不同手机等级下的数据。

挑战:多容器内嵌页面

在手淘中有各种各样的跨端容器框架,如 weex 等。存在一个页面上多种容器并存的情况,容器与容器之间,数据如何兼容,APM 提出了自身的仲裁方案。

在店铺一个页面中,有可能存在多种框架混合使用的情况,举个例子,可以用 WebView 实现一个广告推荐,可以用 Weex 渲染出整个页面。

如果每一个容器直接将自己的页面加载时间点通过一个接口直接打进来,APM 选取哪一个作为页面真正的加载结束时间戳呢?如果选取最小的时间戳,如果对应的 View 不是页面主要元素,那么这个值比体感加载时长小,如果选取最大的时间戳,就有可能偏慢。

其实在 Native 角度,每一个容器只是 View 树上的一个节点,APM 只需要关心这个子 View 是否加载完成,然后使用页面加载算法计算(8060算法),就可以知道整个页面是否加载完成。

那么问题就简化成如何知道这个子 View 是否加载完成。当前 APM 支持对 View 打标,当 View 没有加载完成的时候,就会打上没有加载完成的 tag,完成页面加载就会打上完成的 tag。由于容器知道自己的加载状态,就只需要在合适的时候,给自己的 View 打上合法的 tag 即可。

由于 APM 在遍历 View 树的时候,一旦发现 View 打上了 tag,就不再往下遍历,直接确定了当前 View 的状态,起到了数据仲裁的效果

写在最后

对于 APM 页面加载耗时校准而言,目前 APM 还只是向前走了一小步。在最新的 APM 自动化页面加载耗时计算中,剔除了对用户页面加载体验无效的元素,聚焦页面加载体验中的核心元素,既给了业务相对的自由度,又达到了一定的加载体感准确性。