Android UI卡頓監控

一、背景

應用的使用流暢度,是衡量用戶體驗的重要標準之一。Android 由于機型配置和系統的不同,項目復雜App場景豐富,代碼多人參與迭代歷史較久,代碼可能會存在很多UI線程耗時的操作,實際測試時候也會偶爾發現某些業務場景發生卡頓的現象,用戶也經常反饋和投訴App使用遇到卡頓。因此,我們越來越關注和提升用戶體驗的流暢度問題。

二、方案

基于這樣的痛點,我們希望能使用一套有效的檢測機制,能夠覆蓋各種可能出現的卡頓場景,一旦發生卡頓,能幫助我們更方便地定位耗時卡頓發生的地方,記錄下具體的信息和堆棧,直接從代碼程度給到開發定位卡頓問題。我們設想的Android卡頓監控系統需要達到幾項基本功能:

  • 如何有效地監控到App發生卡頓,同時在發生卡頓時正確記錄app的狀態,如堆棧信息,CPU占用,內存占用,IO使用情況等等;

  • 統計到的卡頓信息上報到監控平臺,需要處理分析分類上報內容,并通過平臺Web直觀簡便地展示,供開發跟進處理。

三、如何從App層面監控卡頓?

我們的思路是,一般主線程過多的UI繪制、大量的IO操作或是大量的計算操作占用CPU,導致App界面卡頓。只要我們能在發生卡頓的時候,捕捉到主線程的堆棧信息和系統的資源使用信息,即可準確分析卡頓發生在什么函數,資源占用情況如何。那么問題就是如何有效檢測Android主線程的卡頓發生,目前業界兩種主流有效的app監控方式如下:

  • 利用UI線程的Looper打印的日志匹配;

  • 使用Choreographer.FrameCallback。

3.1、利用UI線程的Looper打印的日志匹配判斷是否卡頓

Android主線程更新UI。如果界面1秒鐘刷新少于60次,即FPS小于60,用戶就會產生卡頓感覺。簡單來說,Android使用消息機制進行UI更新,UI線程有個Looper,在其loop方法中會不斷取出message,調用其綁定的Handler在UI線程執行。如果在handler的dispatchMesaage方法里有耗時操作,就會發生卡頓。

3.1.1、Looper.loop( )的源碼

