Android自动化页面测速在美团的实践
  69EBWt67zZM1 2023年11月02日 48 0

Android自动化页面测速在美团的实践_网络请求

背景

随着移动互联网的快速发展,移动应用越来越注重用户体验。美团技术团队在开发过程中也非常注重提升移动应用的整体质量,其中很重要的一项内容就是页面的加载速度。如果发生冷启动时间过长、页面渲染时间过长、网络请求过慢等现象,就会直接影响到用户的体验,所以,如何监控整个项目的加载速度就成为我们部门面临的重要挑战。

对于测速这个问题,很多同学首先会想到在页面中的不同节点加入计算时间的代码,以此算出某段时间长度。然而,随着美团业务的快速迭代,会有越来越多的新页面、越来越多的业务逻辑、越来越多的代码改动,这些不确定性会使我们测速部分的代码耦合进业务逻辑,并且需要手动维护,进而增加了成本和风险。于是通过借鉴公司先前的一些方案,分析其存在的问题并结合自身特性,我们实现了一套无需业务代码侵入的自动化页面测速插件,本文将对其原理做一些解读和分析。

现有解决方案

  • 手动在 Application.onCreate() 中进行SDK的初始化调用,同时计算冷启动时间。

Android自动化页面测速在美团的实践_网络请求_02


Activity.setContentView()

Android自动化页面测速在美团的实践_初始化_03

i

  • 本地声明JSON配置文件来确定需要测速的页面以及该页面需要统计的初始网络请求API, getClass().getSimpleName() 作为页面的key,来标识哪些页面需要测速,指定一组API来标识哪些请求是需要被测速的。

Android自动化页面测速在美团的实践_网络请求_04

现有方案问题

  • 冷启动时间不准:冷启动起始时间从 Application.onCreate() 中开始算起,会使得计算出来的冷启动时间偏小,因为在该方法执行前可能会有 MultiDex.install() 等耗时方法的执行。
  • 特殊情况未考虑:忽略了ViewPager+Fragment延时加载这些常见而复杂的情况,这些情况会造成实际测速时间非常不准。
  • 手动注入代码:所有的代码都需要手动写入,耦合进业务逻辑中,难以维护并且随着新页面的加入容易遗漏。
  • 写死配置文件:如需添加或更改要测速的页面,则需要修改本地配置文件,进行发版。

目标方案效果

  • 自动注入代码,无需手动写入代码与业务逻辑耦合。
  • 支持Activity和Fragment页面测速,并解决ViewPager+Fragment延迟加载时测速不准的问题。
  • 在Application的构造函数中开始冷启动时间计算。
  • 自动拉取和更新配置文件,可以实时的进行配置文件的更新。

实现

我们要实现一个自动化的测速插件,需要分为五步进行:

  1. 测速定义:确定需要测量的速度指标并定义其计算方式。
  2. 配置文件:通过配置文件确定代码中需要测量速度指标的位置。
  3. 测速实现:如何实现时间的计算和上报。
  4. 自动化实现:如何自动化实现页面测速,不需要手动注入代码。
  5. 疑难杂症:分析并解决特殊情况。

测速定义

我们把页面加载流程抽象成一个通用的过程模型:页面初始化 -> 初次渲染完成 -> 网络请求发起 -> 请求完成并刷新页面 -> 二次渲染完成。据此,要测量的内容包括以下方面:

onCreate()

需要注意的是,网络请求时间是指定的一组请求全部完成的时间,即从 第一个请求发起开始,直到最后一个请求完成 所用的时间。

根据定义我们的测速模型如下图所示。

Android自动化页面测速在美团的实践_初始化_05

配置文件

接下来要知道哪些页面需要测速,以及页面的初始请求是哪些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配置文件,每个 代表这个页面的一个初始请求,比如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渲染时间有关,那么怎么获取页面的渲染结束时间点呢?

Android自动化页面测速在美团的实践_网络请求_06

由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();
 }
}

Android自动化页面测速在美团的实践_初始化_07

该方法用于处理渲染完毕的各种情况,包括初次渲染时间、二次渲染时间、冷启动时间以及相应的上报。这里的冷启动在 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。

Android自动化页面测速在美团的实践_网络请求_08

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

public Builder() {
 this.addInterceptor(new AutoSpeedRetrofitInterceptor());
 ...
}

public class AutoSpeedRetrofitInterceptor implements Interceptor {
 public Response intercept(Chain var1) throws IOException {
 AutoSpeed.getInstance().onApiLoadStart(var1.request().url());
 return var1.proceed(var1.request());
 }
}

public void enqueue(Callback<T> callback) {
 final Callback<T> callback = new AutoSpeedRetrofitCallback(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);
 }
}

public ViewPager(Context context) {
 ...
 this.addOnPageChangeListener(new AutoSpeedLazyLoadListener(this.mItems));
}

//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;
 }
 }
 }
 }
}

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();
 }
}

dispatchDraw()
dispatchDraw()
dispatchDraw()
dispatchDraw()

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;
 }
}
【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月08日 0

暂无评论

推荐阅读
69EBWt67zZM1