[GITHUB連結 Collie ](https://github.com/happylishang/Collie)
App性能如何量化
如何衡量一個APP性能好壞?直覺感受就是:啟動快、流暢、不閃退、耗電少等感官名額,反應到技術層面包裝下就是:FPS(幀率)、界面渲染速度、Crash率、網絡、CPU使用率、電量損耗速度等,一般挑其中幾個關鍵名額作為APP品質的标尺。目前也有多種開源APM監控方案,但大部分偏向離線檢測,對于線上監測而言顯得太重,可能會适得其反,方案簡單對比如下:
SDK | 現狀與問題 | 是否推薦直接線上使用 |
---|---|---|
騰訊matrix | 功能全,但是重,而且運作測試期間經常Crash | 否 |
騰訊GT | 2018年之後沒更新,關注度低,本身功能挺多,也挺重成本效益還不如matrix | 否 |
網易Emmagee | 2018年之後沒更新,幾乎沒有關注度,重 | 否 |
聽雲App | 适合監測網絡跟啟動,場景受限 | 否 |
還有其他多種APM檢測工具,功能複雜多樣,但其實很多名額并不是特别重要,實作越複雜,線上風險越大,是以,并不建議直接使用。而且,分析多家APP的實作原理,其核心思路基本相同,且門檻也并不是特别高,建議自研一套,在靈活性、安全性上更有保障,更容易做到輕量級。本文主旨就是圍繞幾個關鍵名額:FPS、記憶體(記憶體洩漏)、界面啟動、流量等,實作輕量級的線上監測。
核心性能名額拆解
- 穩定性:Crash統計
Crash統計與聚合有比較通用的政策,比如Firebase、Bugly等,不在本文讨論範圍
- 網絡請求
每個APP的網絡請求一般都存在統一的Hook點,門檻很低,且各家請求協定與SDK有别,很難實作統一的網絡請求監測,其次,想要真正定位網絡請求問題,可能牽扯整個請求的鍊路,更适合做一套網絡全鍊路監控APM,也不在讨論範圍。
- 冷啟動時間及各個Activity頁面啟動時間 (存在統一方案)
- 頁面FPS、卡頓、ANR (存在統一方案)
- 記憶體統計及記憶體洩露偵測 (存在統一方案)
- 流量消耗 (存在統一方案)
- 電量 (存在統一方案)
- CPU使用率(CPU):還沒想好咋麼用,7.0之後實作機制也變了,先不考慮
線上監測的重點就聚焦後面幾個,下面逐個拆解如何實作。
啟動耗時
直覺上說界面啟動就是:從點選一個圖示到看到下一個界面首幀,如果這個過程耗時較長,使用者會會感受到頓挫,影響體驗。從場景上說,啟動耗時間簡單分兩種:
- 冷啟動耗時:在APP未啟動的情況從,從點選桌面icon 到看到閃屏Activity的首幀(非預設背景)
- 界面啟動耗:APP啟動後,從上一個界面pause,到下一個界面首幀可見,
本文粒度較粗,主要聚焦Activity,這裡有個比較核心的時機:Activity首幀可見點,這個點究竟在什麼時候?經分析測試發現,不同版本表現不一,在Android 10 之前這個點與onWindowFocusChanged回調點基本吻合,在Android 10 之後,系統做了優化,将首幀可見的時機提前到onWindowFocusChanged之前,可以簡單看做onResume(或者onAttachedToWindow)之後,對于一開始點選icon的點,可以約等于APP程序啟動的點,拿到了上面兩個時間點,就可以得到冷啟動耗時。
APP程序啟動的點可以通過加載一個空的ContentProvider來記錄,因為ContentProvider的加載時機比較靠前,早于Application的onCreate之前,相對更準确一點,很多SDK的初始也采用這種方式,實作如下:
public class LauncherHelpProvider extends ContentProvider {
// 用來記錄啟動時間
public static long sStartUpTimeStamp = SystemClock.uptimeMillis();
...
}
複制
這樣就得到了冷啟動的開始時間,如何得到第一個Activity界面可見的時間呢?比較簡單的做法是在SplashActivity中進行打點,對于Android 10 以前的,可以在onWindowFocusChanged中打點,在Android 10以後,可以在onResume之後進行打點。不過,做SDK需要減少對業務的入侵,可以借助Applicattion監聽Activity Lifecycle無入侵擷取這個時間點。對于Android 10之前系統, 可以利用ViewTreeObserve監聽nWindowFocusChange回調,達到無入侵擷取onWindowFocusChanged調用點,示意代碼如下
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
....
@Override
public void onActivityResumed(@NonNull final Activity activity) {
super.onActivityResumed(activity);
launcherFlag |= resumeFlag;
<!--添加onWindowFocusChanged 監聽-->
activity.getWindow().getDecorView().getViewTreeObserver().addOnWindowFocusChangeListener(new ViewTreeObserver.OnWindowFocusChangeListener() {
<!--onWindowFocusChanged回調-->
@Override
public void onWindowFocusChanged(boolean b) {
if (b && (launcherFlag ^ startFlag) == 0) {
<!--判斷是不是首個Activity-->
final boolean isColdStarUp = ActivityStack.getInstance().getBottomActivity() == activity;
<!--擷取首幀可見距離啟動的時間-->
final long coldLauncherTime = SystemClock.uptimeMillis() - LauncherHelpProvider.sStartUpTimeStamp;
final long activityLauncherTime = SystemClock.uptimeMillis() - mActivityLauncherTimeStamp;
activity.getWindow().getDecorView().getViewTreeObserver().removeOnWindowFocusChangeListener(this);
<!--異步線程處理回調,減少UI線程負擔-->
mHandler.post(new Runnable() {
@Override
public void run() {
if (isColdStarUp) {
//todo 監聽到冷啟動耗時
...
複制
對于Android 10以後的系統,可以在onActivityResumed回調時添加一UI線程Message來達到監聽目的,代碼如下
@Override
public void onActivityResumed(@NonNull final Activity activity) {
super.onActivityResumed(activity);
if (launcherFlag != 0 && (launcherFlag & resumeFlag) == 0) {
launcherFlag |= resumeFlag;
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
// 10 之後有改動,第一幀可見提前了 可認為onActivityResumed之後
mUIHandler.post(new Runnable() {
@Override
public void run() {
<!--擷取第一幀可見時間點--> }
});
}
複制
如此就可以檢測到冷啟動耗時。APP啟動後,各Activity啟動耗時計算邏輯類似,首幀可見點沿用上面方案即可,不過這裡還缺少上一個界面暫停的點,經分析測試,錨在上一個Actiivty pause的時候比較合理,是以Activity啟動耗時定義如下:
Activity啟動耗時 = 目前Activity 首幀可見 - 上一個Activity onPause被調用
複制
同樣為了減輕對業務入侵,也依賴registerActivityLifecycleCallbacks來實作:補全上方缺失
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityPaused(@NonNull Activity activity) {
super.onActivityPaused(activity);
<!--記錄上一個Activity pause節點-->
mActivityLauncherTimeStamp = SystemClock.uptimeMillis();
launcherFlag = 0;
}
...
@Override
public void onActivityResumed(@NonNull final Activity activity) {
super.onActivityResumed(activity);
launcherFlag |= resumeFlag;
<!--參考上面擷取首幀的點-->
...
複制
到這裡就擷取了兩個比較關鍵的啟動耗時,不過,時機使用中可能存在各種異常場景:比如閃屏頁在onCreate或者onResume中調用了finish跳轉首頁,對于這種場景就需要額外處理,比如在onCreate中調用了finish,onResume可能不會被調用,這個時候就要在 onCreate之後進行統計,同時利用用Activity.isFinishing()辨別這種場景,其次,線上上實操的場景場景中,用onPause到第一幀可見可能存在其他未知問題,比如出現了模态的dialog,然後再跳轉的,這種時候,就可以計算失誤,是以,可以微調到onCreate開始到第一幀可見,最後,啟動耗時對于不同配置也是不一樣的,不能用絕對時間衡量,隻能橫向對比,簡單線上效果如下:
線上效果如下:

流暢度及FPS(Frames Per Second)監測
FPS是圖像領域中的定義,指畫面每秒傳輸幀數,每秒幀數越多,顯示的動作就越流暢。FPS可以作為衡量流暢度的一個名額,但是,從各廠商的報告來看,僅用FPS來衡量是否流暢并不科學。電影或視訊的FPS并不高,30的FPS即可滿足人眼需求,穩定在30FPS的動畫,并不會讓人感到卡頓,但如果FPS 很不穩定的話,就很容易感覺到卡頓,注意,這裡有個詞叫穩定。舉個極端例子:前500ms重新整理了59幀,後500ms隻繪制一幀,即使達到了60FPS,仍會感覺卡頓,這裡就突出穩定的重要性。不過FPS也并不是完全沒用,可以用其上限定義流暢,用其下限可以定義卡頓,對于中間階段的感覺,FPS無能為力,如下示意:
上面那個是極端例子,Android 系統中,VSYNC會杜絕16ms内重新整理兩次,那麼在中間的情況下怎麼定義流暢?比如,FPS降低到50會卡嗎?答案是不一定。50的FPS如果是均分到各個節點,使用者是感覺不到掉幀的,但,如果丢失的10幀全部在一次繪制點,那就能明顯感覺卡頓,這個時候,瞬時幀率的意義更大,如下
Matrix給的卡頓标準:
總之,相比1s平均FPS,瞬時掉幀程度的嚴重性更能反應界面流暢程度,是以FPS監測的重點是偵測瞬時掉幀程度。在應用中,FPS對動畫及清單意義較大,監測開始的時機放在界面啟動并展示第一幀之後,這樣就能跟啟動完美銜接起來,
// 幀率不統計第一幀
@Override
public void onActivityResumed(@NonNull final Activity activity) {
super.onActivityResumed(activity);
activity.getWindow().getDecorView().getViewTreeObserver().addOnWindowFocusChangeListener(new ViewTreeObserver.OnWindowFocusChangeListener() {
@Override
public void onWindowFocusChanged(boolean b) {
if (b) {
<!--界面可見後,開始偵測FPS-->
resumeTrack();
activity.getWindow().getDecorView().getViewTreeObserver().removeOnWindowFocusChangeListener(this);
...
}
複制
偵測停止的時機也比較簡單在onActivityPaused:界面失去焦點,無法與使用者互動的時候
@Override
public void onActivityPaused(@NonNull Activity activity) {
super.onActivityPaused(activity);
pauseTrack(activity.getApplication());
}
複制
如何偵測瞬時FPS?有兩種常用方式
- 360 ArgusAPM類實作方式: 監測Choreographer兩次Vsync時間差
- BlockCanary的實作方式:監測UI線程單條Message執行時間
360的實作依賴Choreographer VSYNC回調,具體實作如下:循環添加Choreographer.FrameCallback
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
mFpsCount++;
mFrameTimeNanos = frameTimeNanos;
if (isCanWork()) {
//注冊下一幀回調
Choreographer.getInstance().postFrameCallback(this);
} else {
mCurrentCount = 0;
}
}
});
複制
這種監聽有個問題就是,監聽過于頻繁,因為在無需界面重新整理的時候Choreographer.FrameCallback還是不斷循環執行,浪費CPU資源,對線上運作采集并不友好,相比之下BlockCanary的監聽單個Message執行要友善的多,而且同樣能夠涵蓋UI繪制耗時、兩幀之間的耗時,額外執行負擔較低,也是本文采取的政策,核心實作參照Matrix:
- 監聽Message執行耗時
- 通過反射循環添加Choreographer.FrameCallback區分doFrame耗時
為Looper設定一個LooperPrinter,根據回傳資訊頭區分消息執行開始于結束,計算Message耗時:原理如下
public static void loop() {
...
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
...
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
複制
自定義LooperPrinter如下:
class LooperPrinter implements Printer {
@Override
public void println(String x) {
...
if (isValid) {
<!--區分開始結束,計算消息耗時-->
dispatch(x.charAt(0) == '>', x);
}
複制
利用回調參數">>>>"與"<<<"的 差別即可診斷出Message執行耗時,進而确定是否導緻掉幀。以上實作針對所有UI Message,原則上UI線程所有的消息都應該保持輕量級,任何消息逾時都應當算作異常行為,是以,直接拿來做掉幀監測沒特大問題的。但是,有些特殊情況可能對FPS計算有一些誤判,比如,在touch時間裡往UI線程塞了很多消息,單條一般不會影響滾動,但多條聚合可能會帶來影響,如果沒跳消息執行時間很短,這種方式就可能統計不到,當然這種業務的寫法本身就存在問題,是以先不考慮這種場景。
Choreographer有個方法addCallbackLocked,通過這個方法添加的任務會被加入到VSYNC回調,會跟Input、動畫、UI繪制一起執行,是以可以用來作為鑒别是否是UI重繪的Message,看看是不是重繪或者觸摸事件導緻的卡頓掉幀。Choreographer源碼如下:
@UnsupportedAppUsage
public void addCallbackLocked(long dueTime, Object action, Object token) {
CallbackRecord callback = obtainCallbackLocked(dueTime, action, token);
CallbackRecord entry = mHead;
if (entry == null) {
mHead = callback;
return;
}
if (dueTime < entry.dueTime) {
callback.next = entry;
mHead = callback;
return;
}
while (entry.next != null) {
if (dueTime < entry.next.dueTime) {
callback.next = entry.next;
break;
}
entry = entry.next;
}
entry.next = callback;
}
複制
該方法不為外部可見,是以需要通過反射擷取,
private synchronized void addFrameCallback(int type, Runnable callback, boolean isAddHeader) {
try {
<!--反射擷取方法-->
addInputQueue = reflectChoreographerMethod(0 “addCallbackLocked”, long.class, Object.class, Object.class);
<!--添加回調-->
if (null != method) {
method.invoke(callbackQueues[type], !isAddHeader ? SystemClock.uptimeMillis() : -1, callback, null);
}
複制
然後在每次執行結束後,重新将callback添加回Choreographer的Queue,監聽下一次UI繪制。
@Override
public void dispatchEnd() {
super.dispatchEnd();
if (mStartTime > 0) {
long cost = SystemClock.uptimeMillis() - mStartTime;
<!--計算耗時-->
collectInfoAndDispatch(ActivityStack.getInstance().getTopActivity(), cost, mInDoFrame);
if (mInDoFrame) {
<!--監聽下一次UI繪制-->
addFrameCallBack();
mInDoFrame = false;
}
}
}
複制
這樣就能檢測到每次Message執行的時間,它可以直接用來計算瞬時幀率,
瞬時掉幀程度 = Message耗時/16 -1 (不足1 可看做1)
複制
瞬時掉幀小于2次可以認為沒有發生抖動,如果出現了單個Message執行過長,可認為發生了掉幀,流暢度與瞬時幀率監測大概就是這樣。不過,同啟動耗時類似,不同配置結果不同,不能用絕對時間衡量,隻能橫向對比,簡單線上效果如下:
image
記憶體洩露及記憶體使用偵測
記憶體洩露有個比較出名的庫LeakCanary,實作原理也比較清晰,就是利用弱引用+ReferenceQueue,其實隻用弱引用也可以做,ReferenceQueue隻是個輔助作用,LeakCanary除了洩露檢測還有個堆棧Dump的功能,雖然很好,但是這個功能并不适合線上,而且,隻要能監聽到Activity洩露,本地分析原因是比較快的,沒必要将堆棧Dump出來。是以,本文隻實作Activity洩露監測能力,不線上上分析原因。而且,參考LeakCanary,改用一個WeakHashMap實作上述功能,不在主動暴露ReferenceQueue這個對象。WeakHashMap最大的特點是其key對象被自動弱引用,可以被回收,利用這個特點,用其key監聽Activity回收就能達到洩露監測的目的。核心實作如下:
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
super.onActivityDestroyed(activity);
<!--放入map,進行監聽-->
mActivityStringWeakHashMap.put(activity, activity.getClass().getSimpleName());
}
@Override
public void onActivityStopped(@NonNull final Activity activity) {
super.onActivityStopped(activity);
// 退背景,GC 找LeakActivity
if (!ActivityStack.getInstance().isInBackGround()) {
return;
}
Runtime.getRuntime().gc();
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
try {
if (!ActivityStack.getInstance().isInBackGround()) {
return;
}
try {
// 申請個稍微大的對象,促進GC
byte[] leakHelpBytes = new byte[4 * 1024 * 1024];
for (int i = 0; i < leakHelpBytes.length; i += 1024) {
leakHelpBytes[i] = 1;
}
} catch (Throwable ignored) {
}
Runtime.getRuntime().gc();
SystemClock.sleep(100);
System.runFinalization();
HashMap<String, Integer> hashMap = new HashMap<>();
for (Map.Entry<Activity, String> activityStringEntry : mActivityStringWeakHashMap.entrySet()) {
String name = activityStringEntry.getKey().getClass().getName();
Integer value = hashMap.get(name);
if (value == null) {
hashMap.put(name, 1);
} else {
hashMap.put(name, value + 1);
}
}
if (mMemoryListeners.size() > 0) {
for (Map.Entry<String, Integer> entry : hashMap.entrySet()) {
for (ITrackMemoryListener listener : mMemoryListeners) {
listener.onLeakActivity(entry.getKey(), entry.getValue());
}
}
}
} catch (Exception ignored) {
}
}
}, 10000);
}
複制
線上選擇監測沒必要實時,将其延後到APP進入背景的時候,在APP進入背景之後主動觸發一次GC,然後延時10s,進行檢查,之是以延時10s,是因為GC不是同步的,為了讓GC操作能夠順利執行完,這裡選擇10s後檢查。在檢查前配置設定一個4M的大記憶體塊,再次確定GC執行,之後就可以根據WeakHashMap的特性,查找有多少Activity還保留在其中,這些Activity就是洩露Activity。
關于記憶體檢測
記憶體檢測比較簡單,弄清幾個關鍵的名額就行,這些名額都能通過 Debug.MemoryInfo擷取
Debug.MemoryInfo debugMemoryInfo = new Debug.MemoryInfo();
Debug.getMemoryInfo(debugMemoryInfo);
appMemory.nativePss = debugMemoryInfo.nativePss >> 10;
appMemory.dalvikPss = debugMemoryInfo.dalvikPss >> 10;
appMemory.totalPss = debugMemoryInfo.getTotalPss() >> 10;
複制
這裡關心三個就行,
- TotalPss(整體記憶體,native+dalvik+共享)
- nativePss (native記憶體)
- dalvikPss (java記憶體 OOM原因)
一般而言total是大于nativ+dalvik的,因為它包含了共享記憶體,理論上我們隻關心native跟dalvik就行,以上就是關于記憶體的監測能力,不過記憶體洩露不是100%正确,暴露明顯問題即可,效果如下:
流量監測
流量監測的實作相對簡單,利用系統提供的TrafficStats.getUidRxBytes方法,配合Actvity生命周期,即可擷取每個Activity的流量消耗。具體做法:在Activity start的時候記錄起點,在pause的時候累加,最後在Destroyed的時候統計整個Activity的流量消耗,如果想要做到Fragment次元,就要具體業務具體分析了,簡單實作如下
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityStarted(@NonNull Activity activity) {
super.onActivityStarted(activity);
<!--開始記錄-->
markActivityStart(activity);
}
@Override
public void onActivityPaused(@NonNull Activity activity) {
super.onActivityPaused(activity);
<!--累加-->
markActivityPause(activity);
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
super.onActivityDestroyed(activity);
<!--統計結果,并通知回調-->
markActivityDestroy(activity);
}
};
複制
電量檢測
Android電量狀态能通過一下方法實時擷取,隻是對于分析來說有點麻煩,需要根據不同手機、不同配置做聚合,單處采集很簡單
IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
android.content.Intent batteryStatus = application.registerReceiver(null, filter);
int status = batteryStatus.getIntExtra("status", 0);
boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL;
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
複制
不過并不能擷取絕對電量,隻能看百分比,因為對單個Activity來做電量監測并不靠譜,往往都是0,可以在APP推到背景後,對真個線上時長的電池消耗做監測,這個可能還能看出一些電量變化。
CPU使用監測
沒想好怎麼弄,顯不出力
資料整合與基線制定
APP端隻是完成的資料的采集,資料的整合及根系還是要依賴背景資料分析,根據不同配置,不同場景才能制定一套比較合理的基線,而且,這種基線肯定不是絕對的,隻能是相對的,這套基線将來可以作為頁面性能評估标準,對Android而言,挺難,機型太多。
總結
- 啟動有相對靠譜節點
- 瞬時FPS(瞬時掉幀程度)意義更大
- 記憶體洩露可以一個WeakHashMap簡單搞定
- 電量及CPU還不知道怎麼用
[GITHUB連結 Collie ](https://github.com/happylishang/Collie)
作者:看書的小蝸牛
原文連結:[Android輕量級APM性能監測方案](https://www.jianshu.com/p/978b7bce6290)