public static void loop() {final Looper me = myLooper();if (me == null) { throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");}final MessageQueue queue = me.mQueue;// Make sure the identity of this thread is that of the local process,// and keep track of what that identity token actually is.Binder.clearCallingIdentity();final long ident = Binder.clearCallingIdentity();    for (;;) {Message msg = queue.next(); // might blockif (msg == null) {// No message indicates that the message queue is quitting.return;}// This must be in a local variable, in case a UI event sets the loggerPrinter logging = me.mLogging;        if (logging != null) {logging.println(">>>>> Dispatching to " + msg.target + " " +msg.callback + ": " + msg.what);}msg.target.dispatchMessage(msg);        if (logging != null) {logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);}        // Make sure that during the course of dispatching the// identity of the thread wasn't corrupted.final long newIdent = Binder.clearCallingIdentity();        if (ident != newIdent) {Log.wtf(TAG, "Thread identity changed from 0x"+ Long.toHexString(ident) + " to 0x"+ Long.toHexString(newIdent) + " while dispatching to "+ msg.target.getClass().getName() + " "+ msg.callback + " what=" + msg.what);}msg.recycleUnchecked();}
}

只要檢測第25行代碼msg.target.dispatchMessage(msg) 的執行時間,就能檢測到部分UI線程是否有耗時的操作。注意到這行執行代碼的前后,有兩個logging.println函數,如果設置了logging,會分別打印出”>>>>> Dispatching to “和”<<<<< Finished to “這樣的日志,這樣我們就可以通過兩次log的時間差值,來計算dispatchMessage的執行時間,從而設置閾值判斷是否發生了卡頓。
在這里插入圖片描述

3.1.2、如何設置logging呢?

我們看第19行代碼me.mLogging源碼

public final class Looper {  private Printer mLogging;  public void setMessageLogging(@Nullable Printer printer) {  mLogging = printer;  }  
}  public interface Printer {  void println(String x);  
}

Looper的mLogging是私有的,并且提供了setMessageLogging(@Nullable Printer printer)方法,所以我們可以自己實現一個Printer,在通過setMessageLogging()方法傳入即可,代碼如下:

public class BlockDetectByPrinter {    public static void start() {Looper.getMainLooper().setMessageLogging(new Printer() { private static final String START = ">>>>> Dispatching"; private static final String END = "<<<<< Finished";@Overridepublic void println(String x) {if (x.startsWith(START)) {LogMonitor.getInstance().startMonitor();}  if (x.startsWith(END)) {LogMonitor.getInstance().removeMonitor();}}});}
}

設置了logging后,loop方法會回調logging.println打印出每次消息執行的時間日志:”>>>>> Dispatching to “和”<<<<< Finished to “。BlockDetectByPrinter的使用則在Application的onCreate方法中調用 BlockDetectByPrinter.start() 即可。

我們可以簡單實現一個LogMonitor來記錄卡頓時候主線程的堆棧信息。當匹配到>>>>> Dispatching時,執行startMonitor,會在1000ms(設定的卡頓閾值)后執行任務,這個任務負責在子線程(非UI線程)打印UI線程的堆棧信息。如果消息低于1000ms內執行完成,就可以匹配到<<<<< Finished日志,那么在打印堆棧任務啟動前執行removeMonitor取消了這個任務,則認為沒有卡頓的發生;如果消息超過1000ms才執行完畢,此時認為發生了卡頓,并打印UI線程的堆棧信息。

3.1.3、LogMonitor如何實現?

public class LogMonitor {    private static LogMonitor sInstance = new LogMonitor();    private HandlerThread mLogThread = new HandlerThread("log");    private Handler mIoHandler;    private static final long TIME_BLOCK = 1000L;    private LogMonitor() {mLogThread.start();mIoHandler = new Handler(mLogThread.getLooper());} private static Runnable mLogRunnable = new Runnable() {        @Overridepublic void run() {StringBuilder sb = new StringBuilder();StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();            for (StackTraceElement s : stackTrace) {sb.append(s.toString() + "\n");}Log.e("TAG", sb.toString());}};public static LogMonitor getInstance() {        return sInstance;}    public boolean isMonitor() {        return mIoHandler.hasCallbacks(mLogRunnable);}    public void startMonitor() {mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);}    public void removeMonitor() {mIoHandler.removeCallbacks(mLogRunnable);}
}

這里我們使用HandlerThread來構造一個Handler,HandlerThread繼承自Thread,實際上就一個Thread,只不過比普通的Thread多了一個Looper,對外提供自己這個Looper對象的getLooper方法,然后創建Handler時將HandlerThread中的looper對象傳入。這樣我們的mIoHandler對象就是與HandlerThread這個非UI線程綁定的了,它處理耗時操作將不會阻塞UI。如果UI線程阻塞超過1000ms,就會在子線程中執行mLogRunnable,打印出UI線程當前的堆棧信息,如果處理消息沒有超過1000ms,則會實時的remove掉這個mLogRunnable任務。

發生卡頓時打印出堆棧信息的大致內容如下,開發可以通過log定位耗時的地方。


優點:用戶使用app或者測試過程中都能從app層面來監控卡頓情況,一旦出現卡頓能記錄app狀態和信息, 只要dispatchMesaage執行耗時過大都會記錄下來,不再有前面兩種adb方式面臨的問題與不足。

缺點:需另開子線程獲取堆棧信息,會消耗少量系統資源。

