背景
随着移动互联网的快速发展,移动应用越来越注重用户体验。美团技术团队在开发过程中也非常注重提升移动应用的整体质量,其中很重要的一项内容就是页面的加载速度。如果发生冷启动时间过长、页面渲染时间过长、网络请求过慢等现象,就会直接影响到用户的体验,所以,如何监控整个项目的加载速度就成为我们部门面临的重要挑战。
对于测速这个问题,很多同学首先会想到在页面中的不同节点加入计算时间的代码,以此算出某段时间长度。然而,随着美团业务的快速迭代,会有越来越多的新页面、越来越多的业务逻辑、越来越多的代码改动,这些不确定性会使我们测速部分的代码耦合进业务逻辑,并且需要手动维护,进而增加了成本和风险。于是通过借鉴公司先前的一些方案,分析其存在的问题并结合自身特性,我们实现了一套无需业务代码侵入的自动化页面测速插件,本文将对其原理做一些解读和分析。
现有解决方案Hertz(移动端性能监控方案Hertz:)
手动在()中进行SDK的初始化调用,同时计算冷启动时间。
手动在Activity生命周期方法中添加代码,计算页面不同阶段的时间。
手动为()设置的View上,添加一层自定义父View,用于计算绘制完成的时间。
手动在每个网络请求开始前和结束后添加代码,计算网络请求的时间。
本地声明JSON配置文件来确定需要测速的页面以及该页面需要统计的初始网络请求API,getClass().getSimpleName()作为页面的key,来标识哪些页面需要测速,指定一组API来标识哪些请求是需要被测速的。
现有方案问题
冷启动时间不准:冷启动起始时间从()中开始算起,会使得计算出来的冷启动时间偏小,因为在该方法执行前可能会有()等耗时方法的执行。
特殊情况未考虑:忽略了ViewPager+Fragment延时加载这些常见而复杂的情况,这些情况会造成实际测速时间非常不准。
手动注入代码:所有的代码都需要手动写入,耦合进业务逻辑中,难以维护并且随着新页面的加入容易遗漏。
写死配置文件:如需添加或更改要测速的页面,则需要修改本地配置文件,进行发版。
目标方案效果
自动注入代码,无需手动写入代码与业务逻辑耦合。
支持Activity和Fragment页面测速,并解决ViewPager+Fragment延迟加载时测速不准的问题。
自动拉取和更新配置文件,可以实时的进行配置文件的更新。
实现
我们要实现一个自动化的测速插件,需要分为五步进行:
测速定义:确定需要测量的速度指标并定义其计算方式。
配置文件:通过配置文件确定代码中需要测量速度指标的位置。
测速实现:如何实现时间的计算和上报。
自动化实现:如何自动化实现页面测速,不需要手动注入代码。
疑难杂症:分析并解决特殊情况。
测速定义
我们把页面加载流程抽象成一个通用的过程模型:页面初始化-初次渲染完成-网络请求发起-请求完成并刷新页面-二次渲染完成。据此,要测量的内容包括以下方面:
页面的初次渲染时间:从Activity或Fragment的onCreate()方法开始,一直到页面View的初次渲染完成所经历的时间。
页面的初始网络请求时间:Activity或Fragment指定的一组初始请求,全部完成所用的时间。
页面的二次渲染时间:Activity或Fragment所有的初始请求完成后,到页面View再次渲染完成所经历的时间。
需要注意的是,网络请求时间是指定的一组请求全部完成的时间,即从第一个请求发起开始,直到最后一个请求完成所用的时间。根据定义我们的测速模型如下图所示:
配置文件
接下来要知道哪些页面需要测速,以及页面的初始请求是哪些API,这需要一个配置文件来定义。
pageid="HomeActivity"tag="1"
apiid="/api/config"/
apiid="/api/list"/
/page
pageid=""tag="0"
apiid="/api/test1"/
/page
更重要的一点是,我们将该配置文件维护在服务端,可以实时更新,而客户端要做的只是在插件SDK初始化时拉取最新的配置文件即可。
测速实现
测速需要实现一个SDK,用于管理配置文件、页面测速对象、计算时间、上报数据等,项目接入后,在页面的不同节点调用SDK提供的方法完成测速。
冷启动开始时间
冷启动的开始时间,我们以Application的构造函数被调用为准,在构造函数中进行时间点记录,并在SDK初始化时,将时间点传入作为冷启动开始时间。
//Application
publicMyApplication(){
super();
coldStartTime=();
}
//SDK初始化
publicvoidonColdStart(longcoldStartTime){
=coldStartTime;
}
这里说明几点:
SDK中所有的时间获取都使用()机器时间,保证了时间的一致性和准确性。
冷启动初始时间以构造函数为准,可以算入MultiDex注入的时间,比在onCreate()中计算更为准确。
在构造函数中直接调用Java的API来计算时间,之后传入SDK中,而不是直接调用SDK的方法,是为了防止MultiDex注入之前,调用到未注入的Dex中的类。
SDK初始化
SDK的初始化在()中调用,初始化时会获取服务端的配置文件,解析为MapString,PageObject,对应配置中页面的id和其配置项。另外还维护了一个当前页面对象的MAPInteger,Object,key为一个int值而不是其类名,因为同一个类可能有多个实例同时在运行,如果存为一个key,可能会导致同一页面不同实例的测速对象只有一个,所以在这里我们使用Activity或Fragment的hashcode()值作为页面的唯一标识。
页面开始时间
页面的开始时间,我们以Activtiy或Fragment的onCreate()作为时间节点进行计算,记录页面的开始时间。
publicvoidonPageCreate(Objectpage){
intpageObjKey=(page);
PageObjectpageObject=(pageObjKey);
ConfigModelconfigModel=getConfigModel(page);//获取该页面的配置
if(pageObject==nullconfigModel!=null){//有配置则需要测速
pageObject=newPageObject(pageObjKey,configModel,(page),callback);
();
(pageObjKey,pageObject);
}
}
//()
voidonCreate(){
if(createTime0){
return;
}
createTime=();
}
这里的getConfigModel()方法中,会使用页面的类名或者全路径类名,去初始化时解析的配置Map中进行id的匹配,如果匹配到说明页面需要测速,就会创建测速对象PageObject进行测速。
网络请求时间
一个页面的初始请求由配置文件指定,我们只需在第一个请求发起前记录请求开始时间,在最后一个请求回来后记录结束时间即可。
booleanonApiLoadStart(Stringurl){
StringrelUrl=(url);
if(!hasApiConfig()||!hasUrl(relUrl)||(())!=NONE){
returnfalse;
}
//改变Url的状态为执行中
((),LOADING);
//第一个请求开始时记录起始点
if(apiLoadStartTime=0){
apiLoadStartTime=();
}
returntrue;
}
booleanonApiLoad(Stringurl){
StringrelUrl=(url);
if(!hasApiConfig()||!hasUrl(relUrl)||(())!=LOADING){
returnfalse;
}
//改变Url的状态为执行结束
((),LOADED);
//全部请求结束后记录时间
if(apiLoadTime=0allApiLoaded()){
apiLoadTime=();
}
returntrue;
}
privatebooleanallApiLoaded(){
if(!hasApiConfig())returntrue;
intsize=();
for(inti=0;isize;++i){
if((i)!=LOADED){
returnfalse;
}
}
returntrue;
}
每个页面的测速对象,维护了一个请求url和其状态的映射关系SparseIntArray,key就为请求url的hashcode,状态初始为NONE。每次请求发起时,将对应url的状态置为LOADING,结束时置为LOADED。当第一个请求发起时记录起始时间,当所有url状态为LOADED时说明所有请求完成,记录结束时间。
渲染时间
由View的绘制流程可知,父View的dispatchDraw()方法会执行其所有子View的绘制过程,那么把页面的根View当做子View,是不是可以在其外部增加一层父View,以其dispatchDraw()作为页面绘制完毕的时间点呢?答案是可以的。
classAutoSpeedFrameLayoutextsFrameLayout{
publicstaticViewwrap(intpageObjectKey,@NonNullViewchild){
//将页面根View作为子View,其他参数保持不变
ViewGroupvg=newAutoSpeedFrameLayout((),pageObjectKey);
if(()!=null){
(());
}
(child,(_PARENT,_PARENT));
returnvg;
}
privatefinalintpageObjectKey;//关联的页面key
privateAutoSpeedFrameLayout(@NonNullContextcontext,intpageObjectKey){
super(context);
=pageObjectKey;
}
@Override
protectedvoiddispatchDraw(Canvascanvas){
(canvas);
().onPageDraw(pageObjectKey);
}
}
我们自定义了一层FrameLayout作为所有页面根View的父View,其dispatchDraw()方法执行super后,记录相关页面绘制结束的时间点。
测速完成
现在所有时间点都有了,那么什么时候算作测速过程结束呢?我们来看看每次渲染结束后的处理就知道了。
//()
voidonPageDraw(){
if(initialDrawTime=0){//初次渲染还没有完成
initialDrawTime=();
if(!hasApiConfig()||allApiLoaded()){//如果没有请求配置或者请求已完成,则没有二次渲染时间,即初次渲染时间即为页面整体时间,且可以上报结束页面了
finalDrawTime=-1;
reportIfNeed();
}
//页面初次展示,回调,用于统计冷启动结束
(this);
return;
}
//如果二次渲染没有完成,且所有请求已经完成,则记录二次渲染时间并结束测速,上报数据
if(finalDrawTime=0(!hasApiConfig()||allApiLoaded())){
finalDrawTime=();
reportIfNeed();
}
}
该方法用于处理渲染完毕的各种情况,包括初次渲染时间、二次渲染时间、冷启动时间以及相应的上报。这里的冷启动在(this)是如何处理的呢?
//初次渲染完成时的回调
voidonMiddlePageShow(booleanisMainPage){
if(!isFinishisMainPagestartTime0Time=0){
Time=();
(this);
finish();
}
}
上报数据
当测速完成后,页面测速对象PageObject里已经记录了页面(包括冷启动)各个时间点,剩下的只需要进行测速阶段的计算并进行网络上报即可。
//计算网络请求时间
longgetApiLoadTime(){
if(!hasApiConfig()||apiLoadTime=0||apiLoadStartTime=0){
return-1;
}
returnapiLoadTime-apiLoadStartTime;
}
自动化实现
有了SDK,就要在我们的项目中接入,并在相应的位置调用SDK的API来实现测速功能,那么如何自动化实现API的调用呢?答案就是采用AOP的方式,在App编译时动态注入代码,我们实现一个Gradle插件,利用其Transform功能以及Javassist实现代码的动态注入。动态注入代码分为以下几步:
初始化埋点:SDK的初始化。
冷启动埋点:Application的冷启动开始时间点。
页面埋点:Activity和Fragment页面的时间点。
请求埋点:网络请求的时间点。
初始化埋点
在Transform中遍历所有生成的class文件,找到Application对应的子类,在其onCreate()方法中调用SDK初始化API即可。
CtMethodmethod=("onCreate")
("${_SPEED_CLASSNAME}.getInstance().init(this);")
最终生成的Application代码如下:
publicvoidonCreate(){
().init(this);
}
冷启动埋点
同上一步,找到Application对应的子类,在其构造方法中记录冷启动开始时间,在SDK初始化时候传入SDK,原因在上文已经解释过。
//Application
privatelongcoldStartTime;
publicMobileCRMApplication(){
coldStartTime=();
}
publicvoidonCreate(){
().init(this,coldStartTime);
}
页面埋点
结合测速时间点的定义以及Activity和Fragment的生命周期,我们能够确定在何处调用相应的API。
Activity
对于Activity页面,现在开发者已经很少直接使用了,取而代之的是和,所以我们只需在这两个基类中进行埋点即可,我们先来看FragmentActivity。
protectedvoidonCreate(@NullableBundlesavedInstanceState){
().onPageCreate(this);
}
publicvoidsetContentView(Viewvar1){
(().createPageView(this,var1));
}
注入代码后,在FragmentActivity的onCreate一开始调用了onPageCreate()方法进行了页面开始时间点的计算;在setContentView()内部,直接调用super,并将页面根View包装在我们自定义的AutoSpeedFrameLayout中传入,用于渲染时间点的计算。
然而在AppCompatActivity中,重写了setContentView()方法,且没有调用super,调用的是AppCompatDelegate的相应方法。
publicvoidsetContentView(Viewview){
getDelegate().setContentView(view);
}
这个delegate类用于适配不同版本的Activity的一些行为,对于setContentView,无非就是将根View传入delegate相应的方法,所以我们可以直接包装View,调用delegate相应方法并传入即可。
publicvoidsetContentView(Viewview){
AppCompatDelegatevar2=();
(().createPageView(this,view));
}
对于Activity的setContentView埋点需要注意的是,该方法是重载方法,我们需要对每个重载的方法做处理。
Fragment
Fragment的onCreate()埋点和Activity一样,不必多说。这里主要说下onCreateView(),这个方法是返回值代表根View,而不是直接传入View,而Javassist无法单独修改方法的返回值,所以无法像Activity的setContentView那样注入代码,并且这个方法不是@CallSuper的,意味着不能在基类里实现。那么怎么办呢?我们决定在每个Fragment的该方法上做一些事情。
//Fragment标志位
protectedstaticbooleanAUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG=true;
//利用递归包装根View
publicViewonCreateView(LayoutInflaterinflater,@NullableViewGroupcontainer,@NullableBundlesavedInstanceState){
if(AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG){
AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG=false;
Viewvar4=().createPageView(this,(inflater,container,savedInstanceState));
AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG=true;
returnvar4;
}else{
returnrootView;
}
}
我们利用一个boolean类型的标志位,进行递归调用onCreateView()方法:
最初调用时,会将标志位置为false,然后递归调用该方法。
递归调用时,由于标志位为false所以会调用原有逻辑,即获取根View。
获取根View后,包装为AutoSpeedFrameLayout返回。
并且由于标志位为false,所以在递归调用时,即使调用了()方法,在父类的该方法中也不会走if分支,而是直接返回其根View。
请求埋点
关于请求埋点我们针对不同的网络框架进行不同的处理,插件中只需要配置使用了哪些网络框架即可实现埋点,我们拿现在用的最多的Retrofit框架来说。
开始时间点
在创建Retrofit对象时,需要OkHttpClient对象,可以为其添加Interceptor进行请求发起前Request的拦截,我们可以构建一个用于记录请求开始时间点的Interceptor,在()调用时,插入该对象。
publicBuilder(){
(newAutoSpeedRetrofitInterceptor());
}
而该Interceptor对象就是用于在请求发起前,进行请求开始时间点的记录。
publicclassAutoSpeedRetrofitInterceptorimplementsInterceptor{
publicResponseintercept(Chainvar1)throwsIOException{
().onApiLoadStart(().url());
(());
}
}
结束时间点
使用Retrofit发起请求时,我们会调用其enqueue()方法进行异步请求,同时传入一个Callback进行回调,我们可以自定义一个Callback,用于记录请求回来后的时间点,然后在enqueue方法中将参数换为自定义的Callback,而原Callback作为其代理对象即可。
publicvoidenqueue(CallbackTcallback){
finalCallbackTcallback=newAutoSpeedRetrofitCallback(callback);
}
该Callback对象用于在请求成功或失败回调时,记录请求结束时间点,并调用代理对象的相应方法处理原有逻辑。
publicclassAutoSpeedRetrofitCallbackimplementsCallback{
privatefinalCallbackdelegate;
publicAutoSpeedRetrofitMtCallback(Callbackvar1){
=var1;
}
publicvoidonResponse(Callvar1,Responsevar2){
().onApiLoad(().url());
(var1,var2);
}
publicvoidonFailure(Callvar1,Throwablevar2){
().onApiLoad(().url());
(var1,var2);
}
}
使用Retrofit+RXJava时,发起请求时内部是调用的execute()方法进行同步请求,我们只需要在其执行前后插入计算时间的代码即可,此处不再赘述。
疑难杂症
至此,我们基本的测速框架已经完成,不过经过我们的实践发现,有一种情况下测速数据会非常不准,那就是开头提过的ViewPager+Fragment并且实现延迟加载的情况。这也是一种很常见的情况,通常是为了节省开销,在切换ViewPager的Tab时,才首次调用Fragment的初始加载方法进行数据请求。经过调试分析,我们找到了问题的原因。
等待切换时间
该图红色时间段反映出,直到ViewPager切换到Fragment前,Fragment不会发起请求,这段等待的时间就会延长整个页面的加载时间,但其实这块时间不应该算在内,因为这段时间是用户无感知的,不能作为页面耗时过长的依据。
那么如何解决呢?我们都知道ViewPager的Tab切换是可以通过一个OnPageChangeListener对象进行监听的,所以我们可以为ViewPager添加一个自定义的Listener对象,在切换时记录一个时间,这样可以通过用这个时间减去页面创建后的时间得出这个多余的等待时间,上报时在总时间中减去即可。
publicViewPager(Contextcontext){
(newAutoSpeedLazyLoadListener());
}
mItems是ViewPager中当前页面对象的数组,在Listener中可以通过他找到对应的页面,进行切换时的埋点。
//AutoSpeedLazyLoadListener
publicvoidonPageSelected(intvar1){
if(!=null){
intvar2=();
for(intvar3=0;var3var2;++var3){
Objectvar4=(var3);
if(var4instanceofItemInfo){
ItemInfovar5=(ItemInfo)var4;
if(==){
().onPageSelect();
break;
}
}
}
}
}
AutoSpeed的onPageSelected()方法记录页面的切换时间。这样一来,在计算页面加载速度总时间时,就要减去这一段时间。
longgetTotalTime(){
if(createTime=0){
return-1;
}
if(finalDrawTime0){//有二次渲染时间
longtotalTime=finalDrawTime-createTime;
//如果有等待时间,则减掉这段多余的时间
if(selectedTime0selectedTimeviewCreatedTimeselectedTimefinalDrawTime){
totalTime-=(selectedTime-viewCreatedTime);
}
returntotalTime;
}else{//以初次渲染时间为整体时间
returngetInitialDrawTime();
}
}
这里减去的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。
publicvoidonPageScrolled(intvar1,floatvar2,intvar3){
if(!=null){
intvar4=(var2);
intvar5=var2!=(float)0var4!=1?(var4==0?var1+1:-1):var1;
intvar6=();
for(intvar7=0;var7var6;++var7){
Objectvar8=(var7);
if(var8instanceofItemInfo){
ItemInfovar9=(ItemInfo)var8;
if(==){
().onPageScroll();
break;
}
}
}
}
}
那么这样就可以解决两次渲染的误差:
初次渲染时间中,scrollToTime-viewCreatedTime就是页面创建后,到初次渲染结束之间,因为等待滚动而产生的多余时间。
二次渲染时间中,scrollToTime-apiLoadTime就是请求完成后,到二次渲染结束之间,因为等待滚动而产生的多余时间。
于是在计算初次和二次渲染时间时,可以减去多余时间得到正确的值。
longgetInitialDrawTime(){
if(createTime=0||initialDrawTime=0){
return-1;
}
if(scrollToTime0scrollToTimeviewCreatedTimescrollToTime=initialDrawTime){//延迟初次渲染,需要减去等待的时间(viewCreated-changeToPage)
returninitialDrawTime-createTime-(scrollToTime-viewCreatedTime);
}else{//正常初次渲染
returninitialDrawTime-createTime;
}
}
longgetFinalDrawTime(){
if(finalDrawTime=0||apiLoadTime=0){
return-1;
}
//延迟二次渲染,需要减去等待时间(apiLoad-scrollToTime)
if(scrollToTime0scrollToTimeapiLoadTimescrollToTime=finalDrawTime){
returnfinalDrawTime-apiLoadTime-(scrollToTime-apiLoadTime);
}else{//正常二次渲染
returnfinalDrawTime-apiLoadTime;
}
}
总结
以上就是我们对页面测速及自动化实现上做的一些尝试,目前已经在项目中使用,并在监控平台上可以获取实时的数据。
我们可以通过分析数据来了解页面的性能进而做优化,不断提升项目的整体质量。并且通过实践发现了一些测速误差的问题,也都逐一解决,使得测速数据更加可靠。自动化的实现也让我们在后续开发中的维护变得更容易,不用维护页面测速相关的逻辑,就可以做到实时监测所有页面的加载速度。
参考文献
移动端性能监控方案Hertz:
文杰,美团前端Android开发工程师,2016年毕业于天津工业大学,同年加入美团点评到店餐饮事业群,从事商家销售端移动应用开发工作。
版权声明:本站所有作品(图文、音视频)均由用户自行上传分享,仅供网友学习交流,不声明或保证其内容的正确性,如发现本站有涉嫌抄袭侵权/违法违规的内容。请举报,一经查实,本站将立刻删除。