布景
跟着移动互联网的快速开展,移动运用越来越注重用户体会。美团技能团队在开发进程中也十分注重提高移动运用的全体质量,其间很重要的一项内容便是页面的加载速度。假如产生冷启动时刻过长、页面烘托时刻过长、网络恳求过慢等现象,就会直接影响到用户的体会,所以,怎么监控整个项目的加载速度就成为咱们部门面临的重要应战。
关于测速这个问题,很多同学首要会想到在页面中的不同节点加入核算时刻的代码,以此算出某段时刻长度。然而,跟着美团事务的快速迭代,会有越来越多的新页面、越来越多的事务逻辑、越来越多的代码改动,这些不确认性会使咱们测速部分的代码耦合进事务逻辑,而且需求手动保护,从而增加了本钱和风险。所以经过借鉴公司从前的一些计划,剖析其存在的问题并结合本身特性,咱们完结了一套无需事务代码侵入的主动化页面测速插件,本文将对其原理做一些解读和剖析。
现有处理计划
- 手动在 Application.onCreate() 中进行SDK的初始化调用,一起核算冷启动时刻。
Activity.setContentView()
i
- 本地声明JSON装备文件来确认需求测速的页面以及该页面需求核算的初始网络恳求API, getClass().getSimpleName() 作为页面的key,来标识哪些页面需求测速,指定一组API来标识哪些恳求是需求被测速的。
现有计划问题
- 冷启动时刻禁绝:冷启动起始时刻从 Application.onCreate() 中开端算起,会使得核算出来的冷启动时刻偏小,因为在该办法履行前或许会有 MultiDex.install() 等耗时办法的履行。
- 特殊状况未考虑:忽略了ViewPager+Fragment延时加载这些常见而杂乱的状况,这些状况会形成实践测速时刻十分禁绝。
- 手动注入代码:一切的代码都需求手动写入,耦合进事务逻辑中,难以保护而且跟着新页面的加入简单遗漏。
- 写死装备文件:如需添加或更改要测速的页面,则需求修正本地装备文件,进行发版。
方针计划作用
- 主动注入代码,无需手动写入代码与事务逻辑耦合。
- 支持Activity和Fragment页面测速,并处理ViewPager+Fragment推迟加载时测速禁绝的问题。
- 在Application的结构函数中开端冷启动时刻核算。
- 主动拉取和更新装备文件,能够实时的进行装备文件的更新。
完结
咱们要完结一个主动化的测速插件,需求分为五步进行:
- 测速界说:确认需求丈量的速度方针并界说其核算办法。
- 装备文件:经过装备文件确认代码中需求丈量速度方针的方位。
- 测速完结:怎么完结时刻的核算和上报。
- 主动化完结:怎么主动化完结页面测速,不需求手动注入代码。
- 疑难杂症:剖析并处理特殊状况。
测速界说
咱们把页面加载流程抽象成一个通用的进程模型:页面初始化 -> 初度烘托完结 -> 网络恳求建议 -> 恳求完结并刷新页面 -> 二次烘托完结。据此,要丈量的内容包含以下方面:
onCreate()
需求留意的是,网络恳求时刻是指定的一组恳求悉数完结的时刻,即从 第一个恳求建议开端,直到最终一个恳求完结 所用的时刻。
根据界说咱们的测速模型如下图所示。
装备文件
接下来要知道哪些页面需求测速,以及页面的初始恳求是哪些API,这需求一个装备文件来界说。
<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装备文件,每个 标签代表了一个页面,其间 id 是页面的类名或许全途径类名,用以表示哪些Activity或许Fragment需求测速; tag 代表是否为主页,这个主页指的是用以核算冷启动完毕时刻的页面,比方咱们想把冷启动时刻界说为从App创立到HomeActivity展示所需求的时刻,那么HomeActivity的tag就为1;每一个 代表这个页面的一个初始恳求,比方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初始化
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 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 Context context, int pageObjectKey) {
super(context);
this.pageObjectKey = pageObjectKey;
}
@Override
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完结代码的动态注入 。动态注入代码分为以下几步:
- 初始化埋点:SDK的初始化。
- 冷启动埋点:Application的冷启动开端时刻点。
- 页面埋点:Activity和Fragment页面的时刻点。
- 恳求埋点:网络恳求的时刻点。
初始化埋点
在 Transform 中遍历一切生成的class文件,找到Application对应的子类,在其 onCreate() 办法中调用SDK初始化API即可。
CtMethod method = it.getDeclaredMethod("onCreate")
method.insertBefore("${Constants.AUTO_SPEED_CLASSNAME}.getInstance().init(this);")
最终生成的pplication代码如下:
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 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 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()
上面的问题总结来看,便是初度烘托时刻和二次烘托时刻中,或许会有个等候切换的时刻,导致这两个时刻变长,而这个切换时刻点并不是 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;
}
}
总结
以上便是咱们对页面测速及主动化完结上做的一些测验,目前现已在项目中运用,并在监控平台上能够获取实时的数据。咱们能够经过剖析数据来了解页面的功能从而做优化,不断提高项目的全体质量。而且经过实践发现了一些测速差错的问题,也都逐一处理,使得测速数据更加可靠。主动化的完结也让咱们在后续开发中的保护变得更简单,不用保护页面测速相关的逻辑,就能够做到实时监测一切页面的加载速度。