在實際實現中,不同手機不同Android系統甚至是不同的ROM版本,Loop函數不一定都能打印出”>>>>> Dispatching to “和”<<<<< Finished to “這樣的日志,導致該方式無法進行。

優化的策略:我們知道Loop函數開始和結束必會執行println打印日志,所以優化版本將卡頓的判斷改為,Loop輸出第一句log時當作startMonitor,輸出下一句log時當作end時刻來解決這個問題。

3.2、利用Choreographer.FrameCallback監控卡頓

Choreographer.FrameCallback官方文檔鏈接(https://developer.android.com/reference/android/view/Choreographer.FrameCallback.html)

我們知道, Android系統每隔16ms發出VSYNC信號,來通知界面進行重繪、渲染,每一次同步的周期為16.6ms,代表一幀的刷新頻率。SDK中包含了一個相關類,以及相關回調。理論上來說兩次回調的時間周期應該在16ms,如果超過了16ms我們則認為發生了卡頓,利用兩次回調間的時間周期來判斷是否發生卡頓(這個方案是Android 4.1 API 16以上才支持)。

這個方案的原理主要是通過Choreographer類設置它的FrameCallback函數,當每一幀被渲染時會觸發回調FrameCallback, FrameCallback回調void doFrame (long frameTimeNanos)函數。一次界面渲染會回調doFrame方法,如果兩次doFrame之間的間隔大于16.6ms說明發生了卡頓。
在這里插入圖片描述

public class BlockDetectByChoreographer {public static void start() {Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { long lastFrameTimeNanos = 0; long currentFrameTimeNanos = 0;@Overridepublic void doFrame(long frameTimeNanos) { if(lastFrameTimeNanos == 0){lastFrameTimeNanos == frameTimeNanos;}currentFrameTimeNanos = frameTimeNanos;long diffMs = TimeUnit.MILLISECONDS.convert(currentFrameTimeNanos-lastFrameTimeNanos, TimeUnit.NANOSECONDS);if (diffMs > 16.6f) {            long droppedCount = (int)diffMs / 16.6;}if (LogMonitor.getInstance().isMonitor()) {LogMonitor.getInstance().removeMonitor();                    } LogMonitor.getInstance().startMonitor();Choreographer.getInstance().postFrameCallback(this);}});}
}

在每一幀被渲染時,記下了渲染的時間用來計算掉幀數,數據可以繪制出流暢度曲線;同時,獲取isMonitor()函數判斷上一幀LogMonitor已經啟動打印堆棧的任務,如果已啟動,則移除LogMonitor,此時如果上一幀渲染的時間到現在已經超過了閾值,則已經執行了任務打印堆棧出來了;如果沒有超過閾值則及時移除了任務。如果isMonitor返回false,沒有LogMonitor回調任務,則開始新的一幀的監控任務。

優點:不僅可用來從app層面來監控卡頓,同時可以實時計算幀率和掉幀數,實時監測App頁面的幀率數據,一旦發現幀率過低,可自動保存現場堆棧信息。

缺點:需另開子線程獲取堆棧信息,會消耗少量系統資源。

3.3、總結下上述兩種方案的對比情況:

Looper.loopChoreographer.FrameCallback
監控是否卡頓
支持靜態頁面卡頓檢測
支持計算幀率X
支持獲取App運行信息

實際項目使用中,我們一開始兩種監控方式都用上,上報的兩種方式收集到的卡頓信息我們分開處理,發現卡頓的監控效果基本相當。同一個卡頓發生時,兩種監控方式都能記錄下來。 由于Choreographer.FrameCallback的監控方式不僅用來監控卡頓,也方便用來計算實時幀率,因此我們現在只使用Choreographer.FrameCallback來監控app卡頓情況。

四、如何保證捕獲卡頓堆棧的準確性?

