随着移动互联网的快速发展,移动应用愈来愈注重用户体验。美团技术团队在开发过程当中也很是注重提高移动应用的总体质量,其中很重要的一项内容就是页面的加载速度。若是发生冷启动时间过长、页面渲染时间过长、网络请求过慢等现象,就会直接影响到用户的体验,因此,如何监控整个项目的加载速度就成为咱们部门面临的重要挑战。 对于测速这个问题,不少同窗首先会想到在页面中的不一样节点加入计算时间的代码,以此算出某段时间长度。然而,随着美团业务的快速迭代,会有愈来愈多的新页面、愈来愈多的业务逻辑、愈来愈多的代码改动,这些不肯定性会使咱们测速部分的代码耦合进业务逻辑,而且须要手动维护,进而增长了成本和风险。因而经过借鉴公司先前的方案Hertz(移动端性能监控方案Hertz),分析其存在的问题并结合自身特性,咱们实现了一套无需业务代码侵入的自动化页面测速插件,本文将对其原理作一些解读和分析。html
现有解决方案Hertz(移动端性能监控方案Hertz)前端
Application.onCreate()
中进行SDK的初始化调用,同时计算冷启动时间。Activity.setContentView()
设置的View上,添加一层自定义父View,用于计算绘制完成的时间。getClass().getSimpleName()
做为页面的key,来标识哪些页面须要测速,指定一组API来标识哪些请求是须要被测速的。现有方案问题android
Application.onCreate()
中开始算起,会使得计算出来的冷启动时间偏小,由于在该方法执行前可能会有 MultiDex.install()
等耗时方法的执行。目标方案效果canvas
咱们要实现一个自动化的测速插件,须要分为五步进行:api
咱们把页面加载流程抽象成一个通用的过程模型:页面初始化 -> 初次渲染完成 -> 网络请求发起 -> 请求完成并刷新页面 -> 二次渲染完成。据此,要测量的内容包括如下方面:数组
onCreate()
方法开始,一直到页面View的初次渲染完成所经历的时间。须要注意的是,网络请求时间是指定的一组请求所有完成的时间,即从第一个请求发起开始,直到最后一个请求完成所用的时间。 根据定义咱们的测速模型以下图所示。网络
接下来要知道哪些页面须要测速,以及页面的初始请求是哪些API,这须要一个配置文件来定义。app
<page id="HomeActivity" tag="1"> <api id="/api/config"/> <api id="/api/list"/> </page> <page id="com.test.MerchantFragment" tag="0"> <api id="/api/test1"/> </page>
咱们定义了一个XML配置文件,每一个 <page/>
标签表明了一个页面,其中 id
是页面的类名或者全路径类名,用以表示哪些Activity或者Fragment须要测速; tag
表明是否为首页,这个首页指的是用以计算冷启动结束时间的页面,好比咱们想把冷启动时间定义为从App建立到HomeActivity展现所须要的时间,那么HomeActivity的tag就为1;每个 <api/>
表明这个页面的一个初始请求,好比HomeActivity页面是个列表页,一进来会先请求config接口,而后请求list接口,当list接口回来后展现列表数据,那么该页面的初始请求就是config和list接口。更重要的一点是,咱们将该配置文件维护在服务端,能够实时更新,而客户端要作的只是在插件SDK初始化时拉取最新的配置文件便可。框架
测速须要实现一个SDK,用于管理配置文件、页面测速对象、计算时间、上报数据等,项目接入后,在页面的不一样节点调用SDK提供的方法完成测速。异步
冷启动的开始时间,咱们以Application的构造函数被调用为准,在构造函数中进行时间点记录,并在SDK初始化时,将时间点传入做为冷启动开始时间。
//Application public MyApplication(){ super(); coldStartTime = SystemClock.elapsedRealtime(); } //SDK初始化 public void onColdStart(long coldStartTime) { this.startTime = coldStartTime; }
这里说明几点:
SystemClock.elapsedRealtime()
机器时间,保证了时间的一致性和准确性。onCreate()
中计算更为准确。SDK的初始化在 Application.onCreate()
中调用,初始化时会获取服务端的配置文件,解析为 Map<String,PageObject>
,对应配置中页面的id和其配置项。另外还维护了一个当前页面对象的 MAP<Integer, Object>
,key为一个int值而不是其类名,由于同一个类可能有多个实例同时在运行,若是存为一个key,可能会致使同一页面不一样实例的测速对象只有一个,因此在这里咱们使用Activity或Fragment的 hashcode()
值做为页面的惟一标识。
页面的开始时间,咱们以Activtiy或Fragment的 onCreate()
做为时间节点进行计算,记录页面的开始时间。
public void onPageCreate(Object page) { int pageObjKey = Utils.getPageObjKey(page); PageObject pageObject = activePages.get(pageObjKey); ConfigModel configModel = getConfigModel(page);//获取该页面的配置 if (pageObject == null && configModel != null) {//有配置则须要测速 pageObject = new PageObject(pageObjKey, configModel, Utils.getDefaultReportKey(page), callback); pageObject.onCreate(); activePages.put(pageObjKey, pageObject); } } //PageObject.onCreate() void onCreate() { if (createTime > 0) { return; } createTime = Utils.getRealTime(); }
这里的 getConfigModel()
方法中,会使用页面的类名或者全路径类名,去初始化时解析的配置Map中进行id的匹配,若是匹配到说明页面须要测速,就会建立测速对象 PageObject
进行测速。
一个页面的初始请求由配置文件指定,咱们只需在第一个请求发起前记录请求开始时间,在最后一个请求回来后记录结束时间便可。
boolean onApiLoadStart(String url) { String relUrl = Utils.getRelativeUrl(url); if (!hasApiConfig() || !hasUrl(relUrl) || apiStatusMap.get(relUrl.hashCode()) != NONE) { return false; } //改变Url的状态为执行中 apiStatusMap.put(relUrl.hashCode(), LOADING); //第一个请求开始时记录起始点 if (apiLoadStartTime <= 0) { apiLoadStartTime = Utils.getRealTime(); } return true; } boolean onApiLoadEnd(String url) { String relUrl = Utils.getRelativeUrl(url); if (!hasApiConfig() || !hasUrl(relUrl) || apiStatusMap.get(relUrl.hashCode()) != LOADING) { return false; } //改变Url的状态为执行结束 apiStatusMap.put(relUrl.hashCode(), LOADED); //所有请求结束后记录时间 if (apiLoadEndTime <= 0 && allApiLoaded()) { apiLoadEndTime = Utils.getRealTime(); } return true; } private boolean allApiLoaded() { if (!hasApiConfig()) return true; int size = apiStatusMap.size(); for (int i = 0; i < size; ++i) { if (apiStatusMap.valueAt(i) != LOADED) { return false; } } return true; }
每一个页面的测速对象,维护了一个请求url和其状态的映射关系 SparseIntArray
,key就为请求url的hashcode,状态初始为 NONE
。每次请求发起时,将对应url的状态置为 LOADING
,结束时置为 LOADED
。当第一个请求发起时记录起始时间,当全部url状态为 LOADED
时说明全部请求完成,记录结束时间。
按照咱们对测速的定义,如今冷启动开始时间有了,还差结束时间,即指定的首页初次渲染结束时的时间;页面的开始时间有了,还差页面初次渲染的结束时间;网络请求的结束时间有了,还差页面的二次渲染的结束时间。这一切都是和页面的View渲染时间有关,那么怎么获取页面的渲染结束时间点呢?
由View的绘制流程可知,父View的 dispatchDraw()
方法会执行其全部子View的绘制过程,那么把页面的根View当作子View,是否是能够在其外部增长一层父View,以其 dispatchDraw()
做为页面绘制完毕的时间点呢?答案是能够的。
class AutoSpeedFrameLayout extends FrameLayout { public static View wrap(int pageObjectKey, [@NonNull](https://my.oschina.net/u/2981441) View child) { ... //将页面根View做为子View,其余参数保持不变 ViewGroup vg = new AutoSpeedFrameLayout(child.getContext(), pageObjectKey); if (child.getLayoutParams() != null) { vg.setLayoutParams(child.getLayoutParams()); } vg.addView(child, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); return vg; } private final int pageObjectKey;//关联的页面key private AutoSpeedFrameLayout([@NonNull](https://my.oschina.net/u/2981441) Context context, int pageObjectKey) { super(context); this.pageObjectKey = pageObjectKey; } [@Override](https://my.oschina.net/u/1162528) protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); AutoSpeed.getInstance().onPageDrawEnd(pageObjectKey); } }
咱们自定义了一层 FrameLayout
做为全部页面根View的父View,其 dispatchDraw()
方法执行super后,记录相关页面绘制结束的时间点。
如今全部时间点都有了,那么何时算做测速过程结束呢?咱们来看看每次渲染结束后的处理就知道了。
//PageObject.onPageDrawEnd() void onPageDrawEnd() { if (initialDrawEndTime <= 0) {//初次渲染尚未完成 initialDrawEndTime = Utils.getRealTime(); if (!hasApiConfig() || allApiLoaded()) {//若是没有请求配置或者请求已完成,则没有二次渲染时间,即初次渲染时间即为页面总体时间,且能够上报结束页面了 finalDrawEndTime = -1; reportIfNeed(); } //页面初次展现,回调,用于统计冷启动结束 callback.onPageShow(this); return; } //若是二次渲染没有完成,且全部请求已经完成,则记录二次渲染时间并结束测速,上报数据 if (finalDrawEndTime <= 0 && (!hasApiConfig() || allApiLoaded())) { finalDrawEndTime = Utils.getRealTime(); reportIfNeed(); } }
该方法用于处理渲染完毕的各类状况,包括初次渲染时间、二次渲染时间、冷启动时间以及相应的上报。这里的冷启动在 callback.onPageShow(this)
是如何处理的呢?
//初次渲染完成时的回调 void onMiddlePageShow(boolean isMainPage) { if (!isFinish && isMainPage && startTime > 0 && endTime <= 0) { endTime = Utils.getRealTime(); callback.onColdStartReport(this); finish(); } }
还记得配置文件中 tag
么,他的做用就是指明该页面是否为首页,也就是代码段里的 isMainPage
参数。若是是首页的话,说明首页的初次渲染结束,就能够计算冷启动结束的时间并进行上报了。
当测速完成后,页面测速对象 PageObject
里已经记录了页面(包括冷启动)各个时间点,剩下的只须要进行测速阶段的计算并进行网络上报便可。
//计算网络请求时间 long getApiLoadTime() { if (!hasApiConfig() || apiLoadEndTime <= 0 || apiLoadStartTime <= 0) { return -1; } return apiLoadEndTime - apiLoadStartTime; }
有了SDK,就要在咱们的项目中接入,并在相应的位置调用SDK的API来实现测速功能,那么如何自动化实现API的调用呢?答案就是采用AOP的方式,在App编译时动态注入代码,咱们实现一个Gradle插件,利用其Transform功能以及Javassist实现代码的动态注入。动态注入代码分为如下几步:
在 Transform
中遍历全部生成的class文件,找到Application对应的子类,在其 onCreate()
方法中调用SDK初始化API便可。
CtMethod method = it.getDeclaredMethod("onCreate") method.insertBefore("${Constants.AUTO_SPEED_CLASSNAME}.getInstance().init(this);")
最终生成的Application代码以下:
public void onCreate() { ... AutoSpeed.getInstance().init(this); }
同上一步,找到Application对应的子类,在其构造方法中记录冷启动开始时间,在SDK初始化时候传入SDK,缘由在上文已经解释过。
//Application private long coldStartTime; public MobileCRMApplication() { coldStartTime = SystemClock.elapsedRealtime(); } public void onCreate(){ ... AutoSpeed.getInstance().init(this,coldStartTime); }
结合测速时间点的定义以及Activity和Fragment的生命周期,咱们可以肯定在何处调用相应的API。
Activity 对于Activity页面,如今开发者已经不多直接使用 android.app.Activity
了,取而代之的是 android.support.v4.app.FragmentActivity
和 android.support.v7.app.AppCompatActivity
,因此咱们只需在这两个基类中进行埋点便可,咱们先来看FragmentActivity。
protected void onCreate([@Nullable](https://my.oschina.net/u/2896689) Bundle savedInstanceState) { AutoSpeed.getInstance().onPageCreate(this); ... } public void setContentView(View var1) { super.setContentView(AutoSpeed.getInstance().createPageView(this, var1)); }
注入代码后,在FragmentActivity的 onCreate
一开始调用了 onPageCreate()
方法进行了页面开始时间点的计算;在 setContentView()
内部,直接调用super,并将页面根View包装在咱们自定义的 AutoSpeedFrameLayout
中传入,用于渲染时间点的计算。 然而在AppCompatActivity中,重写了setContentView()方法,且没有调用super,调用的是 AppCompatDelegate
的相应方法。
public void setContentView(View view) { getDelegate().setContentView(view); }
这个delegate类用于适配不一样版本的Activity的一些行为,对于setContentView,无非就是将根View传入delegate相应的方法,因此咱们能够直接包装View,调用delegate相应方法并传入便可。
public void setContentView(View view) { AppCompatDelegate var2 = this.getDelegate(); var2.setContentView(AutoSpeed.getInstance().createPageView(this, view)); }
对于Activity的setContentView埋点须要注意的是,该方法是重载方法,咱们须要对每一个重载的方法作处理。
Fragment Fragment的 onCreate()
埋点和Activity同样,没必要多说。这里主要说下 onCreateView()
,这个方法是返回值表明根View,而不是直接传入View,而Javassist没法单独修改方法的返回值,因此没法像Activity的setContentView那样注入代码,而且这个方法不是 @CallSuper
的,意味着不能在基类里实现。那么怎么办呢?咱们决定在每一个Fragment的该方法上作一些事情。
//Fragment标志位 protected static boolean AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = true; //利用递归包装根View public View onCreateView(LayoutInflater inflater, [@Nullable](https://my.oschina.net/u/2896689) ViewGroup container, @Nullable Bundle savedInstanceState) { if(AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG) { AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = false; View var4 = AutoSpeed.getInstance().createPageView(this, this.onCreateView(inflater, container, savedInstanceState)); AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = true; return var4; } else { ... return rootView; } }
咱们利用一个boolean类型的标志位,进行递归调用 onCreateView()
方法:
AutoSpeedFrameLayout
返回。而且因为标志位为false,因此在递归调用时,即便调用了 super.onCreateView()
方法,在父类的该方法中也不会走if分支,而是直接返回其根View。
关于请求埋点咱们针对不一样的网络框架进行不一样的处理,插件中只须要配置使用了哪些网络框架便可实现埋点,咱们拿如今用的最多的 Retrofit
框架来讲。
开始时间点 在建立Retrofit对象时,须要 OkHttpClient
对象,能够为其添加 Interceptor
进行请求发起前 Request
的拦截,咱们能够构建一个用于记录请求开始时间点的Interceptor,在 OkHttpClient.Builder()
调用时,插入该对象。
public Builder() { this.addInterceptor(new AutoSpeedRetrofitInterceptor()); ... }
而该Interceptor对象就是用于在请求发起前,进行请求开始时间点的记录。
public class AutoSpeedRetrofitInterceptor implements Interceptor { public Response intercept(Chain var1) throws IOException { AutoSpeed.getInstance().onApiLoadStart(var1.request().url()); return var1.proceed(var1.request()); } }
结束时间点 使用Retrofit发起请求时,咱们会调用其 enqueue()
方法进行异步请求,同时传入一个 Callback
进行回调,咱们能够自定义一个Callback,用于记录请求回来后的时间点,而后在enqueue方法中将参数换为自定义的Callback,而原Callback做为其代理对象便可。
public void enqueue(Callback<T> callback) { final Callback<T> callback = new AutoSpeedRetrofitCallback(callback); ... }
该Callback对象用于在请求成功或失败回调时,记录请求结束时间点,并调用代理对象的相应方法处理原有逻辑。
public class AutoSpeedRetrofitCallback implements Callback { private final Callback delegate; public AutoSpeedRetrofitMtCallback(Callback var1) { this.delegate = var1; } public void onResponse(Call var1, Response var2) { AutoSpeed.getInstance().onApiLoadEnd(var1.request().url()); this.delegate.onResponse(var1, var2); } public void onFailure(Call var1, Throwable var2) { AutoSpeed.getInstance().onApiLoadEnd(var1.request().url()); this.delegate.onFailure(var1, var2); } }
使用Retrofit+RXJava时,发起请求时内部是调用的 execute()
方法进行同步请求,咱们只须要在其执行先后插入计算时间的代码便可,此处再也不赘述。
至此,咱们基本的测速框架已经完成,不过通过咱们的实践发现,有一种状况下测速数据会很是不许,那就是开头提过的ViewPager+Fragment而且实现延迟加载的状况。这也是一种很常见的状况,一般是为了节省开销,在切换ViewPager的Tab时,才首次调用Fragment的初始加载方法进行数据请求。通过调试分析,咱们找到了问题的缘由。
等待切换时间
该图红色时间段反映出,直到ViewPager切换到Fragment前,Fragment不会发起请求,这段等待的时间就会延长整个页面的加载时间,但其实这块时间不该该算在内,由于这段时间是用户无感知的,不能做为页面耗时过长的依据。 那么如何解决呢?咱们都知道ViewPager的Tab切换是能够经过一个 OnPageChangeListener
对象进行监听的,因此咱们能够为ViewPager添加一个自定义的Listener对象,在切换时记录一个时间,这样能够经过用这个时间减去页面建立后的时间得出这个多余的等待时间,上报时在总时间中减去便可。
public ViewPager(Context context) { ... this.addOnPageChangeListener(new AutoSpeedLazyLoadListener(this.mItems)); }
mItems
是ViewPager中当前页面对象的数组,在Listener中能够经过他找到对应的页面,进行切换时的埋点。
//AutoSpeedLazyLoadListener public void onPageSelected(int var1) { if(this.items != null) { int var2 = this.items.size(); for(int var3 = 0; var3 < var2; ++var3) { Object var4 = this.items.get(var3); if(var4 instanceof ItemInfo) { ItemInfo var5 = (ItemInfo)var4; if(var5.position == var1 && var5.object instanceof Fragment) { AutoSpeed.getInstance().onPageSelect(var5.object); break; } } } } }
AutoSpeed的 onPageSelected()
方法记录页面的切换时间。这样一来,在计算页面加载速度总时间时,就要减去这一段时间。
long getTotalTime() { if (createTime <= 0) { return -1; } if (finalDrawEndTime > 0) {//有二次渲染时间 long totalTime = finalDrawEndTime - createTime; //若是有等待时间,则减掉这段多余的时间 if (selectedTime > 0 && selectedTime > viewCreatedTime && selectedTime < finalDrawEndTime) { totalTime -= (selectedTime - viewCreatedTime); } return totalTime; } else {//以初次渲染时间为总体时间 return getInitialDrawTime(); } }
这里减去的 viewCreatedTime
不是Fragment的 onCreate()
时间,而应该是 onViewCreated()
时间,由于从onCreate到onViewCreated之间的时间也是应该算在页面加载时间内,不该该减去,因此为了处理这种状况,咱们还须要对Fragment的onViewCreated方法进行埋点,埋点方式同 onCreate()
的埋点。
渲染时机不固定 此外经实践发现,因为不一样View在绘制子View时的绘制原理不同,有可能会致使如下状况的发生:
dispatchDraw()
。dispatchDraw()
才会调用。dispatchDraw()
进行二次渲染。dispatchDraw()
,即直到切换到Fragment时才会进行二次渲染。上面的问题总结来看,就是初次渲染时间和二次渲染时间中,可能会有个等待切换的时间,致使这两个时间变长,而这个切换时间点并非 onPageSelected()
方法调用的时候,由于该方法是在Fragment彻底滑动出来以后才会调用,而这个问题里的切换时间点,应该是指View初次展现的时候,也就是刚一滑动,ViewPager露出目标View的时间点。因而类比延迟加载的切换时间,咱们利用Listener的 onPageScrolled()
方法,在ViewPager滑动时,找到目标页面,为其记录一个滑动时间点 scrollToTime
。
public void onPageScrolled(int var1, float var2, int var3) { if(this.items != null) { int var4 = Math.round(var2); int var5 = var2 != (float)0 && var4 != 1?(var4 == 0?var1 + 1:-1):var1; int var6 = this.items.size(); for(int var7 = 0; var7 < var6; ++var7) { Object var8 = this.items.get(var7); if(var8 instanceof ItemInfo) { ItemInfo var9 = (ItemInfo)var8; if(var9.position == var5 && var9.object instanceof Fragment) { AutoSpeed.getInstance().onPageScroll(var9.object); break; } } } } }
那么这样就能够解决两次渲染的偏差:
scrollToTime - viewCreatedTime
就是页面建立后,到初次渲染结束之间,由于等待滚动而产生的多余时间。scrollToTime - apiLoadEndTime
就是请求完成后,到二次渲染结束之间,由于等待滚动而产生的多余时间。因而在计算初次和二次渲染时间时,能够减去多余时间获得正确的值。
long getInitialDrawTime() { if (createTime <= 0 || initialDrawEndTime <= 0) { return -1; } if (scrollToTime > 0 && scrollToTime > viewCreatedTime && scrollToTime <= initialDrawEndTime) {//延迟初次渲染,须要减去等待的时间(viewCreated->changeToPage) return initialDrawEndTime - createTime - (scrollToTime - viewCreatedTime); } else {//正常初次渲染 return initialDrawEndTime - createTime; } } long getFinalDrawTime() { if (finalDrawEndTime <= 0 || apiLoadEndTime <= 0) { return -1; } //延迟二次渲染,须要减去等待时间(apiLoadEnd->scrollToTime) if (scrollToTime > 0 && scrollToTime > apiLoadEndTime && scrollToTime <= finalDrawEndTime) { return finalDrawEndTime - apiLoadEndTime - (scrollToTime - apiLoadEndTime); } else {//正常二次渲染 return finalDrawEndTime - apiLoadEndTime; } }
以上就是咱们对页面测速及自动化实现上作的一些尝试,目前已经在项目中使用,并在监控平台上能够获取实时的数据。咱们能够经过分析数据来了解页面的性能进而作优化,不断提高项目的总体质量。而且经过实践发现了一些测速偏差的问题,也都逐一解决,使得测速数据更加可靠。自动化的实现也让咱们在后续开发中的维护变得更容易,不用维护页面测速相关的逻辑,就能够作到实时监测全部页面的加载速度。
文杰,美团前端Android开发工程师,2016年毕业于天津工业大学,同年加入美团点评到店餐饮事业群,从事商家销售端移动应用开发工做。