由于工作需要,需要解決一些性能問題,雖然有 Profiler 、Systrace 等工具, 但是無法實時監控,于是計劃寫一個能實時監控性能的小工具,經過學習大佬們的文章, 最終完成了這個開源的性能實時檢測庫。初步能達到預期效果,這里做個記錄,算是小結了。
這個性能檢測庫,可以檢測以下問題
- UI 線程 block 檢測
- App 的 FPS 檢測
- 線程和線程池的創建和啟動監控
- IPC(進程間通訊)監控
同時還實現了以下功能
- 實時通過 logcat 打印問題
- 高效保存檢測信息到本地
- 提供上報到指定服務器接口
接入指南
1 在 APP 工程目錄下面的 build.gradle 添加如下內容
dependencies {
debugImplementation "com.xander.performance:perf:0.1.9"
releaseImplementation "com.xander.performance:perf-noop:0.1.9"
}
2 APP 工程的 Application 類新增類似如下初始化代碼
private void initPerformanceTool(Context context) {
PERF.Builder builder = new PERF.Builder().globalTag("p-tool") // 全局 log 日志 tag ,可以快速過濾日志
.checkUI(true, 100) // 檢查 ui 線程, 超過指定時間還未結束,會被認為 ui 線程 block
.checkThread(true) // 檢查線程和線程池的創建
.checkFps(true) // 檢查 Fps
.checkIPC(true) // 檢查 IPC 調用
.issueSupplier(new PERF.IssueSupplier() {
@Override
public long maxCacheSize() {
// issue 文件緩存的最大空間
return 1024 * 1024 * 20;
}
@Override
public File cacheRootDir() {
// issue 文件保存的根目錄
return getApplicationContext().getCacheDir();
}
@Override
public boolean upLoad(File file) {
// 上傳入口,返回 true 表示上傳成功
return false;
}
}).build();
PERF.init(builder);
}
原理介紹
- UI 線程 block 檢測原理
主要參考了 AndroidPerformanceMonitor 庫的思路,對 UI 線程的 Looper 里面處理的 Message 過程進行監控。 在 Looper 開始處理 Message 前,在異步線程開啟一個延時任務,用于后續收集信息。如果這個 Message 在指定的 時間段內完成了處理,那么在這個 Message 被處理完后,就取消之前的延時任務,說明 UI 線程沒有 block 。如果在指定 的時間段內沒有完成任務,說明 UI 線程有 block ,在判斷發生 block 的同時,我們可以在異步線程執行剛才的延時任務, 如果我們在這個延時任務里面打印 UI 線程的方法調用棧,就可以知道 UI 線程在做什么了。
但是這個方案有一個缺點,就是無法處理 InputManager 的輸入事件,比如 TV 端的遙控按鍵事件。通過按鍵事件的調用方法 鏈進行分析,最終每個按鍵事件都調用了 DecorView 類的 dispatchKeyEvent 方法,而非 Looper 的 loop Message 流程。所以 AndroidPerformanceMonitor 庫是無法準確監控 TV 端應用的耗時情況。針對 TV 端應用按鍵處理, 需要找到一個新的切入點,這個切入點就是剛剛的 DecorView 類的 dispatchKeyEvent 方法。那如何介入 DecorView 類的 dispatchKeyEvent 方法呢?我們通過 epic 庫來 hook 這個方法的調用,hook 成功后,我們可以在 DecorView 類的 dispatchKeyEvent 方法調用前后都接收到一個回調方法,在 dispatchKeyEvent 方法調用前我們可以在異步線程執行 一個延時任務,在 dispatchKeyEvent 方法調用后,取消這個延時任務。如果 dispatchKeyEvent 方法耗時時間小于 指定的時間閾值,可以認為沒有 block ,此時移除了延時任務。如果 dispatchKeyEvent 方法耗時時間大于指定的時間閾值 說明此事 UI 線程是有 block 的,此時,就會執行這個延時任務來收集必要的信息。
以上就是 UI 線程 block 的檢測原理了,目前做得還比較粗糙,后續可以考慮參考 AndroidPerformanceMonitor 打印 CPU 、內存等更多的信息。
最終終端 log 打印效果如下:
com.xander.performace.demo W/demo_Issue: =================================================
type: UI BLOCK
msg: UI BLOCK
create time: 2021-01-13 11:24:41
trace:
JAVA.lang.Thread.sleep(Thread.java:-2)
java.lang.Thread.sleep(Thread.java:442)
java.lang.Thread.sleep(Thread.java:358)
com.xander.performance.demo.MainActivity.testANR(MainActivity.kt:49)
java.lang.reflect.Method.invoke(Method.java:-2)
androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
android.view.View.performClick(View.java:7496)
android.view.View.performClickInternal(View.java:7473)
android.view.View.access$3600(View.java:831)
android.view.View$PerformClick.run(View.java:28641)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loop(Looper.java:236)
android.app.ActivityThread.main(ActivityThread.java:7876)
java.lang.reflect.Method.invoke(Method.java:-2)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)
復制代碼
- App 的 FPS 檢測的原理
FPS 檢測的原理,利用了 Android 的屏幕繪制原理。
這里簡單說下 Android 的屏幕繪制原理。
系統每隔 16 ms 就會發送一個 VSync 信號,如果 App 注冊了這個 VSync 信號,就會在 VSync 信號到來的時候,收到回調, 從而開始準備繪制,如果準備順利,也就是 cpu 準備好數據, gpu 柵格化完成。如果這些任務在 16 ms 之內完成,那么下一個 VSync 信號到來的時候就可以繪制這一幀界面了。這個準備好的畫面就會被顯示出來。如果沒準備好,可能就需要 32 ms 后 或者更久的時間后,才能準備好,這個畫面才能顯示出來,這種情況下就發生了丟幀。
上面提到了 VSync 信號,當 VSync 信號到來的時候會通知應用開始準備繪制,具體的通知細節不做表述。大概的原理就是, 開始準備繪制前,往 MessageQueue 里面放一個同步屏障,這樣 UI 線程就只會處理異步消息,直到同步屏障被移除, 然后 App 注冊一個 VSync 信號監聽,當 VSync 信號到達的時候,給 MessageQueue 里面放一個異步 Message 。 由于之前 MessageQueue 里有了一個同步屏障消息,所有后續 UI 線程會優先處理這個異步 Message 。 這個異步 Message 做的事情就是從 ViewRootImpl 開始了我們熟悉的 measure 、layout 和 draw 。
檢測 FPS 的原理其實挺簡單的,就是通過一段時間內,比如 1s,統計繪制了多少個畫面,就可以計算出 FPS 了。
那如何知道應用 1s 內繪制了多少個界面呢?這個就要靠 VSync 信號監聽了。我們通過 Choreographer 注冊 VSync 信號監聽。 16ms 后,我們收到了 VSync 的信號,給 MessageQueue 里面放一個同步消息,我們不做特別處理,只是做一個計數, 然后監聽下一次的 VSync 信號,這樣,我們就可以知道 1s 那我們監聽到了多少個 VSync 信號,就可以得出幀率。
為什么監聽到的 VSync 信號數量就是幀率呢?由于 Looper 處理 Message 是串行的,就是一次只處理一個 Message ,處理 完了這個 Message 才會處理下一個 Message 。而繪制的時候,繪制任務 Message 是異步消息,會優先執行,繪制任務 Message 執行完成后,就會執行上面說的 VSync 信號計數的任務,所以最后統計到的 VSync 信號數量可以認為是某段時間內繪制的幀數。 然后就可以通過這段時間的長度和 VSync 信號數量來計算幀率了。
最終終端 log 打印效果如下:
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 54 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz
- 線程和線程池的創建和啟動監控原理
線程和線程池的監控,主要是監控線程和線程池在哪里創建和執行的,如果我們可以知道這些信息, 我們就可以比較清楚線程和線程池的創建和啟動時機是否合理。從而得出優化方案。
一個比較容易想到的方法就是,應用代碼里面的所有線程和線程池繼承同一個線程基類和線程池基類。 然后在構造函數和啟動函數里面打印方法調用棧,這樣我們就知道哪里創建和執行了線程或者線程池。
讓應用所有的線程和線程池繼承同一個基類,可以通過編譯插件來實現,定制一個特殊的 Transform , 通過 ASM 編輯生成的字節碼來改變繼承關系。但是,這個方法有一定的上手難度,不太適合新手。
除了這個方法,我們還有另外一種方法,就是 hook 。通過 hook 線程或者線程池的構造方法和啟動方法, 我們就可以在線程或者線程池的構造方法和啟動方法的前后做一些切片處理,比如打印當前方法調用棧等。 這個也就是線程和線程池監控的基本原理。
線程池的監控沒有太大難度,一般都是 ThreadPoolExecutor 的子類,所以我們 hook 一下 ThreadPoolExecutor 的 構造方法就可以監控線程池的創建了。線程池的執行主要就是 hook 住 ThreadPoolExecutor 類的 execute 方法。
線程的創建和執行的監控方法就稍微要費些腦筋了,因為線程池里面會創建線程,所以這個線程的創建和執行應該和線程池 綁定的。需要找到線程和線程池的聯系,之前看到一個庫,好像是通過線程和線程池的 ThreadGroup 來建立關聯的,本來 我也計劃按照這個關系來寫代碼的,但是我發現,我們有的小伙伴寫的線程池的 ThreadFactory 里面創建線程并沒有傳入 ThreadGroup ,這個就尷尬了,就建立不了聯系了。經過查閱相關源碼發現了一個關鍵的類,ThreadPoolExecutor 的內部類 Worker ,由于這個類是內部類,所以這個類實際的構造方法里面會傳入一個外部類的實例,也就是 ThreadPoolExecutor 實例。 同時, Worker 這個類還是一個 Runnable 實現,在 Worker 類通過 ThreadFactory 創建線程的時候,會把自己作為一個 Runnable 傳給 Thread 所以,我們通過這個關系,就可以知道 Worker 和 Thread 的關聯了。這樣,我們通過 ThreadPoolExecutor 和 Worker 的關聯,以及 Worker 和 Thread 的關聯,就可以得到 ThreadPoolExecutor 和 它創建的 Thread 的關聯了。這個也就是線程和線程池的監控原理了。
最終終端 log 打印效果如下:
com.xander.performace.demo W/demo_Issue: =================================================
type: THREAD
msg: THREAD POOL CREATE
create time: 2021-01-13 11:23:47
create trace:
com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
com.xander.performance.ThreadTool$ThreadPoolExecutorConstructorHook.afterHookedMethod(ThreadTool.java:158)
de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:265)
me.weishu.epic.art.entry.Entry64.onHookObject(Entry64.java:64)
me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:239)
java.util.concurrent.Executors.newSingleThreadExecutor(Executors.java:179)
com.xander.performance.demo.MainActivity.testThreadPool(MainActivity.kt:38)
java.lang.reflect.Method.invoke(Method.java:-2)
androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
android.view.View.performClick(View.java:7496)
android.view.View.performClickInternal(View.java:7473)
android.view.View.access$3600(View.java:831)
android.view.View$PerformClick.run(View.java:28641)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loop(Looper.java:236)
android.app.ActivityThread.main(ActivityThread.java:7876)
java.lang.reflect.Method.invoke(Method.java:-2)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)
- IPC(進程間通訊)監控的原理
進程間通訊的具體原理,也就是 Binder 機制,這里不做詳細的說明,也不是這個框架庫的原理。
檢測進程間通訊的方法和前面檢測線程的方法類似,就是找到所有的進程間通訊的方法的共同點,然后 對共同點做一些修改或者說切片,讓應用在進行進程間通訊的時候,打印一下調用棧,然后繼續做原來 的事情。就達到了 IPC 監控的目的。
那如何找到共同點,或者說切片,就是本節的重點。
進程間通訊離不開 Binder ,需要從 Binder 入手。
寫一個 AIDL demo 后來發現,自動生成的代碼里面,接口 A 繼承自 IInterface 接口,然后接口里面有個 內部抽象類 Stub 類,繼承自 Binder ,同時實現了接口 A 。這個 Stub 類里面還有一個內部類 Proxy , 實現了接口 A ,并持有一個 IBinder 實例。
我們在使用 AIDL 的時候,會用到 Stub 類的 asInterFace 的方法,這個方法會新建一個 Proxy 實例, 并給這個 Proxy 實例傳入 IBinder , 或者如果傳入的 IBinder 實例如果是接口 A 的話,就強制轉化為接口 A 實例。 一般而言,這個 IBinder 實例是 ServiceConnection 的回調方法里面的實例,是 BinderProxy 的實例。 所以 Stub 類的 asInterFace 一般會創建一個 Proxy 實例,查看這個 Proxy 接口的實現方法, 發現最終都會調用 BinderProxy 的 transact 方法,所以 BinderProxy 的 transact 方法是一個很好的切入點。
本來我也是計劃通過 hook 住 BinderProxy 類的 transact 方法來做 IPC 的檢測的。但是 epic 庫在 hook 含有 Parcel 類型參數的方法的時候,不穩定,會有異常。由于暫時還沒能力解決這個異常,只能重新找切入點。 最后發現 AIDL demo 生成的代碼里面,除了調用了 調用 BinderProxy 的 transact 方法外, 還調用了 Parcel 的 readException 方法,于是決定 hook 這個方法來切入 IPC 調用流程, 從而達到 IPC 監控的目的。
最終終端 log 打印效果如下:
com.xander.performace.demo W/demo_Issue: =================================================
type: IPC
msg: IPC
create time: 2021-01-13 11:25:04
trace:
com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
com.xander.performance.IPCTool$ParcelReadExceptionHook.beforeHookedMethod(IPCTool.java:96)
de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:229)
me.weishu.epic.art.entry.Entry64.onHookVoid(Entry64.java:68)
me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:220)
me.weishu.epic.art.entry.Entry64.voidBridge(Entry64.java:82)
android.app.IActivityManager$Stub$Proxy.getRunningAppProcesses(IActivityManager.java:7285)
android.app.ActivityManager.getRunningAppProcesses(ActivityManager.java:3684)
com.xander.performance.demo.MainActivity.testIPC(MainActivity.kt:55)
java.lang.reflect.Method.invoke(Method.java:-2)
androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
android.view.View.performClick(View.java:7496)
android.view.View.performClickInternal(View.java:7473)
android.view.View.access$3600(View.java:831)
android.view.View$PerformClick.run(View.java:28641)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loop(Looper.java:236)
android.app.ActivityThread.main(ActivityThread.java:7876)
java.lang.reflect.Method.invoke(Method.java:-2)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)