細心的同學可以發現,我們通過上述兩種方案(Looper.loop和Choreographer.FrameCallback)可以判斷是當前主線程是否發生了卡頓,進而在計算發現卡頓后的時刻dump下了主線程的堆棧信息。實際上,通過一個子線程,監控主線程的活動情況,計算發現超過閾值后dump下主線程的堆棧,那么生成的堆棧文件只是捕捉了一個時刻的現場快照。打個不太恰當的比方,相當于閉路電視監控只拍下了兇案發生后的慘狀,而并沒有錄下這個案件發生的過程,那么作為警察的你只看到了結局,依然很難判斷案情和兇手。在實際的運用中,我們也發現這種方式下獲取到的堆棧情況,查看相關的代碼和函數,經常已經不是發生卡頓的代碼了。
在這里插入圖片描述
如圖所示,主線程在T1~T2時間段內發生卡頓,上述方案中獲取卡頓堆棧的時機已經是T2時刻。實際卡頓可能是這段時間內某個函數的耗時過大導致卡頓,而不一定是T2時刻的問題,如此捕獲的卡頓信息就無法如實反應卡頓的現場。

我們看看在這之前微信iOS主線程卡頓監控系統是如何實現的捕獲堆棧。微信iOS的方案是起檢測線程每1秒檢查一次,如果檢測到主線程卡頓,就將所有線程的函數調用堆棧dump到內存中。本質上,微信iOS方案的計時起點是固定的,檢查次數也是固定的。如果任務1執行花費了較長的時間導致卡頓,但由于監控線程是隔1秒掃一次的,可能到了任務N才發現并dump下來堆棧,并不能抓到關鍵任務1的堆棧。這樣的情況的確是存在的,只不過現上監控量大走人海戰術,通過概率分布抓到卡頓點,但依然不是最佳的捕獲方案。

因此,擺在我們面前的是如何更加精準地獲取卡頓堆棧。為了卡頓堆棧的準確度,我們想要能獲取一段時間內的堆棧,而不是一個點的堆棧,如下圖:
在這里插入圖片描述
我們采用高頻采集的方案來獲取一段卡頓時間內的多個堆棧,而不再是只有一個點的堆棧。這樣的方案的優點是保證了監控的完備性,整個卡頓過程的堆棧都得以采樣、收集和落地。

具體做法是在子線程監控的過程中,每一輪log輸出或是每一幀開始啟動monitor時,我們便已經開啟了高頻采樣收集主線程堆棧的工作了。當下一輪log或者下一幀結束monitor時,我們判斷是否發生卡頓(計算耗時是否超過閾值),來決定是否將內存中的這段堆棧集合落地到文件存儲。也就是說,每一次卡頓的發生,我們記錄了整個卡頓過程的多個高頻采樣堆棧。由此精確地記錄下整個兇案發生的詳細過程,供上報后分析處理(后文會闡述如何從一次卡頓中多個堆棧信息中提取出關鍵堆棧)。

五、海量卡頓堆棧后該如何處理?

卡頓堆棧上報到平臺后,需要對上報的文件進行分析,提取和聚類過程,最終展示到卡頓平臺。前面我們提到,每一次卡頓發生時,會高頻采樣到多個堆棧信息描述著這一個卡頓。做個最小的估算,每天上報收集2000個用戶卡頓文件,每個卡頓文件dump下了用戶遇到的10個卡頓,每個卡頓高頻收集到30個堆棧,這就已經產生20001030=60W個堆棧。按照這個量級發展,一個月可產生上千萬的堆棧信息,每個堆棧還是幾十行的函數調用關系。這么大量的信息對存儲,分析,頁面展示等均帶來相當大的壓力。很快就能撐爆存儲層,平臺無法展示這么大量的數據,開發更是沒辦法處理這些多的堆棧問題。因而,海量卡頓堆棧成為我們另外一個面對的難題。

在一個卡頓過程中,一般卡頓發生在某個函數的調用上,在這多個堆棧列表中,我們把每個堆棧都做一次hash處理后進行排重分析,有很大的幾率會是dump到同一個堆棧hash,如下圖:
在這里插入圖片描述

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/446897.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/446897.shtml
英文地址,請注明出處:http://en.pswp.cn/news/446897.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

