隨着移動互聯網的快速發展,移動應用愈來愈注重用戶體驗。美團技術團隊在開發過程當中也很是注重提高移動應用的總體質量,其中很重要的一項內容就是頁面的加載速度。若是發生冷啓動時間過長、頁面渲染時間過長、網絡請求過慢等現象,就會直接影響到用戶的體驗,因此,如何監控整個項目的加載速度就成爲咱們部門面臨的重要挑戰。 對於測速這個問題,不少同窗首先會想到在頁面中的不一樣節點加入計算時間的代碼,以此算出某段時間長度。然而,隨着美團業務的快速迭代,會有愈來愈多的新頁面、愈來愈多的業務邏輯、愈來愈多的代碼改動,這些不肯定性會使咱們測速部分的代碼耦合進業務邏輯,而且須要手動維護,進而增長了成本和風險。因而經過借鑑公司先前的方案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 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實現代碼的動態注入。動態注入代碼分爲如下幾步:
在 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 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()
,即直到切換到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年畢業於天津工業大學,同年加入美團點評到店餐飲事業羣,從事商家銷售端移動應用開發工做。