Android 自动化页面测速在美团的实践,android 蓝牙开发框架
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;//关联的页面 keyprivate AutoSpeedFrameLayout(@NonNull Context context, int pageObjectKey) {super(context);this.pageObjectKey = pageObjectKey;}@Overrideprotected 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);")
最终生成的 Application 代码如下:
public void onCreate() {...AutoSpeed.getInstance().init(this);}
冷启动埋点
同上一步,找到 Application 对应的子类,在其构造方法中记录冷启动开始时间,在 SDK 初始化时候传入 SDK,原因在上文已经解释过。
//Applicationprivate 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;//利用递归包装根 Viewpublic 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() 方法:
<pre style="margin: 0px; padding: 0px; border: 0px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-variant-numeric: inherit; font-variant-east-asian: inherit; font-w
eight: 400; font-stretch: inherit; font-size: 18px; line-height: inherit; font-family: inherit; vertical-align: baseline; word-break: break-word; color: rgb(93, 93, 93); letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">
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 对象,在切换时记录一个时间,这样可以通过用这个时间减去页面创建后的时间得出这个多余的等待时间,上报时在总时间中减去即可。
评论