linux查看內核版本信息

使用命令 uname -acat /proc/version

C語言學習:snprintf()函數

函數原型&#xff1a; int snprintf(char* dest_str,size_t size,const char* format,...);函數功能&#xff1a; 先將可變參數 “...” 按照format的格式格式化為字符串&#xff0c;然后再將其拷貝至dest_str中。 頭文件&#xff1a; #include<stdio.h>注意事項&…

物理 質點運動學

常用公式 重點 1.求軌道方程:消去時間t 2.dr---->位置矢量大小的增量 3.求方向:tanax/y 4.求位置也就是求位移rxiyj即可 習題解析 1.求運動時一定要求出加速度,變速與勻速就是看a 2.求位移時必須看X0是不是為0,如果不為0,求位移與路程時都要減去x0 3. 記住等號兩邊統一…

使用CLion的時候,對于cmake的使用

問題概述 使用CLion的時候&#xff0c;一個大的項目會有一個總的CMakeLists.txt&#xff0c;這個是控制整個項目的編譯環境&#xff0c;但是針對測試的代碼會有自己的單獨的CMakeLists.txt&#xff0c;這個單獨的cmake文件是控制自己的程序所需要的環境即使是編譯單獨的測試程…

Android NDK之靜態/動態注冊Native方法

一、簡介 關于NDK有兩種方法注冊&#xff1a;靜態注冊和動態注冊。 靜態注冊&#xff1a; 就是直接在Java文件里寫個native方法 然后再c/c文件中實現這個方法就行了&#xff1b;動態注冊&#xff1a; 就是為了不要寫很長的方法名&#xff0c;用JNI_OnLoad方法實現預注冊&…

概率論 條件概率 全概率 貝葉斯公式

常用知識點 條件概率 1.P(B|A)1表示A發生的情況下B必然發生 A屬于B 2.可列可加性 P(BUC|A)P(B|A)P(C|A) 3.P(B|A)的樣本空間為A,A與B都發生了 大題解答思路 1.首先設取出一件商品為次品為事件A 2.寫B1:甲生產,B2:乙生產 PB1…PB2… P(A|B1)…P(A|B2)… 3.寫PAPB1*P(A|B1)……

使用命令行的方式,將ini配置文件中的配置信息傳遞給程序

ini配置文件 {"device_type": "fake","device_socket": "192.168.1.108:5000"} 使用rpc的方式 ./bin/hsm_device_apitest --gtest_filter"*aes_test" --device-type rpc --device-socket 192.168.1.108:5000 使用fake的方…

C語言學習:malloc()函數

函數聲明&#xff1a; void *malloc(size_t size)頭文件&#xff1a; #include <stdio.h>函數描述&#xff1a; 分配所需的內存空間&#xff0c;并返回一個指向它的指針。 參數&#xff1a; size – 內存塊的大小&#xff0c;以字節為單位。 返回值&#xff1a; 該…

java 希爾排序

希爾排序(更高效的插入排序) 減少最小數在最后一位的情況下要循環的次數 思路: 把數組按增量(n/2)分組,對每一組使用插入排序去排序交換位置,然后不停地增量/2,直到其為1時,結束 分組:如n/25 891723 8與3為一組 從不包含本身的數開始數兩種實現方法: 交換法(效率較低) 移動法…

使用gtest進行自己的單獨測試的代碼介紹

命令行 ./bin/hsm_device_apitest --gtest_filter"*aes_test" --device-type rpc --device-socket 192.168.1.108:5000 命令詳解 進入工程文件&#xff0c;mkdir build&#xff0c;cd build在build的文件夾下面執行cmake命令和make命令之后&#xff0c;會在build文…

C語言學習:%d、2d、02d、.2d的區別

