背景
随着移动互联网的快速发展,移动应用越来越注重用户体验。美团技术团队在开发过程中也非常注重提升移动应用的整体质量,其中很重要的一项内容就是页面的加载速度。如果发生冷启动时间过长、页面渲染时间过长、网络请求过慢等现象,就会直接影响到用户的体验,所以,如何监控整个项目的加载速度就成为我们部门面临的重要挑战。
对于测速这个问题,很多同学首先会想到在页面中的不同节点加入计算时间的代码,以此算出某段时间长度。然而,随着美团业务的快速迭代,会有越来越多的新页面、越来越多的业务逻辑、越来越多的代码改动,这些不确定性会使我们测速部分的代码耦合进业务逻辑,并且需要手动维护,进而增加了成本和风险。于是通过借鉴公司先前的方案 Hertz( 移动端性能监控方案Hertz ),分析其存在的问题并结合自身特性,我们实现了一套无需业务代码侵入的自动化页面测速插件,本文将对其原理做一些解读和分析。
现有解决方案 Hertz( 移动端性能监控方案 Hertz](https://tech.meituan.com/hertz.html) ) * 手动在 Application.onCreate() 中进行SDK 的初始化调用,同时计算冷启动时间。
手动在 Activity 生命周期方法中添加代码,计算页面不同阶段的时间。
手动为 Activity.setContentView() 设置的 View 上,添加一层自定义父 View,用于计算绘制完成的时间。
手动在每个网络请求开始前和结束后添加代码,计算网络请求的时间。
本地声明 JSON 配置文件来确定需要测速的页面以及该页面需要统计的初始网络请求 API,getClass().getSimpleName() 作为页面的 key,来标识哪些页面需要测速,指定一组 API 来标识哪些请求是需要被测速的。
现有方案问题:
冷启动时间不准:冷启动起始时间从 Application.onCreate() 中开始算起,会使得计算出来的冷启动时间偏小,因为在该方法执行前可能会有 MultiDex.install() 等耗时方法的执行。
特殊情况未考虑:忽略了ViewPager+Fragment 延时加载这些常见而复杂的情况,这些情况会造成实际测速时间非常不准。
手动注入代码:所有的代码都需要手动写入,耦合进业务逻辑中,难以维护并且随着新页面的加入容易遗漏。
写死配置文件:如需添加或更改要测速的页面,则需要修改本地配置文件,进行发版。
目标方案效果:
自动注入代码,无需手动写入代码与业务逻辑耦合。
支持 Activity 和 Fragment 页面测速,并解决 ViewPager+Fragment 延迟加载时测速不准的问题。
在 Application 的构造函数中开始冷启动时间计算。
自动拉取和更新配置文件,可以实时的进行配置文件的更新。
实现
我们要实现一个自动化的测速插件,需要分为五步进行:
测速定义:确定需要测量的速度指标并定义其计算方式。
配置文件:通过配置文件确定代码中需要测量速度指标的位置。
测速实现:如何实现时间的计算和上报。
自动化实现:如何自动化实现页面测速,不需要手动注入代码。
疑难杂症:分析并解决特殊情况。
测速定义
我们把页面加载流程抽象成一个通用的过程模型:页面初始化 -> 初次渲染完成 -> 网络请求发起 -> 请求完成并刷新页面 -> 二次渲染完成。据此,要测量的内容包括以下方面:
项目的冷启动时间:从 App 被创建,一直到我们首页初次绘制出来所经历的时间。
页面的初次渲染时间:从 Activity 或 Fragment 的 onCreate() 方法开始,一直到页面 View 的初次渲染完成所经历的时间。
页面的初始网络请求时间:Activity 或 Fragment 指定的一组初始请求,全部完成所用的时间。
页面的二次渲染时间:Activity 或 Fragment 所有的初始请求完成后,到页面 View 再次渲染完成所经历的时间。
需要注意的是,网络请求时间是指定的一组请求全部完成的时间,即从第一个请求发起开始,直到最后一个请求完成所用的时间。根据定义我们的测速模型如下图所示:
配置文件
接下来要知道哪些页面需要测速,以及页面的初始请求是哪些 API,这需要一个配置文件来定义。
我们定义了一个 XML 配置文件,每个 <page/> 标签代表了一个页面,其中 id 是页面的类名或者全路径类名,用以表示哪些 Activity 或者 Fragment 需要测速; tag 代表是否为首页,这个首页指的是用以计算冷启动结束时间的页面,比如我们想把冷启动时间定义为从 App 创建到 HomeActivity 展示所需要的时间,那么 HomeActivity 的 tag 就为 1;每一个 <api/> 代表这个页面的一个初始请求,比如 HomeActivity 页面是个列表页,一进来会先请求 config 接口,然后请求 list 接口,当 list 接口回来后展示列表数据,那么该页面的初始请求就是 config 和 list 接口。更重要的一点是,我们将该配置文件维护在服务端,可以实时更新,而客户端要做的只是在插件 SDK 初始化时拉取最新的配置文件即可。
测速实现
测速需要实现一个 SDK,用于管理配置文件、页面测速对象、计算时间、上报数据等,项目接入后,在页面的不同节点调用 SDK 提供的方法完成测速。
冷启动开始时间
冷启动的开始时间,我们以 Application 的构造函数被调用为准,在构造函数中进行时间点记录,并在 SDK 初始化时,将时间点传入作为冷启动开始时间。
这里说明几点:
SDK 中所有的时间获取都使用 SystemClock.elapsedRealtime() 机器时间,保证了时间的一致性和准确性。
冷启动初始时间以构造函数为准,可以算入 MultiDex 注入的时间,比在 onCreate() 中计算更为准确。
在构造函数中直接调用 Java 的 API 来计算时间,之后传入 SDK 中,而不是直接调用 SDK 的方法,是为了防止 MultiDex 注入之前,调用到未注入的 Dex 中的类。
SDK 初始化
SDK 的初始化在 Application.onCreate() 中调用,初始化时会获取服务端的配置文件,解析为 Map<String,PageObject> ,对应配置中页面的 id 和其配置项。另外还维护了一个当前页面对象的 MAP<Integer, Object> ,key 为一个 int 值而不是其类名,因为同一个类可能有多个实例同时在运行,如果存为一个 key,可能会导致同一页面不同实例的测速对象只有一个,所以在这里我们使用 Activity 或 Fragment 的 hashcode() 值作为页面的唯一标识。
页面开始时间
页面的开始时间,我们以 Activtiy 或 Fragment 的 onCreate() 作为时间节点进行计算,记录页面的开始时间。
这里的 getConfigModel() 方法中,会使用页面的类名或者全路径类名,去初始化时解析的配置 Map 中进行id 的匹配,如果匹配到说明页面需要测速,就会创建测速对象 PageObject 进行测速。
网络请求时间
一个页面的初始请求由配置文件指定,我们只需在第一个请求发起前记录请求开始时间,在最后一个请求回来后记录结束时间即可。
每个页面的测速对象,维护了一个请求 url 和其状态的映射关系 SparseIntArray ,key 就为请求 url 的 hashcode,状态初始为 NONE 。每次请求发起时,将对应 url 的状态置为 LOADING ,结束时置为 LOADED 。当第一个请求发起时记录起始时间,当所有 url 状态为 LOADED 时说明所有请求完成,记录结束时间。
渲染时间
按照我们对测速的定义,现在冷启动开始时间有了,还差结束时间,即指定的首页初次渲染结束时的时间;页面的开始时间有了,还差页面初次渲染的结束时间;网络请求的结束时间有了,还差页面的二次渲染的结束时间。这一切都是和页面的 View 渲染时间有关,那么怎么获取页面的渲染结束时间点呢?
由 View 的绘制流程可知,父 View 的 dispatchDraw() 方法会执行其所有子 View 的绘制过程,那么把页面的根 View 当做子 View,是不是可以在其外部增加一层父 View,以其 dispatchDraw() 作为页面绘制完毕的时间点呢?答案是可以的。
我们自定义了一层 FrameLayout 作为所有页面根 View 的父 View,其 dispatchDraw() 方法执行super 后,记录相关页面绘制结束的时间点。
测速完成
现在所有时间点都有了,那么什么时候算作测速过程结束呢?我们来看看每次渲染结束后的处理就知道了。
该方法用于处理渲染完毕的各种情况,包括初次渲染时间、二次渲染时间、冷启动时间以及相应的上报。这里的冷启动在 callback.onPageShow(this) 是如何处理的呢?
还记得配置文件中 tag 么,他的作用就是指明该页面是否为首页,也就是代码段里的 isMainPage 参数。如果是首页的话,说明首页的初次渲染结束,就可以计算冷启动结束的时间并进行上报了。
上报数据
当测速完成后,页面测速对象 PageObject 里已经记录了页面(包括冷启动)各个时间点,剩下的只需要进行测速阶段的计算并进行网络上报即可。
自动化实现
有了SDK,就要在我们的项目中接入,并在相应的位置调用 SDK 的 API 来实现测速功能,那么如何自动化实现 API 的调用呢?答案就是采用 AOP 的方式,在 App 编译时动态注入代码,我们实现一个 Gradle 插件,利用其 Transform 功能以及 Javassist 实现代码的动态注入。动态注入代码分为以下几步:
初始化埋点:SDK 的初始化。
冷启动埋点:Application 的冷启动开始时间点。
页面埋点:Activity 和 Fragment 页面的时间点。
请求埋点:网络请求的时间点。
初始化埋点
在 Transform 中遍历所有生成的 class 文件,找到 Application 对应的子类,在其 onCreate() 方法中调用 SDK 初始化 API 即可。
最终生成的 Application 代码如下:
冷启动埋点
同上一步,找到 Application 对应的子类,在其构造方法中记录冷启动开始时间,在 SDK 初始化时候传入 SDK,原因在上文已经解释过。
页面埋点
结合测速时间点的定义以及 Activity 和 Fragment 的生命周期,我们能够确定在何处调用相应的 API。
Activity
对于 Activity 页面,现在开发者已经很少直接使用 android.app.Activity 了,取而代之的是 android.support.v4.app.FragmentActivity 和 android.support.v7.app.AppCompatActivity ,所以我们只需在这两个基类中进行埋点即可,我们先来看 FragmentActivity。
注入代码后,在 FragmentActivity 的 onCreate 一开始调用了 onPageCreate() 方法进行了页面开始时间点的计算;在 setContentView() 内部,直接调用 super,并将页面根 View 包装在我们自定义的 AutoSpeedFrameLayout 中传入,用于渲染时间点的计算。然而在 AppCompatActivity 中,重写了setContentView()方法,且没有调用 super,调用的是 AppCompatDelegate 的相应方法。
这个 delegate 类用于适配不同版本的 Activity 的一些行为,对于 setContentView,无非就是将根 View 传入 delegate 相应的方法,所以我们可以直接包装 View,调用 delegate 相应方法并传入即可。
对于 Activity 的 setContentView 埋点需要注意的是,该方法是重载方法,我们需要对每个重载的方法做处理。
Fragment
Fragment 的 onCreate() 埋点和 Activity 一样,不必多说。这里主要说下 onCreateView() ,这个方法是返回值代表根 View,而不是直接传入 View,而 Javassist 无法单独修改方法的返回值,所以无法像 Activity 的 setContentView 那样注入代码,并且这个方法不是 @CallSuper 的,意味着不能在基类里实现。那么怎么办呢?我们决定在每个 Fragment 的该方法上做一些事情。
我们利用一个 boolean 类型的标志位,进行递归调用 onCreateView() 方法:
最初调用时,会将标志位置为 false,然后递归调用该方法。
递归调用时,由于标志位为 false 所以会调用原有逻辑,即获取根 View。
获取根 View 后,包装为 AutoSpeedFrameLayout 返回。
并且由于标志位为 false,所以在递归调用时,即使调用了 super.onCreateView() 方法,在父类的该方法中也不会走 if 分支,而是直接返回其根 View。
请求埋点
关于请求埋点我们针对不同的网络框架进行不同的处理,插件中只需要配置使用了哪些网络框架即可实现埋点,我们拿现在用的最多的 Retrofit 框架来说。
开始时间点
在创建 Retrofit 对象时,需要 OkHttpClient 对象,可以为其添加 Interceptor 进行请求发起前 Request 的拦截,我们可以构建一个用于记录请求开始时间点的 Interceptor,在 OkHttpClient.Builder() 调用时,插入该对象。
而该 Interceptor 对象就是用于在请求发起前,进行请求开始时间点的记录。
结束时间点
使用 Retrofit 发起请求时,我们会调用其 enqueue() 方法进行异步请求,同时传入一个 Callback 进行回调,我们可以自定义一个 Callback,用于记录请求回来后的时间点,然后在 enqueue 方法中将参数换为自定义的 Callback,而原 Callback 作为其代理对象即可。
该 Callback 对象用于在请求成功或失败回调时,记录请求结束时间点,并调用代理对象的相应方法处理原有逻辑。
使用 Retrofit+RXJava 时,发起请求时内部是调用的 execute() 方法进行同步请求,我们只需要在其执行前后插入计算时间的代码即可,此处不再赘述。
疑难杂症
至此,我们基本的测速框架已经完成,不过经过我们的实践发现,有一种情况下测速数据会非常不准,那就是开头提过的 ViewPager+Fragment 并且实现延迟加载的情况。这也是一种很常见的情况,通常是为了节省开销,在切换 ViewPager 的 Tab 时,才首次调用 Fragment 的初始加载方法进行数据请求。经过调试分析,我们找到了问题的原因。
等待切换时间
该图红色时间段反映出,直到 ViewPager 切换到 Fragment 前,Fragment不会发起请求,这段等待的时间就会延长整个页面的加载时间,但其实这块时间不应该算在内,因为这段时间是用户无感知的,不能作为页面耗时过长的依据。
那么如何解决呢?我们都知道 ViewPager 的 Tab 切换是可以通过一个 OnPageChangeListener 对象进行监听的,所以我们可以为 ViewPager 添加一个自定义的 Listener 对象,在切换时记录一个时间,这样可以通过用这个时间减去页面创建后的时间得出这个多余的等待时间,上报时在总时间中减去即可。
mItems 是 ViewPager 中当前页面对象的数组,在 Listener 中可以通过他找到对应的页面,进行切换时的埋点。
AutoSpeed 的 onPageSelected() 方法记录页面的切换时间。这样一来,在计算页面加载速度总时间时,就要减去这一段时间。
这里减去的 viewCreatedTime 不是 Fragment 的 onCreate() 时间,而应该是 onViewCreated() 时间,因为从 onCreate 到 onViewCreated 之间的时间也是应该算在页面加载时间内,不应该减去,所以为了处理这种情况,我们还需要对 Fragment 的 onViewCreated 方法进行埋点,埋点方式同 onCreate()的埋点。
渲染时机不固定
此外经实践发现,由于不同 View 在绘制子 View 时的绘制原理不一样,有可能会导致以下情况的发生:
没有切换至 Fragment 时,Fragment 的 View 初次渲染已经完成,即 View不可见的情况下也调用了dispatchDraw() 。
没有切换至 Fragment 时,Fragment 的 View 初次渲染未完成,即直到 View 初次可见时 dispatchDraw() 才会调用。
没有延迟加载时,当 ViewPager 没有切换到 Fragment,而是直接发送请求后,请求回来时更新 View,会调用 dispatchDraw() 进行二次渲染。
没有延迟加载时,当 ViewPager 没有切换到 Fragment,而是直接发送请求后,请求回来时更新 View,不会调用 dispatchDraw() ,即直到切换到 Fragment 时才会进行二次渲染。
上面的问题总结来看,就是初次渲染时间和二次渲染时间中,可能会有个等待切换的时间,导致这两个时间变长,而这个切换时间点并不是 onPageSelected() 方法调用的时候,因为该方法是在 Fragment 完全滑动出来之后才会调用,而这个问题里的切换时间点,应该是指 View 初次展示的时候,也就是刚一滑动,ViewPager露出目标 View 的时间点。于是类比延迟加载的切换时间,我们利用 Listener 的 onPageScrolled() 方法,在 ViewPager 滑动时,找到目标页面,为其记录一个滑动时间点 scrollToTime 。
那么这样就可以解决两次渲染的误差:
初次渲染时间中, scrollToTime viewCreatedTime 就是页面创建后,到初次渲染结束之间,因为等待滚动而产生的多余时间。
二次渲染时间中, scrollToTime apiLoadEndTime 就是请求完成后,到二次渲染结束之间,因为等待滚动而产生的多余时间。
于是在计算初次和二次渲染时间时,可以减去多余时间得到正确的值。
总结
以上就是我们对页面测速及自动化实现上做的一些尝试,目前已经在项目中使用,并在监控平台上可以获取实时的数据。我们可以通过分析数据来了解页面的性能进而做优化,不断提升项目的整体质量。并且通过实践发现了一些测速误差的问题,也都逐一解决,使得测速数据更加可靠。自动化的实现也让我们在后续开发中的维护变得更容易,不用维护页面测速相关的逻辑,就可以做到实时监测所有页面的加载速度。
参考文献
作者介绍
文杰,美团前端 Android 开发工程师,2016年毕业于天津工业大学,同年加入美团点评到店餐饮事业群,从事商家销售端移动应用开发工作。
评论