%d&#xff1a;為普通的輸出。 %2d&#xff1a;按寬度為2輸出&#xff0c;右對齊方式輸出。若不夠兩位&#xff0c;左邊補空格。 %02d&#xff1a;同樣寬度為2&#xff0c;右對齊方式。位數不夠&#xff0c;左邊補0。 %.2d&#xff1a;從執行效果來看&#xff0c;與%02d一樣…

計算機系統基礎 數據的表示和存儲

數制和編碼 1.信息的二進制編碼 2.進制轉換必須要知道: 1)使用哪一個進制(二,八…) 2)定點數還是浮點數(關于小數點的問題) 3)編碼問題----原碼,補碼,反碼,移碼 3.進制轉換 1)R進制轉十進制(按權展開) ----R進制 ----八進制與十六進制 ----R轉換為十進制 2)十進制轉換為R…

C++中vector章節iterator與const_iterator及const iterator區別

C目前傾向于使用迭代器遍歷容器中的元素&#xff0c;而不是使用下標訪問的方式來訪問容器中的元素。可以使用iterator和const_iterator來訪問元素&#xff0c;但是const類型的容器&#xff0c;那么只能用const_iterator來遍歷。區別在于iterator可以改變元素的數值&#xff0c;…

Android查看當前應用已經加載的so庫

源代碼&#xff1a; private static List<String> allSOLists new ArrayList<String>();/** * 獲取全部已加載的SO庫*/private void getAllSOLoaded(){allSOLists.clear();// 當前應用的進程IDint pid Process.myPid();String path "/proc/" pid &q…

Android 進程監控(top命令)

文章目錄一、查看top命令Android N&#xff08;7.1系統&#xff0c;level 25&#xff09; 及之前Android O&#xff08;8.0系統&#xff0c;level 26&#xff09; 及之后二、top -n [number]Android N&#xff08;7.1系統&#xff0c;level 25&#xff09; 及之前Android O&…

java 快速排序

快速排序 對冒泡排序的一種改進 思路: 一趟排序后,選取一個中間值,數組被分為比中間值小的部分,比中間值大的部分;再對左右兩部分分別遞歸排序 代碼實現 import java.util.Arrays;public class QuickSort {public static void main(String[] args) {int[] arr {-9, 78, 0, 2…

C++字符串的個人理解

String string是字符串&#xff0c;在聲明一個字符串的時候&#xff0c;比如string a;這個過程是在棧上進行的&#xff0c;但是如果給這個字符串分配內存空間&#xff0c;這段區間是存儲在堆上的&#xff0c;因此最好在聲明字符串的時候就要指出字符串的大小和對其進行初始化s…

Android 基礎性能數據獲取(/proc/)

一、系統內存 讀取命令&#xff1a; /proc/meminfoJava代碼&#xff1a; private void click(){try{String cmd "/proc/meminfo";BufferedReader reader new BufferedReader(new InputStreamReader(new FileInputStream(cmd)), 1000);StringBuilder sb new Stri…

物理 常見力與牛頓三定律

常用知識點 動量 dmvdmvdvm p-mv- f-dp-/dtma- 開普勒第三定律 r1^3__k只與恒星質量有關 T^2 總結 1.電梯勻速就相當于在地面,加速或減速就會有一個a 2.當合外力為0時,物體保持靜止或勻速直線運動 3.力是改變物體運動狀態的原因 4.重力在地球兩極最大,赤道最小,隨緯度…

Java命令:jmap — 打印指定進程的共享對象內存映射或堆內存細節

文章目錄一、前言二、命令介紹三、使用實例1、jmap -heap [pid]2、jmap -histo[:live] [pid]3、jmap -histo[:live] [pid] |grep "[關鍵字1]\|[關鍵字2]"4、jmap -dump:live,formatb,filea.log [pid]四、總結一、前言 jdk安裝后會自帶一些小工具&#xff0c;jmap命令…